Iterator Helpers
Say you want the first five even squares. With array methods you write this:
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
.filter(n => n % 2 === 0) // [2, 4, 6, 8, 10]
.map(n => n * n) // [4, 16, 36, 64, 100]
.slice(0, 5); // [4, 16, 36, 64, 100]
Clean enough. But two things are quietly wrong with it. First, every step builds a brand-new array in memory — filter produces one, map produces another — even though you only asked for five numbers. Second, and worse: you had to start with a finite array. If your source were an endless stream of integers, .filter() would try to walk all of them before map ever ran, and the program would hang forever.
Iterator helpers fix both. They’re the same familiar verbs — map, filter, take, drop, reduce — but they live on iterators instead of arrays, and they pull values one at a time instead of materializing the whole collection at each step.
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
.values() // an iterator, not an array
.filter(n => n % 2 === 0)
.map(n => n * n)
.take(5)
.toArray(); // [4, 16, 36, 64, 100]
Looks almost identical. Behaves completely differently underneath.
Eager arrays vs lazy iterators
The difference is when the work happens. Array methods are eager: each one runs to completion and returns a full array before the next one starts. Iterator helpers are lazy: nothing runs until something asks for a value, and then exactly one value is dragged through the entire chain before the next is requested.
That single moving box is the whole idea. In the lazy version there is never a full “list of even numbers” sitting in memory. A value is pulled from the source, checked by filter, squared by map, counted by take, and pushed into the final array — and only then does the next value start its journey.
Where these methods live
The verbs hang off Iterator.prototype. So the real question is: how do you get your hands on an iterator to begin with? You already have several.
// Array, Map, Set — call .values() (or .keys() / .entries())
[10, 20, 30].values();
new Map([["a", 1]]).values();
new Set([1, 2, 3]).values();
// Any generator returns an iterator when called
function* nums() { yield 1; yield 2; yield 3; }
nums();
// A string's iterator, via Symbol.iterator
"hello"[Symbol.iterator]();
Each of those gives you an object that inherits the helper methods. If generators and iterables are still fuzzy, those pages cover the protocol in depth — here we assume you know that an iterator is an object with a .next() method returning { value, done }.
Iterator is now a real global, too. You can subclass it, and there’s a static adapter, Iterator.from(), for anything that doesn’t already inherit the helpers — more on that below.
The lazy producers
Five helpers return a new iterator rather than a value. They’re the pipeline stages. Because they’re lazy, stringing them together does no work at all — it just wires up the chain.
map and filter
Same shape you know from arrays. map(fn) transforms each value; filter(fn) keeps the ones where fn returns truthy.
const it = [1, 2, 3, 4].values()
.map(n => n * 10) // 10, 20, 30, 40 — lazily
.filter(n => n > 15); // 20, 30, 40 — lazily
console.log(it.next()); // { value: 20, done: false }
console.log(it.next()); // { value: 30, done: false }
Both callbacks get the value and a zero-based index as the second argument — handy, and something array filter also does:
"abcde"[Symbol.iterator]()
.map((ch, i) => `${i}:${ch}`)
.toArray();
// ["0:a", "1:b", "2:c", "3:d", "4:e"]
take and drop — the flow valves
These two are what make infinite sequences usable.
take(n) yields at most the first n values, then reports done. drop(n) skips the first n values and yields everything after.
[1, 2, 3, 4, 5].values().take(2).toArray(); // [1, 2]
[1, 2, 3, 4, 5].values().drop(2).toArray(); // [3, 4, 5]
On a finite source they’re mildly convenient. On an infinite source they’re essential — take is the valve that shuts the flow off so the program can actually terminate.
function* naturals() {
let n = 1;
while (true) yield n++; // never stops on its own
}
naturals()
.map(n => n * n)
.take(5)
.toArray();
// [1, 4, 9, 16, 25]
That generator loops forever, yet the program finishes instantly. take(5) pulls exactly five values through map and then closes the source. No while (true) runs away, because nothing ever asks for a sixth value.
Watching a real pull counter drives the point home. The generator below loops forever, but the pipeline only ever pulls as many values as take lets through. Move the slider and re-run — notice how many source values are actually touched.
flatMap
flatMap(fn) expects fn to return an iterator or iterable for each value, and it flattens those one level into the output stream.
const posts = [
{ title: "A", tags: ["js", "css"] },
{ title: "B", tags: ["js", "html"] },
];
posts.values()
.flatMap(p => p.tags) // each array is iterated in place
.toArray();
// ["js", "css", "js", "html"]
It’s the tool for “expand each item into several, then keep going” — flattening nested structures without building the intermediate array-of-arrays that array.flatMap would.
Toggle which lists to include, then flatten them into one stream. Each list’s inner array is iterated in place — flatMap splices them together end to end.
Watch one value walk the chain
The pull model is easier to see than to describe. Here’s a three-stage lazy pipeline; step through it and notice how each next() drives exactly one value all the way down before returning.
The last step is the payoff: values 5 and 6 were never read. take short-circuited the entire chain the moment it had enough. An eager filter().map().slice() would have processed all six no matter what.
The terminal consumers
The other group of helpers ends the pipeline. They pull values until the source is exhausted (or they’ve seen enough) and return a real value — an array, a number, a boolean, an element. After calling one, the iterator is spent.
| Method | Returns | Pulls the whole source? |
|---|---|---|
toArray() |
an Array |
yes |
reduce(fn, init?) |
accumulated value | yes |
forEach(fn) |
undefined |
yes |
find(fn) |
first match or undefined |
stops at first match |
some(fn) |
boolean |
stops at first truthy |
every(fn) |
boolean |
stops at first falsy |
toArray — materialize when you actually need an array
Lazy pipelines are great for computing, but sometimes you genuinely need an array — to return from a function, to JSON.stringify, to hand to code that expects one. toArray() runs the chain to completion and collects the results.
const firstThreePrimes = primes() // some infinite prime generator
.take(3)
.toArray();
// [2, 3, 5]
Reach for toArray() at the end, once you’ve bounded the stream. Calling it on an unbounded source never returns.
reduce, forEach
These mirror their array cousins, but skip the intermediate array. reduce is especially nice — folding a Map’s values without spreading them first:
const deposits = new Map([["Anne", 1000], ["Bert", 1500], ["Carl", 2000]]);
// No [...deposits.values()] spread needed:
const total = deposits.values().reduce((sum, n) => sum + n, 0);
// 4500
forEach runs a side effect per value and returns undefined — same as the array version, just without allocating the array first.
find, some, every — the short-circuiters
This trio stops the instant it knows the answer, which pairs beautifully with laziness. find on an infinite generator is completely reasonable:
function* fib() {
let a = 1, b = 1;
while (true) { yield a; [a, b] = [b, a + b]; }
}
const firstOver100 = fib().find(n => n > 100);
// 144
You cannot write [...fib()].find(...) — the spread would hang forever trying to build the array. The iterator version pulls Fibonacci numbers one at a time and returns the moment one exceeds 100.
Set your own threshold and watch how few values find actually pulls before it hands back the answer and abandons the infinite source.
Iterator.from — adapting anything
Some iterators in the wild are hand-rolled: a plain object with a next() method that doesn’t inherit from Iterator.prototype. Legacy APIs and some library types look like this. They have no .map, no .take.
Iterator.from(x) wraps such a value so it gains the full helper set. It accepts an iterable (something with Symbol.iterator) or a bare iterator-like object, and hands back a proper iterator.
// A hand-rolled iterator with no helper methods:
const clock = {
n: 0,
next() { return { value: this.n++, done: false }; },
};
// clock.map(...) -> TypeError, no such method
Iterator.from(clock)
.take(3)
.toArray();
// [0, 1, 2]
If you pass it something that already inherits the helpers, Iterator.from returns it as-is rather than wrapping twice. It’s the safe adapter to normalize “some iterator-ish thing” into one you can chain on.
The gotchas
Laziness buys power, and power comes with sharp edges. A few that will bite you.
An iterator is single-use. Once consumed, it’s empty. Chaining a helper doesn’t copy the source — it wraps it. So the wrapper and the original draw from the same well:
const it = [1, 2, 3].values();
const doubled = it.map(n => n * 2);
it.next(); // { value: 1, done: false } — pulled from the source
doubled.next(); // { value: 4, done: false } — that's 2*2, the *next* source value
it.next(); // { value: 3, done: false }
Once you’ve handed an iterator to a helper, stop using the original. Treat the wrapping as taking ownership.
Terminal methods drain it. After toArray(), reduce(), or a for...of, the iterator is done. Call toArray() twice and the second returns []. If you need the data more than once, materialize it to an array early and iterate the array. Run this to see the second call come back empty:
They’re not always faster on tiny arrays. For a ten-element array that you fully consume, eager array methods can edge out the iterator machinery — there’s per-value overhead in the wrapping. The lazy version wins decisively when you process far fewer items than the source contains, or when the source can’t fit in memory at all. Don’t rewrite every .map() reflexively.
Availability
The proposal reached Stage 4 in late 2024 and shipped in ES2025. It became Baseline Newly available on 31 March 2025, meaning the current versions of Chrome, Edge, Firefox, and Safari all support it — as do Node 22 LTS and 24, Deno 2, and Bun 1.1.31+.
Summary
- Iterator helpers put
map,filter,take,drop,flatMap,reduce,toArray,forEach,some,every, andfindonIterator.prototype— get an iterator via.values(), a generator call,Symbol.iterator, orIterator.from(). - The producers (
map,filter,take,drop,flatMap) return a new lazy iterator and do no work until pulled. The consumers (toArray,reduce,forEach,find,some,every) run the chain and return a value. - Lazy means one value at a time through the whole chain — no intermediate arrays, flat memory, and free short-circuiting.
take(n)bounds an infinite generator so it terminates;find/some/everystop at the first decisive value.dropand never-matchingfilterstill hang on infinite sources because they must pull to search.- An iterator is single-use and shared with its wrappers — once a helper or terminal touches it, don’t reuse the original.
- Reach for helpers on large, streaming, or infinite data and when short-circuiting; keep plain array methods for small finite arrays you want as arrays anyway.
- ES2025, Baseline Newly available since March 2025 — great on modern Node today; polyfill or feature-detect for older browsers.