How it works
The spec models a generator object with [[GeneratorState]] and [[GeneratorContext]]. The state is one of suspended-start, suspended-yield, executing or completed.
When you call a generator function, EvaluateGeneratorBody runs FunctionDeclarationInstantiation (arguments, defaults, scope setup), creates the generator object with OrdinaryCreateFromConstructor from the function's prototype, sets it to suspended-start, and runs GeneratorStart. GeneratorStart stores an Abstract Closure that evaluates the body and returns the object. Nothing in the body has run.
next(value) calls GeneratorResume:
GeneratorValidate requires the receiver to be a generator object, and throws a TypeError if the state is executing. That is the re-entrancy check.
- If the state is
completed, return { value: undefined, done: true }.
- Set the state to
executing and resume the saved execution context with the normal completion value.
At a yield, GeneratorYield sets the state to suspended-yield and hands an IteratorResult back to the caller's execution context. The generator's context is kept off the execution context stack. The next resume pops it back on, and the yield expression evaluates to the resumption value. If the body finishes, the state becomes completed and the spec notes that its context is never resumed and can be discarded.
return() and throw() use GeneratorResumeAbrupt, which resumes with a return or throw completion. If the state is suspended-start, it first sets completed. For a completed generator, return(v) gives { value: v, done: true } and throw(e) throws e.
The spec says nothing about how an engine stores the suspended context; only the observable behaviour is specified.
Edge cases
Unstarted generators, finally that yields, and re-entrancy:
function* body() { console.log('never runs'); try { yield 1; } finally { console.log('never'); } }
const a = body();
console.log(a.return('early')); // → { value: 'early', done: true }
console.log(a.next()); // → { value: undefined, done: true }
function* retry() {
try { yield 1; } finally { yield 'cleanup-step'; }
}
const b = retry();
b.next();
console.log(b.return('R')); // → { value: 'cleanup-step', done: false }
console.log(b.next()); // → { value: 'R', done: true }
let running;
function* reenter() { running.next(); yield 1; }
running = reenter();
try { running.next(); } catch (e) { console.log(e.constructor.name); } // → TypeError
Because return() on an unstarted generator never enters the body, no finally runs. That is the rule: cleanup only exists once a try has been entered.
yield* delegation, including the result value:
function* inner() {
const received = yield 'a';
return `inner got ${received}`;
}
function* outer() {
const result = yield* inner();
console.log(result);
yield 'b';
}
const it = outer();
console.log(it.next()); // → { value: 'a', done: false }
console.log(it.next('sent')); // → inner got sent
// → { value: 'b', done: false }
console.log([...outer()]); // → inner got undefined
// → [ 'a', 'b' ]
Per the spec's yield* steps, next(x) is forwarded to the inner next, throw(e) to the inner throw (if there is none, the spec closes the inner iterator and throws a TypeError), and return(v) to the inner return (if there is none, the outer generator just returns v). yield* works with any iterable, including arrays and strings.
Real-world scenario
Before async/await, libraries such as co drove generators with promises. The shape is the one async/await later gave syntax to: the function body suspends at each yield, a driver resumes it with next(value) for a fulfilled promise, or throw(error) for a rejected one, so try…catch works across the suspension.
function run(generatorFn) {
const gen = generatorFn();
function step(method, arg) {
let result;
try {
result = gen[method](arg);
} catch (error) {
return Promise.reject(error);
}
if (result.done) return Promise.resolve(result.value);
return Promise.resolve(result.value).then(
(value) => step('next', value),
(error) => step('throw', error),
);
}
return step('next');
}
const wait = (ms, value) => new Promise((resolve) => setTimeout(resolve, ms, value));
run(function* () {
const a = yield wait(10, 1);
try {
yield Promise.reject(new Error('boom'));
} catch (error) {
console.log('caught', error.message); // → caught boom
}
return a + (yield wait(10, 2));
}).then(console.log); // → 3
Today you write async/await; the practical uses of plain generators are lazy sequences, custom iterables, tree walks with yield*, and state machines driven by next(input).
Trade-offs
Generators give you pull-based laziness and resource-safe iteration in a few lines, where a hand-written iterator needs explicit state and return(). The costs: each generator object holds its suspended context while it lives, so abandoned half-finished generators keep their scope alive until collected; they are single-pass; the two-way next(value) channel is obscure, which makes it a poor fit for ordinary code (a plain callback or an object with methods is easier to read); and stack traces through yield* chains are harder to follow than plain calls.