skip to content

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%

answer

  1. yield* relays, it does not inline
  2. one next() per nesting level
  3. O(n·d), not O(n)
  4. depth is the data's, not the element count
  5. explicit stack moves depth to the heap

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.

solid answer

~50 s

The recursive form is short: for each node, either `yield` the leaf or `yield* walk(child)`, and the consumer gets a flat lazy stream regardless of depth. The catch is what delegation costs. `yield*` does not splice the inner generator into the outer one; it relays. Pulling one value through a chain `d` levels deep means `d` nested `next()` calls, so a structure of `n` leaves at depth `d` costs roughly O(n·d) instead of O(n), and at a few thousand levels you get a `RangeError: Maximum call stack size exceeded` — the delegation chain occupies real stack frames. For shallow, human-authored data this is irrelevant. For deep or adversarial input, rewrite the traversal as a single generator with an explicit stack array, which keeps the laziness, makes the cost O(n), and moves the depth from the call stack to the heap.

code

javascript · 30 lines
javascript
function* walk(node) {
  if (Array.isArray(node)) {
    for (const child of node) yield* walk(child);
  } else {
    yield node;
  }
}

function* walkIterative(root) {
  const stack = [root];
  while (stack.length > 0) {
    const node = stack.pop();
    if (Array.isArray(node)) {
      for (let i = node.length - 1; i >= 0; i--) stack.push(node[i]);
    } else {
      yield node;
    }
  }
}

// Build a structure nested 50000 levels deep
let deep = 'leaf';
for (let i = 0; i < 50000; i++) deep = [deep];

try {
  console.log([...walk(deep)]);
} catch (e) {
  console.log(e.constructor.name); // RangeError
}
console.log([...walkIterative(deep)]); // [ 'leaf' ]

go deeper

for a junior

Be able to write the four-line recursive generator that flattens nested arrays with yield* and explain that the consumer receives a flat stream.

for a middle

Explain that yield* relays each value through every delegation level, so pulling a value from depth d costs d next() calls, and that traversal is therefore O(n·d).

for a senior

Recognise the stack-overflow hazard on input-controlled depth, and be able to write the explicit-stack generator that keeps laziness while making the cost linear, or cap depth and fail with a clear error.

for a principal

Treat nesting depth as an input-validation concern for untrusted data, and decide where the codebase draws the line between readable recursive traversals and hardened iterative ones.

## The recursive form A generator that delegates to itself turns any nested structure into a flat, lazy stream: ```js function* walk(node) { if (Array.isArray(node)) { for (const child of node) yield* walk(child); } else { yield node; } } [...walk([1, [2, [3, [4, 5]]]])]; // [1, 2, 3, 4, 5] ``` The same three lines handle trees: ```js function* nodes(tree) { yield tree.value; for (const child of tree.children ?? []) yield* nodes(child); } ``` It is lazy: `for (const v of walk(huge)) { if (v === target) break; }` stops descending as soon as the consumer stops pulling, and no flattened array is ever built. That combination — trivially short, fully lazy, arbitrary shape — is why the recursive generator is the idiomatic answer. ## Why depth costs The cost is in how `yield*` works. It is not a macro that inlines the inner generator's yields into the outer one. It is a relay: while delegating, the outer generator's `next()` forwards to the inner generator's `next()`, which forwards again, all the way to whichever generator is actually suspended at a `yield`. The produced value then returns back up through every level. So for a structure nested `d` levels deep, each leaf value costs about `d` `next()` calls and `d` result objects returned, not one. For `n` leaves the traversal is O(n·d) rather than O(n). With `d` in the single digits nobody notices. With a list nested 10,000 deep — say the linked-list-shaped `[1, [2, [3, [...]]]]` — the last element is relayed through 10,000 frames, and traversal becomes quadratic. Worse, those relayed `next()` calls are real JavaScript call frames. Beyond a few thousand levels of active delegation you hit `RangeError: Maximum call stack size exceeded`, thrown at the moment a value is pulled through the chain. This is a genuine production hazard for parsers, JSON-shaped data from an external source, or DOM-like structures built programmatically, because the depth is controlled by the input rather than by your code. ## The iterative rewrite When depth is unbounded or attacker-controlled, keep the laziness but flatten the recursion into one generator with an explicit stack: ```js function* walkIterative(root) { const stack = [root]; while (stack.length > 0) { const node = stack.pop(); if (Array.isArray(node)) { for (let i = node.length - 1; i >= 0; i--) stack.push(node[i]); } else { yield node; } } } ``` One generator, one `next()` per value, O(n) total, and depth now lives in a heap array that can grow to millions without touching the call stack. Note the reverse-order push: a stack pops last-in first, so pushing children in reverse preserves left-to-right order. The price is that the code no longer reads like the structure it walks — which is exactly the readability the recursive version was buying. ## Choosing between them - **Recursive `yield*`**: default choice. Depth is small and known — a config object, a component-ish tree you authored, a directory a few levels deep. Optimize for the reader. - **Explicit stack**: depth is unbounded, input-controlled, or measured to be deep; or profiling shows traversal dominating. Also the right call when you need to carry per-node state (a path, a depth counter) that recursion would pass as parameters through every frame. A middle option is to cap depth explicitly and fail loudly rather than blow the stack: ```js function* walkCapped(node, depth = 0) { if (depth > 200) throw new RangeError('structure too deep'); if (Array.isArray(node)) { for (const c of node) yield* walkCapped(c, depth + 1); } else yield node; } ``` For untrusted input that is often the best answer: a clear domain error beats a stack overflow surfacing from inside a library. ## Details worth knowing - Delegation stays transparent no matter how deep it goes: `return()` propagates down the chain when the consumer breaks, so `finally` blocks in every active generator still run and resources get released. - The recursion depth here is the *data's* depth, not the number of elements. A million-element flat array is fine recursively; a thousand-deep nesting of single elements is not. - Generators do not avoid the call stack. A suspended generator's frame is off-stack, but an *active* delegation chain is on it, which is why the limit applies at pull time rather than at construction time.

  • Why does the recursive generator throw a RangeError at pull time rather than when it is created?
    Calling `walk(deep)` only creates a generator object; no body runs. The stack is consumed when a value is pulled, because that `next()` forwards through every active delegation level as real call frames. So construction is always cheap and the overflow surfaces from inside the `for...of` or spread that consumes the sequence.
  • If a consumer breaks out of the loop halfway through a deep recursive traversal, do the inner generators get cleaned up?
    Yes. Breaking calls `return()` on the outermost generator, and `yield*` forwards `return()` down the delegation chain, so every suspended generator in it is finished and its `finally` blocks run. That is what makes it safe to open a resource in a traversal stage and release it in `finally` even when the consumer stops early.
  • Does the iterative version keep the laziness of the recursive one?
    Yes — it is still a generator, so it yields one value per pull and stops descending the moment the consumer stops. The only things that change are the cost model, O(n) instead of O(n·d), and where depth is stored: a heap array instead of the call stack. The trade is readability, since the code no longer mirrors the structure.

saying these in an interview costs you the question

  • Assumes yield* inlines the inner yields with no relay cost
  • Says generators never touch the call stack
  • Confuses data nesting depth with element count
  • Thinks the RangeError happens when the generator is created
  • Claims an explicit stack version cannot stay lazy

context