Iterators and Generators
Prerequisites assumed: fluency with objects, closures, arrays and their iteration methods, the prototype chain, and the event loop. This chapter examines the iteration protocol at the language level, then treats generators as compiler-managed coroutines — including the failure modes that show up in production code.
The iteration protocol
JavaScript iteration is defined by two conventions, not by classes. Iterable is any object with a [Symbol.iterator] method that returns an iterator. An iterator is any object with a next() method returning { value, done }. The two protocols are deliberately decoupled: one iterable can produce fresh iterators repeatedly; an iterator itself may or may not be iterable.
Every consuming construct — for...of, spread, array destructuring, Array.from, yield*, new Map(iterable), Promise.all — calls [Symbol.iterator] once and then calls next() until done is truthy. The value of the final done: true result is ignored by all built-in consumers, which is why return-less generator exits yield undefined implicitly.
const range = {
from: 1,
to: 3,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
return current <= last
? { value: current++, done: false }
: { value: undefined, done: true };
},
};
},
};
console.log([...range]); // [1, 2, 3]
for (const n of range) console.log(n); // 1, 2, 3Because range implements [Symbol.iterator] as a fresh call per consumer, it is indefinitely re-iterable. This is a common bug source when authors instead return a shared iterator: a for...of loop that has consumed the sequence breaks every later consumer.
Iterators versus arrays: the actual trade-off
| Property | Array | Iterator/generator |
|---|---|---|
| Random access | O(1) | O(n) per advance |
| Re-iterable | Yes | Only if `[Symbol.iterator]` recreates it |
| Backpressure | None — fully materialized | Pull-based, on demand |
| Infinite sequences | Impossible | Natural |
| `map`/`filter` | Eager, allocates | Must be composed manually or via iterator helpers |
Generators as suspended functions
A generator function (function*) produces a generator object that is both an iterator and iterable (its [Symbol.iterator] returns this — so generators re-iterating themselves is a trap: a exhausted generator is empty forever).
The internal machinery is a suspended stack frame. On each next() call, the frame resumes at the last yield, runs to the next yield or return, and suspends. This is implemented as a state machine over the function body; the generator's lexical scope persists across suspensions like a closure's.
function* fibonacci() {
let [a, b] = [0, 1];
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1Bidirectional communication
next(arg) is where generators diverge from ordinary iteration: arg becomes the value of the currently suspended yield expression. This turns a generator into a coroutine — a function you can push values into, not just pull from.
function* accumulator() {
let total = 0;
while (true) {
const input = yield total;
total += input;
}
}
const acc = accumulator();
console.log(acc.next()); // { value: 0, done: false } — first next() only starts the body
console.log(acc.next(10).value); // 10
console.log(acc.next(5).value); // 15The first next() cannot receive a value because no yield is suspended yet; anything passed to the starting next() is silently discarded. This is a classic gotcha.
return() and the awkward contract
generator.return(v) forces the frame to behave as if a return v executed at the suspension point: pending finally blocks run, and the generator becomes permanently done. for...of calls return() when it exits early (break, throw), which is the mechanism that makes try/finally inside generators correct under early loop exit.
A subtlety: a generator currently executing a finally block ignores return() calls — the result is an object with done: true only after the block completes. And yield inside finally is legal, which can make cleanup observable to consumers in surprising orders.
throw() and error propagation
generator.throw(e) resumes the frame as if throw e occurred at the suspension point. If the generator has no matching catch, the exception propagates to the .throw() caller and the generator is done. If it catches the error, the generator resumes normally. Error handling inside a generator is therefore cooperative and testable:
function* guarded() {
try {
yield 1;
yield 2;
} catch (e) {
console.log('caught inside:', e.message);
yield 'recovered';
}
}
const it = guarded();
it.next(); // 1
console.log(it.throw(new Error('boom')).value); // 'recovered'yield* and delegation
yield* iterable delegates: it drives the inner iterable, forwarding every next(), throw(), and return() call transparently, and evaluates to the inner iterable's return value. The return-value forwarding is underused and worth internalizing — it makes generators composable in a way that mimics monadic chaining:
function* inner() {
yield 1;
yield 2;
return 'inner-done';
}
function* outer() {
const result = yield* inner();
console.log(result); // 'inner-done'
yield 3;
}
console.log([...outer()]); // [1, 2, 3]Async generators and the event loop
async function* yields promises; the consumer must await each next(). The returned iterator is an async iterator, consumed with for await...of. Each next() returns a promise resolving to { value, done }, and the pull-based model gives you natural backpressure for streams — the consumer's pace throttles production, unlike pushing every chunk into an array.
async function* ticks(n) {
for (let i = 1; i <= n; i++) {
await new Promise((r) => setTimeout(r, 100));
yield i;
}
}
(async () => {
for await (const t of ticks(3)) console.log(t);
})();Gotcha: within one generator, yield and await must not mix in a plain generator, and for await...of on a source that throws will invoke return() on the async iterator — cleanup applies there too.
Decision criteria
- Use an array when data is small, needs random access, or is iterated repeatedly.
- Use a generator for lazy pipelines, infinite or on-demand sequences, or when composition via
yield*clarifies structure. - Use an async generator over an event-emitter or stream API when the consumer should control pacing.
- Avoid generators for hot paths with per-item work: resuming a generator frame costs more than a plain loop; measure before reaching for them.
Exercise
Why is a value passed to the first next() call on a fresh generator discarded?
Show answer
The first next() starts execution from the top of the function body; there is no suspended yield expression to receive the value, so the argument has no destination and is dropped.
Exercise
A for...of loop breaks out of a generator-driven sequence that holds an open file handle released in a finally block. Does the handle get released, and by what mechanism?
Show answer
Yes. On early exit the for...of construct calls the iterator's return() method, which resumes the generator as if return executed at the suspension point; that unwinds the frame, running the pending finally block and releasing the handle.
Spotted a mistake, or code that does not work?