advanced

Iterator Helpers in JavaScript (ES2025): Lazy map, filter, take

ES2025 iterator helpers explained — map, filter, take, drop on any iterator, lazy evaluation vs array chaining, generators, and interview implementations.


What changed

Until ES2025, map/filter/reduce lived only on Array.prototype. To transform an iterator — a generator, a Map's entries, a stream of ids — you either spread it into an array first ([...iter].map(...), materializing everything) or wrote generator plumbing by hand. Iterator helpers put the familiar methods directly on iterators, evaluated lazily:

function* naturals() {
  let n = 1;
  while (true) yield n++;
}
 
const result = naturals()      // infinite source — no problem
  .filter((n) => n % 3 === 0)
  .map((n) => n * n)
  .take(4)
  .toArray();                  // [9, 36, 81, 144]

The full set: map, filter, take, drop, flatMap, forEach, reduce, toArray, some, every, find — plus Iterator.from() to wrap any iterable.

Lazy vs eager: the interview point

Array chaining is eager: each step builds a full intermediate array.

huge.map(f).filter(g).slice(0, 10);
// walks all of huge twice, allocates two intermediate arrays

Iterator chaining is lazy: each element flows through the whole pipeline one at a time, and take(10) stops the source after ten survivors:

Iterator.from(huge).map(f).filter(g).take(10).toArray();
// touches only as many elements as needed to produce 10 results

When an interviewer asks "how would you process a 10-million-row dataset's first 100 matches," this is the answer — O(matches) work instead of O(n) allocations, constant memory.

They work on everything iterable

const scores = new Map([["ada", 92], ["alan", 77], ["grace", 98]]);
 
const top = scores.entries()
  .filter(([, score]) => score >= 90)
  .map(([name]) => name)
  .toArray(); // ["ada", "grace"]

No more [...map.entries()] just to chain a filter.

Implement them yourself (the actual interview question)

Before ES2025, this was a popular generator exercise — and it still is, phrased as "polyfill take and map for iterators":

function* mapIter(iter, fn) {
  for (const value of iter) {
    yield fn(value);
  }
}
 
function* takeIter(iter, limit) {
  if (limit <= 0) return;
  let count = 0;
  for (const value of iter) {
    yield value;
    if (++count >= limit) return; // also triggers source cleanup via .return()
  }
}
 
// Composition works exactly like the built-ins:
[...takeIter(mapIter(naturals(), (n) => n * 2), 3)]; // [2, 4, 6]

Talking points while you write it:

  • for...of handles calling next() and, on early exit, the iterator's return() — which is how generators run their finally blocks (resource cleanup).
  • The built-in helpers do the same: take closes the underlying iterator when the limit is hit.
  • These are iterator helpers, not iterable helpers: the result is consumed once; call the source again for a fresh pass.

Async iterator helpers

The async counterparts (AsyncIterator.prototype.map and friends) are on their own track. For now, the interview-safe way to transform an async stream is an async generator:

async function* mapAsync(source, fn) {
  for await (const item of source) {
    yield fn(item);
  }
}

If the conversation heads toward async streams, cancellation, and backpressure, bridge to AbortController and batching promises.

Follow-ups to expect

  • "Why does .map on an iterator not return an array?" — laziness; nothing runs until consumption (toArray, for...of, spread).
  • "What happens if you reuse a helper chain?" — it's exhausted; iterators are single-pass, unlike arrays.
  • "Where do generators fit?" — generators are the easiest way to produce iterators; helpers are the standard way to transform them. See also the event loop for how for await scheduling behaves.

  • Object.groupBy, Promise.withResolvers, Set methods, iterator helpers, and the other additions worth knowing in interviews.