Exotic goto: generators and exceptions

18 Aug, 2026 · 13 min read
Contents

The last post argued that break, continue, and labeled jumps are structured goto — disciplined jumps that keep code linear. Two ordinary language features are jumps in disguise: yield, which suspends a function and later resumes it in the middle, and throw, which leaps across stack frames to a waiting handler.

Exotic goto: generators and exceptions

Both are jumps, and neither looks like one. Handled with a little discipline, both do what the rest of this series has been about: they flatten code that would otherwise be a hand-rolled state machine or a tangle of values threaded back up through every level.

What sets these two apart from break and continue is what becomes of the code that has not run yet. break skips the rest of the loop and the function carries on. yield hands the rest of the function to somebody else, who decides whether it runs at all. throw discards it. Who owns the code after the jump is the question this post is about.

Generators: a function that pauses

A generator (function* with yield in the body) is a function that can stop in the middle, hand a value back to its caller, and later resume from that exact spot. The yield is the jump: control leaves the function and, on the next .next(), lands back inside it, mid-execution.

Last time we saw what Dijkstra actually objected to: control able to land anywhere, so that no statement carries a knowable set of facts about how it was reached. A generator is the opposite case. Its suspended frame keeps every local and the exact spot in the loop, and the only way back in is through the generator’s own protocol, so the facts on re-entry are the facts on the way out.

What it buys us is linearization across time: code that would otherwise be an explicit state machine becomes a plain top-to-bottom read.

Take a pre-order walk of a tree, produced lazily so the consumer pulls one value at a time. As a generator it is the algorithm and nothing else:

function* walk(node) {
  yield node.value;
  for (const child of node.children) yield* walk(child);
}

for (const value of walk(root)) {
  // one node at a time, computed on demand
}

yield* delegates to another iterable, so recursion just works. Now write the same lazy, pull-driven iterator by hand, without generators, and the hidden state comes out into the open:

const makeWalker = root => {
  const stack = [root];
  return {
    next() {
      if (stack.length === 0) {
        return { done: true, value: undefined };
      }
      const node = stack.pop();
      for (let i = node.children.length - 1; i >= 0; --i) {
        stack.push(node.children[i]);
      }
      return { done: false, value: node.value };
    },
    [Symbol.iterator]() { return this; },
  };
};

Same traversal, but now we keep an explicit stack, reverse the children onto it, and thread done/value bookkeeping through every call. The generator hid all of it: the suspended function is the state — its locals and its position in the loop are the stack the manual version had to build by hand.

The hand-written version is also the faster one. Walking a 781-node tree — five levels, five children each — makeWalker beats a generator keeping that same explicit stack by about 1.7×, and beats walk as written by 10×, because yield* re-yields every value through every level it came from. That penalty grows with depth, on a call stack that can run out. Measured on Node 26 and one laptop CPU; the Duff’s device numbers are a standing reminder that such orderings can turn over between engines, though gaps this wide are unlikely to invert.

What the speed costs is that the state has to be packed by hand. Every value that must survive between calls becomes a field, and working out what that set is can be the hard part. That is what all of makeWalker’s machinery is for, and the generator needed none of it. Where packing the state by hand is the bulk of the work, the generator comes out smaller, more obvious, and easier to verify; where it is not, a plain function and a for loop will serve.

Generators are how the language linearizes asynchrony. Before async/await, co ran generators that yielded promises, with a driver resuming the generator once each promise settled — straight-line async code years before the syntax existed. async/await is that pattern folded into the language: an async function is a generator whose yields are awaits and whose driver is built in. Async generators (async function*) close the loop — a paginated API becomes a flat for await loop with the cursor and the “are we done?” logic tucked inside:

async function* allItems(fetchPage) {
  let cursor;
  do {
    const { items, next } = await fetchPage(cursor);
    yield* items;
    cursor = next;
  } while (cursor);
}

The caller sees a flat stream of items; the cursor, the termination test, and the awaiting all stay inside the generator.

The driver does not have to be await’s built-in one — we can write our own, and it need not deal in promises at all. Here is one that threads plain values and reads a null as “stop”: each yield feeds its value back into the generator, unless that value is null, in which case the whole call ends right there. safeDiv returns null rather than dividing by zero:

