Skip to content
JavaScript Basics: Build Lazy Pipelines with Iterator Helpers

JavaScript Basics: Build Lazy Pipelines with Iterator Helpers

October 21, 2026

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 1

The tested runtime prints:

v24.19.0

1. 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.mjs

Output:

Visited before consuming: 0
Selected: 30, 40
Visited after consuming: 3

Iterator.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.mjs

Output:

Page: B, C
Zero limit and oversized skip produce empty arrays
Negative and NaN limits rejected

values() 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.mjs

Output:

Total quantity: 15
Empty total: 0
Empty reduction without an initial value rejected

Unlike 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_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on