Plugin Internals
This guide is for people who want to understand how operator registration works under the hood, how to build custom sequence types, and how to wire bridge methods that let users enter your custom sequence from a regular one.
If you just want to add a custom operator, start with Custom Operators - that covers everything for the common cases. Come back here when you want the full architecture.
How registration works
When you call any registration function (createGeneratorOperator, @operator, etc.), four things happen in order:
- Metadata is created - name, kind, category, source (
"internal"or"external"), and the target class to patch onto. - Guards run - any guards added via
OperatorRegistry.addGuardare called synchronously. If a guard throws, registration is aborted. - The entry is stored in
OperatorRegistry, keyed by name, with the metadata and the implementation function. - The method is patched onto the target class's prototype. For standard operators, that is
TyneqEnumerableBase. For specialized operators, it isTyneqOrderedEnumerableorTyneqCachedEnumerable.
All of this happens at module import time, as a side effect:
// Before import: Tyneq.from([]).myOp is undefined
import "./my-plugin/myOp"; // registration runs here
// After import: Tyneq.from([]).myOp is a functionWhich prototype gets patched
The targetClass in OperatorMetadata determines where the method lands:
| API / Decorator | Patches prototype of | Available on |
|---|---|---|
createGeneratorOperator | TyneqEnumerableBase | All sequences |
createOperator | TyneqEnumerableBase | All sequences |
createTerminalOperator | TyneqEnumerableBase | All sequences |
@operator | TyneqEnumerableBase | All sequences |
@terminal | TyneqEnumerableBase | All sequences |
createOrderedOperator | TyneqOrderedEnumerable | Ordered sequences only |
@orderedOperator | TyneqOrderedEnumerable | Ordered sequences only |
createCachedOperator | TyneqCachedEnumerable | Cached sequences only |
@cachedOperator | TyneqCachedEnumerable | Cached sequences only |
Since TyneqOrderedEnumerable and TyneqCachedEnumerable both extend TyneqEnumerableBase, they inherit all base-level operators too. The specialized APIs add methods that appear only on their subtype. TypeScript enforces this at compile time via the TyneqOrderedSequence and TyneqCachedSequence interfaces.
The sequence hierarchy
Understanding how sequences work internally is essential for building custom sequence types. Here is the full class hierarchy:
TyneqEnumerableCore<T> (abstract - adds orderBy, memoize, pipe)
|
+-- TyneqEnumerableBase<T> (abstract - adds all 55+ operators)
|
+-- TyneqEnumerable<T> (concrete - standard sequences)
+-- TyneqOrderedEnumerable<T> (concrete - ordered sequences)
+-- TyneqCachedEnumerable<T> (concrete - cached sequences)Factory methods
The key to extending the hierarchy is three abstract factory methods on TyneqEnumerableCore:
protected abstract createEnumerable<TResult>(
factory: EnumeratorFactory<TResult>,
node: Nullable<QueryPlanNode>
): TyneqSequence<TResult>;
protected abstract createOrderedEnumerable<TKey>(
keySelector: (x: TSource) => TKey,
comparer: Comparer<TKey>,
descending: boolean,
node: Nullable<QueryPlanNode>
): TyneqOrderedSequence<TSource>;
protected abstract createCachedEnumerable(
source: TyneqSequence<TSource>,
node: Nullable<QueryPlanNode>
): TyneqCachedSequence<TSource>;Every operator in TyneqEnumerableBase delegates to createEnumerable() to produce its output sequence. This is what allows different sequence types to control which concrete class gets instantiated.
For example, when you call .where() on a TyneqEnumerable, it calls this.createEnumerable(...) which returns a new TyneqEnumerable. When you call .where() on a TyneqOrderedEnumerable, it calls the same abstract method - but the ordered class overrides it to return a TyneqEnumerable too (ordered-ness does not survive a where).
The SequenceFactory interface
The factory methods are protected - they are not part of the public API. But the registration machinery needs to call them. This is solved via the SequenceFactory structural interface:
// types/core.ts
export interface SequenceFactory<TSource> {
readonly [tyneqQueryNode]: Nullable<QueryPlanNode>;
createEnumerable(factory: { getEnumerator(): unknown }, node?: Nullable<QueryPlanNode>): unknown;
createOrderedEnumerable<TKey>(...): TyneqOrderedSequence<TSource>;
createCachedEnumerable(...): TyneqCachedSequence<TSource>;
}This is a structural cast - it works because TyneqEnumerableBase has exactly those methods (they are just protected). The internal RegistrationUtility class centralizes this cast, but it is @internal and is not exported from any public subpath - plugin authors cannot import it directly.
Internal helper
RegistrationUtility is not part of the public API. The two helpers it provides (buildEnumerable and buildQueryNode) are used by the library's own registration machinery. In your plugin code, use new QueryNode(...) directly (it is exported) and cast via SequenceFactory<TSource> as shown in the examples below.
How bridge methods work
A "bridge method" is an operator on one sequence type that returns a different sequence type. For example:
orderBy()on a regular sequence returns aTyneqOrderedSequencememoize()on any sequence returns aTyneqCachedSequence
Here is how orderBy works internally:
// In TyneqEnumerableCore:
public orderBy<TKey>(keySelector, comparer?): TyneqOrderedSequence<TSource> {
return this.createOrderedEnumerable(
keySelector,
comparer ?? TyneqComparer.defaultComparer,
false,
this.createNode("orderBy", "buffer", [keySelector])
);
}And each subclass overrides createOrderedEnumerable:
// In TyneqEnumerable:
protected createOrderedEnumerable<TKey>(...): TyneqOrderedSequence<TSource> {
return new TyneqOrderedEnumerable(this, keySelector, comparer, descending, undefined, node);
}The result: calling .orderBy() on any sequence produces a TyneqOrderedEnumerable, which has thenBy() and thenByDescending() methods that only ordered sequences carry.
Building a custom sequence type
This is where it gets interesting. Let's build a ValidatedSequence - a sequence that carries a validation function and enforces it on every element.
Step 1: Define the sequence class
Your custom sequence extends TyneqEnumerableBase and implements the three abstract factory methods:
Internal API
TyneqEnumerableBase, TyneqEnumerable, TyneqOrderedEnumerable, and TyneqCachedEnumerable are internal concrete classes. They are not exported from any public subpath. Building a custom sequence type requires access to these internals - this is an advanced use case intended for contributors or library authors who vendor Tyneq and ship their own distribution.
All public-facing types (TyneqSequence, Enumerator, Comparer, etc.) are available from "tyneq" as usual.
// Internal imports - not available from any public subpath.
// These are shown for documentation purposes only.
// import { TyneqEnumerableBase } from "<tyneq-internals>";
// import { TyneqEnumerable } from "<tyneq-internals>";
// import { TyneqOrderedEnumerable } from "<tyneq-internals>";
// import { TyneqCachedEnumerable } from "<tyneq-internals>";
import type {
Enumerator, EnumeratorFactory, TyneqSequence,
TyneqOrderedSequence, TyneqCachedSequence, Comparer,
} from "tyneq";
import { tyneqQueryNode } from "tyneq";
import type { QueryPlanNode } from "tyneq";
import type { Nullable } from "tyneq";
export class ValidatedEnumerable<TSource> extends TyneqEnumerableBase<TSource> {
private readonly source: TyneqSequence<TSource>;
private readonly validator: (item: TSource) => boolean;
private readonly label: string;
public readonly [tyneqQueryNode]: Nullable<QueryPlanNode>;
public constructor(
source: TyneqSequence<TSource>,
validator: (item: TSource) => boolean,
label: string,
node: Nullable<QueryPlanNode>
) {
super();
this.source = source;
this.validator = validator;
this.label = label;
this[tyneqQueryNode] = node;
}
public override getEnumerator(): Enumerator<TSource> {
const sourceEnum = this.source[Symbol.iterator]();
const validator = this.validator;
const label = this.label;
return {
next(): IteratorResult<TSource> {
const result = sourceEnum.next();
if (!result.done && !validator(result.value)) {
throw new Error(
`Validation failed [${label}]: element did not pass the check`
);
}
return result;
},
return(value?: unknown): IteratorResult<TSource> {
return sourceEnum.return?.(value) ?? { done: true, value: undefined };
},
};
}
// When an operator is called on a ValidatedSequence,
// the result should be a regular sequence - validation was already applied.
// TyneqEnumerable is not exported publicly. Use the SequenceFactory cast pattern
// to call createEnumerable on the parent instead, or keep the ValidatedSequence
// hierarchy by returning `new ValidatedEnumerable(...)` here.
protected override createEnumerable<TResult>(
factory: EnumeratorFactory<TResult>,
node: Nullable<QueryPlanNode>
): TyneqSequence<TResult> {
// Delegate to the parent implementation via the SequenceFactory cast.
return (this as unknown as import("tyneq").SequenceFactory<TResult>).createEnumerable(factory, node) as TyneqSequence<TResult>;
}
protected override createOrderedEnumerable<TKey>(
keySelector: (x: TSource) => TKey,
comparer: Comparer<TKey>,
descending: boolean,
node: Nullable<QueryPlanNode>
): TyneqOrderedSequence<TSource> {
return new TyneqOrderedEnumerable(
this, keySelector, comparer, descending, undefined, node
);
}
protected override createCachedEnumerable(
source: TyneqSequence<TSource>,
node: Nullable<QueryPlanNode>
): TyneqCachedSequence<TSource> {
return new TyneqCachedEnumerable(source, node);
}
}A few design decisions to notice:
getEnumerator()wraps the source enumerator with validation logic. Every element is checked before being yielded.createEnumerable()returns a plainTyneqEnumerable, not anotherValidatedEnumerable. This means operators chained aftervalidate()produce regular sequences. The validation was already baked into the enumerator. If you wanted validation to propagate through the chain, you would return aValidatedEnumerableinstead.- The query node is stored so the validation step appears in query plans.
Step 2: Register a bridge method
Now we need a way for users to enter the ValidatedSequence from a regular sequence. We register a method on TyneqEnumerableBase that creates a ValidatedEnumerable:
import { OperatorRegistry } from "tyneq/plugin";
import { OperatorMetadata, QueryNode, tyneqQueryNode } from "tyneq";
// TyneqEnumerableBase is internal - not importable from a public subpath.
// In a real implementation that has access to internals, use it as the target class.
// Here we cast `this` to access the query node and create the sequence.
OperatorRegistry.register({
metadata: OperatorMetadata.streaming("validate"), // patches onto all sequences
impl: function (
this: { readonly [tyneqQueryNode]: unknown; [key: string]: unknown },
validator: (item: unknown) => boolean,
label: string = "validate"
) {
if (typeof validator !== "function") {
throw new TypeError("validator must be a function");
}
const parentNode = this[tyneqQueryNode] as import("tyneq").QueryPlanNode | null;
const node = new QueryNode("validate", [validator, label], parentNode, "streaming");
return new ValidatedEnumerable(
this as unknown as import("tyneq").TyneqSequence<unknown>,
validator,
label,
node
);
},
});Step 3: Add TypeScript types
declare module "tyneq" {
interface TyneqSequence<T> {
validate(
validator: (item: T) => boolean,
label?: string
): TyneqSequence<T>; // or a custom ValidatedSequence<T> interface
}
}Step 4: Use it
import { Tyneq } from "tyneq";
import "./validated-sequence"; // registration runs on import
const data = [1, 2, 3, -1, 5];
// This throws on the -1 during iteration
Tyneq.from(data)
.validate((x) => x > 0, "positive check")
.toArray();
// Error: Validation failed [positive check]: element did not pass the check
// This works fine
Tyneq.from([1, 2, 3])
.validate((x) => x > 0)
.select((x) => x * 2)
.toArray();
// [2, 4, 6]
// Shows up in query plans
import { QueryPlanPrinter, tyneqQueryNode } from "tyneq";
const seq = Tyneq.from([1, 2, 3]).validate((x) => x > 0).select((x) => x * 2);
console.log(QueryPlanPrinter.print(seq[tyneqQueryNode]!));
// from([1, 2, 3])
// -> validate(<fn>, "validate")
// -> select(<fn>)Step 5: Register operators specific to ValidatedSequence
If you want operators that only appear on ValidatedEnumerable (not on all sequences), pass your class as the targetClass:
OperatorRegistry.register({
metadata: new OperatorMetadata(
"revalidate",
"streaming",
"external",
ValidatedEnumerable // only appears on ValidatedEnumerable instances
),
impl: function (
this: ValidatedEnumerable<unknown>,
newValidator: (item: unknown) => boolean,
label: string = "revalidate"
) {
// Create a new ValidatedEnumerable with the new validator
const node = RegistrationUtility.buildQueryNode(this, "revalidate", [newValidator, label], "streaming");
return new ValidatedEnumerable(
this as unknown as TyneqSequence<unknown>,
newValidator,
label,
node
);
},
});This method only shows up on ValidatedEnumerable instances - not on regular sequences. TypeScript enforces this through module augmentation on a custom interface.
OperatorRegistry
OperatorRegistry is the central catalog of all operators - built-in and external. Use it for introspection, governance, and test isolation.
Querying the registry
import { OperatorRegistry } from "tyneq/plugin";
// All registered operators
OperatorRegistry.list();
// Filter by kind
OperatorRegistry.listByKind("streaming"); // streaming operators
OperatorRegistry.listByKind("terminal"); // terminal operators
// Filter by source
OperatorRegistry.listBySource("internal"); // built-in operators
OperatorRegistry.listBySource("external"); // plugin/custom operators
// Point queries
OperatorRegistry.has("repeatEach"); // boolean
OperatorRegistry.get("select"); // OperatorEntry | undefined
OperatorRegistry.count(); // total countEach OperatorEntry contains:
metadata.name- the operator namemetadata.kind-"streaming","buffer","terminal", etc.metadata.source-"internal"or"external"metadata.targetClass- the class whose prototype was patchedimpl- the actual function that was patched onto the prototype
Guards
Guards run synchronously before every external registration and can block it by throwing. Use them to enforce naming policies:
const removeGuard = OperatorRegistry.addGuard((entry) => {
if (
entry.metadata.source === "external" &&
!entry.metadata.name.startsWith("mylib_")
) {
throw new PluginError(
`External operators must be prefixed with 'mylib_'. Got: '${entry.metadata.name}'`
);
}
});
// Call removeGuard() to detach it when no longer needed
removeGuard();Post-registration hooks
Observe-only callbacks that run after a successful registration. Cannot block.
const unsubscribe = OperatorRegistry.onRegister((entry) => {
console.log(`Registered: ${entry.metadata.name} (${entry.metadata.kind})`);
});
unsubscribe(); // detachUnregistering
Remove a registered operator. Useful in tests for cleanup.
OperatorRegistry.unregister("mylib_slidingAverage");Unregistering deletes the prototype method (for external operators) and removes the registry entry. Internal operators keep their prototype method - only the entry is removed.
Source operators
Source operators produce the root sequence (like Tyneq.from or Tyneq.range). They are not patched onto any prototype - they are static factories. Register them with registerSource:
OperatorRegistry.registerSource("fibonacci", (count: number) => {
function* fib(n: number) {
let a = 0, b = 1;
for (let i = 0; i < n; i++) {
yield a;
[a, b] = [b, a + b];
}
}
return Tyneq.from({ [Symbol.iterator]: () => fib(count) });
});Source operators registered this way are automatically compilable by QueryPlanCompiler - no changes to the compiler needed.
Test isolation
Cross-test contamination is a common issue with prototype-patched registrations. Clean up after yourself:
import { Tyneq } from "tyneq";
import { OperatorRegistry, createGeneratorOperator } from "tyneq/plugin";
import { afterEach, it, expect } from "vitest";
let registered: string | null = null;
afterEach(() => {
if (registered) {
OperatorRegistry.unregister(registered);
registered = null;
}
});
it("repeatEach works", () => {
registered = "test_repeatEach";
createGeneratorOperator({
name: registered,
category: "streaming",
*generator(source: Iterable<unknown>, times: number) {
for (const item of source) {
for (let i = 0; i < times; i++) yield item;
}
},
});
const result = (Tyneq.from([1, 2]) as any)[registered](2).toArray();
expect(result).toEqual([1, 1, 2, 2]);
});OperatorMetadata
Every registered operator carries an OperatorMetadata instance. It describes the operator for the registry, query plan, and compiler.
| Field | Type | Meaning |
|---|---|---|
name | string | Operator name as it appears on sequences and in query plans |
kind | OperatorKind | "streaming", "buffer", "terminal", "source", "cache", "extension", "unknown" (fallback, not produced by any factory) |
source | OperatorSource | "internal" (built-in) or "external" (plugin) |
targetClass | SequenceConstructor | undefined | The class whose prototype gets patched. undefined for source operators. |
extensions | Record<string, unknown> | Custom metadata for introspection. Free-form dictionary for plugin-specific data. |
Convenience factory methods:
OperatorMetadata.streaming("myOp"); // kind="streaming", target=TyneqEnumerableBase
OperatorMetadata.buffer("myOp"); // kind="buffer", target=TyneqEnumerableBase
OperatorMetadata.terminal("myOp"); // kind="terminal", target=TyneqEnumerableBase
OperatorMetadata.source("mySource"); // kind="source", no target
OperatorMetadata.streaming("myOp", MyCustomSequence); // target=MyCustomSequenceUtility helpers
These are used extensively in built-in operators and are available for custom operators too.
ArgumentUtility
A facade over guards for common argument validation. Pass a single-key object so error messages include the argument name automatically:
import { ArgumentUtility } from "tyneq";
function validateWindow(windowSize: number): void {
ArgumentUtility.checkNotOptional({ windowSize }); // throws if null or undefined
ArgumentUtility.checkInteger({ windowSize }); // throws if not an integer
ArgumentUtility.checkPositive({ windowSize }); // throws if <= 0
}Available checks:
| Method | Validates |
|---|---|
checkNotNull | Not null |
checkNotUndefined | Not undefined |
checkNotOptional | Not null and not undefined |
checkNotNullOrWhiteSpace | String is present and not empty/whitespace |
checkNonNegative | Number >= 0 |
checkPositive | Number > 0 |
checkNegative | Number < 0 |
checkInRange | Number within [min, max] |
checkInteger | Number.isInteger() |
checkFinite | Number.isFinite() |
checkSafeInteger | Number.isSafeInteger() |
checkArrayIndex | Non-negative integer, optionally within array bounds |
checkFunction | Is a function |
checkIterable | Implements Symbol.iterator |
checkInstanceOf | Is instance of a given class |
check | Custom predicate with custom message |
ValidationBuilder
Collects multiple validation checks and throws a single ValidationError with all failures:
import { ValidationBuilder } from "tyneq";
new ValidationBuilder()
.check(() => ArgumentUtility.checkNotOptional({ selector }))
.check(() => ArgumentUtility.checkPositive({ count }))
.check(() => ArgumentUtility.checkFunction({ processor }))
.throwIfAny();
// -> ValidationError: 2 validation error(s):
// 1. selector must not be null or undefined
// 2. count must be positiveTypeGuardUtility
Type guard helpers for runtime checks:
import { TypeGuardUtility } from "tyneq";
TypeGuardUtility.isIterable(value); // value is Iterable<unknown>
TypeGuardUtility.isIterator(value); // value is Iterator<unknown>
TypeGuardUtility.isEnumerable(value); // value is Enumerable<unknown>
TypeGuardUtility.isEnumerator(value); // value is Enumerator<unknown>TyneqComparer
Tested, stable comparers for ordering logic. Prefer these over inline comparers:
import { TyneqComparer } from "tyneq";
TyneqComparer.defaultComparer; // generic <, > comparison
TyneqComparer.createLocaleComparer(); // locale-aware string comparison
TyneqComparer.caseInsensitiveComparer; // case-insensitive ordering
TyneqComparer.caseInsensitiveEqualityComparer; // case-insensitive equality
TyneqComparer.reverse(cmp); // reverses any Comparer<T>
TyneqComparer.defaultEqualityComparer; // === equalityDebugging plugin issues
If a custom operator does not appear on sequences:
- Confirm the module is imported. Side-effect imports are tree-shaken if unused. Make sure the import is present and not dead-code eliminated.
- Check
OperatorRegistry.has("yourOperator")- iffalse, registration did not run. - Look for guard failures. Add
OperatorRegistry.onRegister((e) => console.log(e))before importing the plugin. - Verify the
declare module "tyneq"block is in scope for TypeScript (included intsconfig.jsonincludes or referenced directly). - Check for name collisions - registering a name that already exists throws
RegistryError.
If behavior is wrong at runtime, print the query plan:
import { QueryPlanPrinter, tyneqQueryNode } from "tyneq";
const seq = Tyneq.from(data).myOp(args);
console.log(QueryPlanPrinter.print(seq[tyneqQueryNode]!));
// Verify myOp appears at the expected position with the right argsPlugin packaging pattern
A practical layout for distributing a plugin as an npm package:
my-plugin/
src/
operators/
mylib_smoothing.ts # one file per operator
mylib_percentile.ts
sequences/
ValidatedEnumerable.ts # custom sequence type (if any)
index.ts # imports all operators for side effects
package.json// src/index.ts
export * from "./operators/mylib_smoothing";
export * from "./operators/mylib_percentile";
export type { MySmoothingOptions } from "./operators/mylib_smoothing";// Consumer - once in entry point
import "@my-org/tyneq-plugin-analytics";
// Now available everywhere
Tyneq.from(data).mylib_smoothing({ window: 5 }).toArray();