// a test function that can issue `null`
const safeDiv = (a, b) => (b === 0 ? null : a / b);

// our driver
const Do = genFn => {
  const gen = genFn();
  let input;
  for (;;) {
    const { value, done } = gen.next(input);
    if (done) return value;           // the final result, as-is
    if (value === null) return null;  // stop, never resume
    input = value;                    // otherwise resume with it
  }
};

// our sample code
const result = Do(function* () {
  const x = yield safeDiv(10, 2);   // 5
  const y = yield safeDiv(x, 0);    // null -> Do stops here
  return x + y;
});
console.log(result);                // null

The null from safeDiv(x, 0) makes Do stop and never resume — the return is never reached, and the code after the failing yield never runs. That decision is the driver’s. break and throw return control to us; here the generator hands the rest of itself over, and whether it ever runs is no longer the generator’s call.

There is a limit worth knowing. A generator cannot be resumed twice from the same point: next() advances it, and a paused generator cannot be copied. So a driver can run the continuation once or not at all, and that is the whole menu. Drivers that need it to run more than once — the list-style ones in functional languages — re-run the generator from the top and replay the earlier steps, which works only if the body has no side effects.

That limit is worth carrying into any iterator design. An object holds its state where we can reach it, so it can be duplicated: copy the state and there is a second cursor over the same sequence, free to advance on its own. In JavaScript that is the only way to get one.

Handing the continuation to a driver also inverts the usual arrangement. To give custom behavior to a framework, the conventional answer is an object implementing a fixed interface: a visitor, a strategy, a state machine with a method per state. Here the caller writes a top-to-bottom function and the framework supplies the driver — the same trade as before, one altitude up, with the framework’s method boundaries taking the place of the hand-packed fields.

redux-saga is this in production. Its generators yield plain objects that describe what should happen rather than doing it, and the middleware decides what each description means — which is also what makes sagas testable without running anything real.

Readers coming from functional programming will recognize the shape: this is Haskell’s do notation , with the Do driver standing in for a monad ’s bind — fantasydo is the generic version.

Exceptions: a jump across stack frames

throw is the long-distance jump. It unwinds the call stack, abandoning every frame between the throw and the nearest matching catch, and lands on the handler. In C that pairing is setjmp/longjmp; an exception is the structured, typed version of it. Used as intended, for errors, this is uncontroversial. Used deliberately as control flow, it leaves arbitrarily deep code in a single step.

When is that worth it? When a result lives deep inside a recursion, or behind a callback we do not control, and threading it back up by hand would dominate the code. Start with the shape, in a traversal we do own — where a relayed return would serve just as well, and so would the generator from the first half:

class Found {
  constructor(value) { this.value = value; }
}

const findDeep = (root, predicate) => {
  const visit = node => {
    if (predicate(node)) throw new Found(node);
    for (const child of node.children) visit(child);
  };
  try {
    visit(root);
  } catch (e) {
    if (e instanceof Found) return e.value;
    throw e;                 // not ours -- let it propagate
  }
  return undefined;
};

The moment visit finds a match it throws, and control jumps straight to the catch — past every half-finished recursive call, with no return value relayed up level by level. Three rules keep it disciplined rather than reckless:

  • A private signal type. Found is ours alone, so the instanceof check can tell our jump apart from a real failure.
  • Re-throw everything else. A catch that swallows unknown exceptions hides bugs. We handle Found and let real errors keep going.
  • Found does not extend Error. It is a signal, not a failure, so it skips the stack-trace capture that constructing an Error would pay for. Throwing and catching an Error subclass instead costs two to four times as much.

The case that leaves no alternative is the one the last post ended on: Array.prototype.forEach has no break. Only the searching methods cut early; the rest visit every element by construction. MDN is blunt about the way out:

There is no way to stop or break a forEach() loop other than by throwing an exception.

One array is not the problem — we can pick a different method. Nested levels are, because a find on the inner one cannot stop the outer ones, and what it found never comes back out. A throw cuts every level at once:

