JavaScript Basics: Build Lazy Pipelines with Iterator Helpers
Array methods usually produce an array immediately. Synchronous iterator helpers can instead describe a transformation and pull values only when a consumer asks. This is useful when you want just the first few matches or a single total without intermediate arrays.
These independent examples require Node.js 24 and were tested on Linux with v24.19.0. They use built-in Iterator helpers, not a third-party library or async iterators. Assertions check lazy execution, exhaustion, empty input, and invalid limits.
Prepare a scratch directory
You need Bash, a text editor, Node.js 24 on your PATH, and the mktemp, rm, and rmdir utilities. Keep the same terminal open. Save each named source file as UTF-8 in this directory; .mjs selects ES modules. The setup stops if creating or entering the scratch directory fails.
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested runtime prints:
v24.19.01. Transform only as far as needed
Save this as lazy-iterator-pipeline.mjs:
import assert from 'node:assert/strict';
let visited = 0;
const pipeline = Iterator.from([2, 3, 4, 5, 6])
.map(number => { visited += 1; return number * 10; })
.filter(number => number >= 30)
.take(2);
assert.equal(visited, 0);
console.log(`Visited before consuming: ${visited}`);
const selected = pipeline.toArray();
assert.deepEqual(selected, [30, 40]);
assert.equal(visited, 3);
assert.deepEqual(pipeline.toArray(), []);
console.log(`Selected: ${selected.join(', ')}`);
console.log(`Visited after consuming: ${visited}`);Run it:
node lazy-iterator-pipeline.mjsOutput:
Visited before consuming: 0
Selected: 30, 40
Visited after consuming: 3Iterator.from() obtains an iterator from this array. map(), filter(), and take() return lazy helpers. Creating the chain does not run the mapper. toArray() consumes it, so the counter then increases.
Only three source numbers are visited to produce two matches. Calling toArray() again gives an empty array because the same helper is exhausted. Build a fresh iterator for another pass. This pipeline shares its source’s position; it does not copy or fork an iterator. See the ECMAScript iterator helpers and Iterator reference.
2. Skip and limit a sequence explicitly
Save this as limit-iterator-values.mjs:
import assert from 'node:assert/strict';
const labels = ['A', 'B', 'C', 'D', 'E'];
const page = labels.values().drop(1).take(2).toArray();
assert.deepEqual(page, ['B', 'C']);
assert.deepEqual(labels.values().take(0).toArray(), []);
assert.deepEqual(labels.values().drop(99).toArray(), []);
assert.throws(() => labels.values().take(-1), RangeError);
assert.throws(() => labels.values().take(NaN), RangeError);
assert.deepEqual(labels, ['A', 'B', 'C', 'D', 'E']);
console.log(`Page: ${page.join(', ')}`);
console.log('Zero limit and oversized skip produce empty arrays');
console.log('Negative and NaN limits rejected');Run it:
node limit-iterator-values.mjsOutput:
Page: B, C
Zero limit and oversized skip produce empty arrays
Negative and NaN limits rejectedvalues() creates a fresh array iterator. drop(1) skips the first value, and take(2) allows the next two through. Neither changes the array. Each boundary check starts with a new iterator, keeping its state independent.
The helper API rejects negative limits and NaN; it converts other numeric limits according to its specification. For application pagination, validate a nonnegative safe integer yourself rather than depending on coercion or fractional truncation. A limit does not guarantee termination if an earlier filter keeps searching an endless source that never matches. See the take reference.
3. Reduce without an intermediate array
Save this as reduce-iterator-values.mjs:
import assert from 'node:assert/strict';
const quantities = new Map([['pens', 4], ['pads', 3], ['clips', 8]]);
const total = quantities.values().reduce((sum, count) => sum + count, 0);
const emptyTotal = Iterator.from([]).reduce((sum, count) => sum + count, 0);
assert.equal(total, 15);
assert.equal(emptyTotal, 0);
assert.throws(() => Iterator.from([]).reduce((a, b) => a + b), TypeError);
assert.equal(quantities.size, 3);
console.log(`Total quantity: ${total}`);
console.log(`Empty total: ${emptyTotal}`);
console.log('Empty reduction without an initial value rejected');Run it:
node reduce-iterator-values.mjsOutput:
Total quantity: 15
Empty total: 0
Empty reduction without an initial value rejectedUnlike map() or filter(), reduce() consumes the iterator immediately and returns one result. The initial 0 defines the empty-input answer and the first accumulator value. Without an initial value, an empty iterator cannot supply an accumulator and throws.
The Map remains intact; its temporary values iterator is what gets consumed. toArray() would allocate an array of every remaining item, while this reduction keeps only the running total. Neither approach makes an unbounded source finish. These helpers are synchronous: an async mapper would yield promises rather than automatically await their values. Keep async processing in an explicitly asynchronous workflow.
Clean up
In the same terminal, remove only the named files, return to the original directory, and remove the empty scratch directory:
rm -- lazy-iterator-pipeline.mjs limit-iterator-values.mjs reduce-iterator-values.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.