tyneq
v1.0.3
Published
Lazy query pipelines for TypeScript.
Maintainers
Readme
import { Tyneq } from "tyneq";
const topScorers = Tyneq
.from([
{ name: "Ada", team: "core", score: 84 },
{ name: "Linus", team: "infra", score: 92 },
{ name: "Grace", team: "core", score: 97 },
])
.where((p) => p.team === "core")
.orderByDescending((p) => p.score)
.select((p) => `${p.name} (${p.score})`)
.toArray();
// ["Grace (97)", "Ada (84)"]Nothing runs until .toArray(). Every operator is deferred, fully typed, and the same query can be re-evaluated as many times as you want.
What is this?
Tyneq is a LINQ-style query pipeline library for TypeScript. You compose operators on a sequence, nothing executes until you call a terminal, and you can reuse the same query without rebuilding it.
It is not a thin wrapper around Array.prototype. It is a pipeline engine with a deliberate execution model, a real query plan system, and a plugin API that lets you ship custom operators as standalone packages.
Why Tyneq?
Sequences, not cursors
Most iterator libraries give you a one-shot cursor. Once you consume it, it is gone. Tyneq sequences are re-iterable by default - call any terminal as many times as you want, each gets independent state.
const active = Tyneq.from(users)
.where((u) => u.active)
.orderByDescending((u) => u.score);
active.count(); // 3
active.first().name; // "Grace"
active.select((u) => u.email).toArray(); // ["g@...", "l@...", "a@..."]No re-wrapping. No rebuilding. Same query, three independent evaluations.
You always know what is happening
Every operator is explicitly streaming (O(1) memory, one element at a time) or buffering (reads the full source once, then serves from a buffer). There is no hidden materialization and no guessing about when data gets copied.
Tyneq.from(largeDataset)
.where((x) => x.active) // streaming
.orderBy((x) => x.score) // buffering - reads all matching, sorts once
.take(10) // streaming - stops after 10
.toArray(); // terminal - kicks everything offQuery plans you can actually use
Every sequence carries a live description of its pipeline. Print it, walk it with a visitor, rewrite it with a transformer, or compile it back into an executable sequence.
import { QueryPlanPrinter, QueryPlanCompiler, QueryPlanOptimizer, tyneqQueryNode } from "tyneq";
const seq = Tyneq.from(data)
.where((x) => x > 0)
.where((x) => x < 100)
.select((x) => x * 2);
// Print the plan
console.log(QueryPlanPrinter.print(seq[tyneqQueryNode]!));
// from([...])
// -> where(<fn>)
// -> where(<fn>)
// -> select(<fn>)
// Compile with optimization - fuses the two where nodes
const compiler = new QueryPlanCompiler([new QueryPlanOptimizer()]);
compiler.compile(seq[tyneqQueryNode]!).toArray();The compiler turns plans into executable sequences. Store a pipeline as metadata, optimize it, swap the data source, replay it. Pipelines become data.
Extensible to the core
Custom operators look and behave exactly like built-ins. They get registered at import time, appear on every sequence, and show up in query plans.
import { createGeneratorOperator } from "tyneq";
createGeneratorOperator({
name: "repeatEach",
category: "streaming",
*generator(source: Iterable<unknown>, times: number) {
for (const item of source) {
for (let i = 0; i < times; i++) yield item;
}
},
validate(times) {
if (times < 1) throw new RangeError("times must be >= 1");
},
});
declare module "tyneq" {
interface TyneqSequence<T> {
repeatEach(times: number): TyneqSequence<T>;
}
}
Tyneq.from([1, 2, 3]).repeatEach(2).toArray();
// [1, 1, 2, 2, 3, 3]Two registration styles: functional (generators, factories, terminals) and class-based (decorators with full lifecycle). Ship it as a package - consumers import once and every sequence gains the operator.
Quick comparison
| | Tyneq | Typical iterator lib |
|---|---|---|
| Deferred execution | yes | yes |
| Re-iterable sequences | yes | often no |
| Explicit streaming vs. buffering | yes | usually implicit |
| Multi-key ordering (thenBy) | yes | varies |
| Joins and group joins | yes | rare |
| Built-in memoization | yes | rare |
| Custom operator plugin API | yes | rare |
| Query plan + compiler | yes | very rare |
| Zero runtime dependencies | yes | varies |
Install
npm install tyneqRequirements
- TypeScript 5.0 or higher
"strictNullChecks": truein your tsconfig
No @types package needed.
Stage 3 Decorators
tyneq is implemented with TC39 Stage 3 decorators - the standardized decorator proposal
that shipped in TypeScript 5.0 and requires no extra tsconfig flags. This is a different
model from the legacy experimentalDecorators syntax used by NestJS, Angular, and
InversifyJS.
Using built-in operators requires nothing from you. The decorator machinery lives
entirely inside tyneq's compiled output. Calling .where(), .select(), or any terminal
is just calling a plain method. No decorator support is needed on your side at all.
Using the class-based plugin API does require Stage 3 decorators in your project.
If you write custom operators using @operator, @terminal, @orderedOperator, or
@cachedOperator, your tsconfig must not have experimentalDecorators: true, because
TypeScript supports only one decorator model at a time. If your project already uses
the legacy model, the functional plugin API is fully equivalent and has no such constraint.
How the dist handles decorators
The published dist is compiled by tsup (esbuild) targeting es2017. esbuild
transforms Stage 3 decorator syntax into plain helper functions (__decorateClass,
__decorateElement) at build time, so the output works in every Node version, bundler,
and consumer project - including those with no native decorator support at all.
| Aspect | Detail |
|---|---|
| Raw @decorator syntax in dist | No - compiled to helper functions |
| Node 18+ | Works |
| Projects with experimentalDecorators: true | Works (built-ins and functional plugin API) |
| Extra bundle overhead | Small - one shared set of helpers per entry point |
Trade-offs to be aware of:
- Helper-function output is slightly larger than native decorator syntax would be. The helpers are small and shared across all operators within an entry point, so in practice the impact is minimal.
- Tools that understand Stage 3 decorator syntax natively (newer runtimes, bundlers) do not get any advantage from the raw syntax - they just run the helper functions instead.
- This is the correct approach for a published package right now. Shipping raw
@decoratorsyntax would silently break consumers on Node versions or bundlers that do not handle it.
Future: When TC39 Stage 3 decorators are universally supported in all target runtimes and bundlers, the build toolchain will be updated to pass decorator syntax through natively, removing the helper-function overhead. Until that point, the helper-based output is the safe and correct choice.
Compatibility
All projects: Built-in operators and sequences work without any decorator support. Import and use normally.
Projects with experimentalDecorators: true (NestJS, Angular, InversifyJS): The built-in
operators and the functional plugin API (createGeneratorOperator, createOperator, and
related helpers) work without any changes. The class-based decorator plugin API (@operator,
@terminal, and related decorators) cannot be used because TypeScript supports only one
decorator model at a time. Use the functional API instead - it is fully equivalent and the
recommended path in these projects.
Projects with TypeScript 5.0+ and no experimentalDecorators: Full access to everything,
including the class-based plugin API decorators.
Quick tour
import { Tyneq } from "tyneq";
// Wrap any iterable
Tyneq.from([1, 2, 3]);
Tyneq.from(new Set(["a", "b"]));
Tyneq.range(1, 5); // [1, 2, 3, 4, 5]
Tyneq.empty<number>();
// Compose operators - nothing runs yet
const query = Tyneq.range(1, 1_000_000)
.where((n) => n % 2 === 0)
.select((n) => n * n)
.take(5);
// Execute with a terminal
query.toArray(); // [4, 16, 36, 64, 100]
query.count(); // 5 - same query, independent traversal
query.first(); // 4
// Standard iteration works too
for (const n of query) console.log(n);
const arr = [...query];Multi-key sorting, grouping, joins - all built in:
Tyneq.from(employees)
.where((e) => e.department === "engineering")
.orderBy((e) => e.level)
.thenByDescending((e) => e.yearsAtCompany)
.groupBy(
(e) => e.team,
(e) => e.name,
(team, members) => ({ team, members: members.toArray() })
)
.toArray();Operators
80+ operators across three categories.
Streaming (O(1) memory)
select where take takeWhile takeUntil skip skipWhile skipLast skipUntil slice selectMany flatten append prepend concat zip scan pairwise window chunk split repeat defaultIfEmpty populate ofType tap tapIf throttle pipe
Buffering (reads full source once)
orderBy orderByDescending thenBy thenByDescending groupBy distinct distinctBy reverse shuffle union unionBy intersect intersectBy except exceptBy join groupJoin backsert memoize permutations
Terminal (executes the pipeline)
toArray toSet toMap toRecord toAsync first firstOrDefault last lastOrDefault single singleOrDefault elementAt elementAtOrDefault count countBy sum average min max minBy maxBy minMax aggregate any all contains indexOf sequenceEqual startsWith endsWith isNullOrEmpty consume
Query plan
Every sequence carries a query plan tree. Access it, print it, walk it, transform it, or compile it back into an executable sequence.
import { QueryPlanPrinter, tyneqQueryNode } from "tyneq";
const seq = Tyneq.from([1, 2, 3])
.where((x) => x > 1)
.select((x) => x * 2)
.take(5);
console.log(QueryPlanPrinter.print(seq[tyneqQueryNode]!));
// from([1, 2, 3])
// -> where(<fn>)
// -> select(<fn>)
// -> take(5)The QueryPlanCompiler takes any plan node and produces a fully executable sequence. Pass a source option to run the same pipeline against different data without rebuilding it:
const plan = Tyneq.from(data).where((x) => x > 0).select((x) => x * 2)[tyneqQueryNode]!;
const compiler = new QueryPlanCompiler();
compiler.compile(plan, { source: datasetA }).toArray();
compiler.compile(plan, { source: datasetB }).toArray();Store pipelines as metadata, optimize them, replay them on any source. See the Query Plan guide for the full picture.
Docs
chrisitopherus.github.io/tyneq
| | | |---|---| | Getting Started | Install, first query, sources, re-iteration | | Core Concepts | Execution model, streaming vs. buffering, memoization | | Operators | All 80+ operators with examples | | Custom Operators | Functional API and decorators | | Plugin Internals | Registry, custom enumerators, utility helpers | | Query Plan | Plan access, printing, walking, transforming, compiling | | Best Practices | Patterns, pitfalls, and performance guidance | | API Reference | Full generated API docs |
Stability
Tyneq follows Semantic Versioning. The public API contract covers:
- All methods on
TyneqSequence,TyneqOrderedSequence, andTyneqCachedSequence - All symbols exported from the
tyneq,tyneq/plugin, andtyneq/utilitysubpaths - The
Tyneqstatic factory class
Internal classes (TyneqEnumerableBase, TyneqEnumerableCore, and anything tagged @internal) are not part of the contract and may change between minor versions.
Contributing
git clone https://github.com/chrisitopherus/tyneq
npm install
npm run build # compile CJS + ESM + types
npm test # run test suite
npm run lint # check style
npm run docs:dev # local docs siteSee the Contributing guide for the full workflow, including how to add operators, run the test suite, and submit a PR.
Bug reports and feature requests: github.com/chrisitopherus/tyneq/issues
License
MIT Copyright (c) 2026 chrisitopherus