const findDoodad = (gadgets, acceptable) => {
  try {
    gadgets.forEach(gadget =>
      gadget.gizmos.forEach(gizmo =>
        gizmo.doodads.forEach(doodad => {
          if (acceptable(doodad)) throw new Found(doodad);
        })));
  } catch (e) {
    if (e instanceof Found) return e.value;
    throw e;
  }
  return undefined;
};

It is not free either, and the gap is wider than it looks. Running the whole search above to an early match costs about 1.7 microseconds when it ends in a throw through three nested forEach callbacks, against 15 nanoseconds for the same search written with a labeled break and 45 with a return relayed through each level — the engine walks frames looking for a handler where a return simply leaves one. How much that matters depends on how much work the jump skips. On that early match the throw version costs over a hundred times the break version, though in absolute terms it is still under two microseconds; when the search walks nearly the whole structure first, the traversal dominates and the gap falls to about sixfold. That sixfold is the generous reading: only the throw arm pays for forEach callbacks, so some of what is left is traversal rather than escape.

throw also hides — a jump crossing many frames is invisible at the throw site, and a reader scanning visit would never guess it can abandon the whole traversal. So keep the throw and its catch inside one small, self-contained function: the jump is then non-local at runtime but entirely local in the source, where both ends are in view. And keep it rare: this is a bail-out, not a routine step.

One check the three rules do not cover, because they govern our catch rather than our throw: when the frames in between are not ours, something in there may catch first. A library try/catch can swallow Found, log it to an error reporter, or run a finally written on the assumption that a failure just happened. Reading the traversal’s source is the only way to know, which is one more reason to keep this for the cases that have no alternative.

Every number above comes from nano-benchmark , which compares medians across batched runs and reports an ordering only when a rank test says the difference is real; the method notes are in the harness’s wiki . The benchmarks are a runnable gistnpm install && npm run bench repeats the experiment.

Where the two meet

The two jumps are not separate mechanisms. A generator can be resumed with a value, and it can be resumed with an exception:

function* g() {
  try {
    yield 'a';
  } catch (e) {
    yield 'recovered: ' + e.message;
  }
}

const it = g();
it.next();                    // {value: 'a'}
it.throw(new Error('boom'));  // {value: 'recovered: boom'}

it.throw() does not throw at the call site. It throws inside the paused function, at the yield it is sitting on, where the generator’s own try/catch can handle it. That is the mechanism behind await: a rejected promise becomes a throw at the await that produced it, which is why wrapping an await in try/catch works at all. It is also what Do would need the moment a yielded step can fail for a reason worth reporting, rather than merely stopping.

The traffic runs the other way too. A for...of loop that leaves early calls the generator’s .return(), which resumes it just long enough to run its finally blocks:

function* withResource() {
  try {
    yield 1;
    yield 2;
  } finally {
    console.log('released');
  }
}

for (const v of withResource()) {
  console.log(v);   // 1
  break;            // -> prints: released
}

The break is in the consumer, the cleanup is in the generator, and neither function names the other. It is the guarantee try/finally gives inside a plain function, stretched across a suspension — as long as the consumer honors the protocol. A hand-rolled loop over .next() that simply stops calls no .return(), and a generator nobody finishes never runs its finally at all.

The Do driver earlier and the two mechanisms here all answer the question the post opened with, and answer it the same way: the code that performs the jump is not the code that decides what runs next. Do decides whether the rest of the generator runs; it.throw() decides that it resumes as a failure; the consumer’s break decides it does not resume, but its cleanup does.

Summary

yield and throw are jumps in the same family as break and continue, with longer reach and a twist: they decide what happens to the code that has not run yet. yield hands it to a driver, which can resume it once or drop it. throw discards it down to the nearest handler.

Both earn their keep in the same situation — when the structured alternative is the bigger tangle: a hand-built state machine, or a result threaded back up through every recursive call. Both cost more than the plain version, so spend them where they buy something. And confine them: a generator behind an iterator protocol, a throw and its catch inside one small function, so both ends of the jump stay in view.

Coding tactics posts

The series so far, in order:

More installments are on the way: what “premature” optimization really costs.