skip to content

yield* Delegation and Lazy Sequences

yield* hands control to another iterable, letting you compose small generators into a pipeline the way you compose array methods — except nothing is materialized. That makes infinite sequences and huge streams practical, and it is the punchline interviewers are listening for when they ask why generators exist.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

5

In JavaScript, you need the first five results of mapping and filtering an unbounded sequence of numbers. Why do chained array methods fail here, and how does a generator pipeline solve it?

level: middleimportance: must knowfreq 50%

answer

  1. array methods need a finished array
  2. pull, don't push
  3. take is what makes it terminate
  4. one value in flight, not one array per stage
  5. never spread an endless generator

basics

~20 s

Array map and filter are eager: they need a finished array and build a new one at every stage, so an unbounded source never gets past the first call. Generators are pull-based, so a take stage requests only the five values it needs.

solid answer

~50 s

Array methods are eager and whole-collection: `map` and `filter` walk the entire input and allocate a new array each, so you can only call them on a sequence that has already been fully materialized. An unbounded source can never be materialized, so the pipeline hangs or exhausts memory before your `slice(0, 5)` ever runs. A generator pipeline inverts the control: each stage is a `function*` that takes an iterable and yields transformed values one at a time, and nothing runs until a consumer pulls. Put a `take(5)` stage at the end and exactly five values are pulled through the whole chain — the source generator is asked for values only as far as the filter needs to find five matches, and no intermediate array is ever created. That is the practical answer to "why do generators exist": laziness plus composition.

code

javascript · 24 lines
javascript
function* naturals() {
  let n = 1;
  while (true) yield n++;
}

function* map(iterable, fn) {
  for (const x of iterable) yield fn(x);
}

function* filter(iterable, predicate) {
  for (const x of iterable) if (predicate(x)) yield x;
}

function* take(iterable, n) {
  if (n <= 0) return;
  let i = 0;
  for (const x of iterable) {
    yield x;
    if (++i === n) return;
  }
}

const result = [...take(filter(map(naturals(), n => n * n), n => n % 2 === 0), 5)];
console.log(result); // [ 4, 16, 36, 64, 100 ]

go deeper

for a junior

Know that array map and filter run over the whole array and build a new one each time, so they cannot be pointed at a sequence that never ends.

for a middle

Write the map, filter and take generator stages from memory and explain the pull order: one next() at the end of the chain drives exactly one value through every stage.

for a senior

Show the memory and early-exit argument on real numbers, warn that materializing consumers like spread must come after take, and note that pipelines are single-use.

for a principal

Decide when a lazy sequence layer is worth introducing at all, weighing constant memory and early exit against readability, debuggability and the per-item overhead the team will pay everywhere.

## Eager versus lazy, concretely `[...].map(f).filter(p).slice(0, 5)` executes strictly left to right and to completion at each step. `map` visits every element and returns a full new array; `filter` then visits every element of *that* array and returns another full array; only then does `slice` throw away all but five results. Two properties follow: the source must be finite and already in memory, and every stage costs one allocation of roughly source size. A generator pipeline is *pull-based*. Nothing happens when you construct it. Work happens when the consumer calls `next()`, and each call travels back up the chain: the last stage asks the one before it for a value, which asks the one before that, until the source produces one. ```js function* naturals() { let n = 1; while (true) yield n++; // never ends — and that is fine } function* map(iterable, fn) { for (const x of iterable) yield fn(x); } function* filter(iterable, predicate) { for (const x of iterable) if (predicate(x)) yield x; } function* take(iterable, n) { if (n <= 0) return; let i = 0; for (const x of iterable) { yield x; if (++i === n) return; // stops pulling — the source is abandoned } } const squares = map(naturals(), n => n * n); const evens = filter(squares, n => n % 2 === 0); console.log([...take(evens, 5)]); // [ 4, 16, 36, 64, 100 ] ``` The source is infinite, yet the program terminates in microseconds and allocates no intermediate arrays. Only ten naturals were ever produced, because the filter needed ten to find five even squares. ## What actually happens per value For each of the five results, one `next()` at the `take` stage causes exactly one `next()` at `filter`, which loops calling `next()` on `map`, which calls `next()` on `naturals`. Values are interleaved, not batched: value 1 goes all the way through the pipeline before value 2 is produced. This is why the shape is often called a "vertical" traversal, against the "horizontal" stage-by-stage traversal of array methods. Memory follows directly. The array chain holds, at peak, the source array plus one intermediate per stage. The generator chain holds one value in flight plus each generator's own local state — constant, independent of how long the source is. ## When it matters - **Unbounded sources**: an ID generator, a random walk, a Fibonacci stream, a paging cursor. Array methods are simply not applicable. - **Early exit on a large source**: finding the first match in ten million records reads only as far as the match. `find` on an array does that too, but only for a single step — as soon as you need `map(...).find(...)`, the array version maps all ten million first. - **Big files or big result sets read in chunks**: each transformation stage adds no copy of the data. When the collection is small and already in memory, array methods are usually the better choice: they are clearer, they are heavily optimized in engines, and the allocation you save is trivial. ## Composition without an intermediate Because each stage takes an iterable and returns an iterable, stages compose in any order and `yield*` splices whole sub-sequences in without materializing them: ```js function* concat(...iterables) { for (const it of iterables) yield* it; // no array is built } ``` That is the same composability array methods give you, minus the copies — the trade being that each stage is a hand-written `function*` rather than a method call. ## The built-in version Since ES2025, iterator helpers give the same laziness without hand-rolling stages: `Iterator.prototype.map`, `.filter`, `.take`, `.drop`, `.flatMap` and `.toArray` exist on iterators, so `naturals().map(n => n * n).filter(n => n % 2 === 0).take(5).toArray()` produces the same result, lazily. They are available in Chrome 122+ and Node 22+; in older environments the hand-written generators above are the portable form, and interviewers still expect you to be able to write them. ## Traps to name - **Never spread an unbounded generator.** `[...naturals()]` and `Array.from(naturals())` both try to drain it and will hang until the process runs out of memory. The `take` must come before any materializing consumer. - **A generator object is one-shot.** After you consume `evens` once, a second spread yields nothing, because a generator object is its own iterator and it is exhausted. Wrap the pipeline in a function if you need to run it twice. - **Laziness delays side effects.** Building the pipeline runs no user code at all; a `console.log` or a fetch inside a stage happens only when someone pulls, and possibly never.

  • How many values does the source generator actually produce in that five-even-squares example?
    Ten. The `take` stage stops after five results, and the filter needed the squares of 1 through 10 to find five even ones. Every pull travels the whole chain, so the source is advanced exactly as far as demand requires and no further — that is the property that makes an infinite source safe to use.
  • Why does iterating the same pipeline a second time produce nothing?
    A generator object is an iterator that is consumed once; once it reports done it stays done, and the pipeline stages hold references to already-exhausted sources. To make the pipeline re-runnable, wrap its construction in a function and call it again — each call builds fresh generator objects. Treating a pipeline value as if it were an array is a common bug.
  • When would you still prefer chained array methods?
    When the data is small and already in memory. Array methods read better, are highly optimized, and the intermediate copies of a few hundred elements cost nothing measurable. Reach for generators when the source is unbounded, when it is far larger than what you need, when each element is expensive to compute, or when you want to stop early.

saying these in an interview costs you the question

  • Says slice(0, 5) protects you from an infinite source
  • Spreads the generator before applying take
  • Thinks each stage completes before the next begins
  • Claims generators are always faster than array methods
  • Assumes a consumed pipeline can be iterated again

context

open as a page

In a JavaScript generator function, what is the difference between yield [1, 2, 3] and yield* [1, 2, 3], and what kinds of operands does yield* accept?

level: juniorimportance: should knowfreq 45%

basics

~20 s

yield [1, 2, 3] produces one value: the array itself. yield* iterates its operand and produces each element separately, so the consumer sees 1, then 2, then 3. yield* accepts any iterable, not only generators.

open as a page

In JavaScript, a generator delegates with const result = yield* inner(). What does result end up holding, and what does the caller of the outer generator see?

level: middleimportance: should knowfreq 40%

basics

~20 s

result holds the inner generator's return value, the value carried by its final {value, done: true} result. Everything the inner generator yields goes straight through to the caller; only the return value flows back into the delegating generator.

open as a page

In JavaScript, when does replacing a chain of array map and filter calls with a generator pipeline actually pay off in production, and what do you give up by doing it?

level: seniorimportance: should knowfreq 33%

basics

~20 s

Generator pipelines pay off when the source is unbounded or far larger than what you consume, when work per item is expensive, or when you exit early. You give up speed on small inputs, random access and length, re-iterability, and easy debugging.

open as a page

In JavaScript, how do you lazily walk a deeply nested structure with a recursive generator, and what goes wrong when the nesting gets very deep?

level: seniorimportance: should knowfreq 30%

basics

~20 s

A generator that calls yield* on itself for each child flattens any nesting lazily in a few lines. The cost is that every value is relayed through one next() call per nesting level, so deep structures get slow and can hit the call-stack limit.

open as a page