Iterators and Generators
This chapter assumes you are comfortable with closures, this, arrays, the spread operator, and async/await. Iterators and generators sit below the abstraction of for...of and above it: understanding them explains how iteration protocols work, why spread works on some values and not others, and how lazy evaluation is implemented in JavaScript.
The iteration protocol
Two contracts define iteration:
- Iterable: an object with a
[Symbol.iterator]method returning an iterator. - Iterator: an object with a
next()method returning{ value, done }.
for...of, spread, destructuring, Array.from, and yield* all consume the protocol — not arrays specifically.
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]Key consequence: an iterator is single-use. Iterating range again calls [Symbol.iterator] again, producing a fresh iterator — but spreading the iterator itself ([...range[Symbol.iterator]()]) exhausts it permanently.
Generators
A generator function (function*) builds iterables without hand-writing the protocol. Calling it does not run the body; it returns a generator object that is both an iterable and an iterator.
function* fib() {
let [a, b] = [0, 1];
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
const seq = fib();
console.log(seq.next().value); // 0
console.log(seq.next().value); // 1
console.log(seq.next().value); // 1yield suspends the function mid-body and preserves its local state — this is a closure-like frame, but with resumable execution. yield* delegates to another iterable or generator, propagating return values.
Two-way communication
next(arg) resumes execution with arg as the value of the pending yield; generator.return(x) forces done at the current yield point; generator.throw(e) raises at it. This makes generators coroutines, not just lazy sequences.
function* echo() {
const received = yield 'ready';
yield `got: ${received}`;
}
const g = echo();
console.log(g.next().value); // 'ready' (first next arg is ignored)
console.log(g.next(42).value); // 'got: 42'Laziness and composition
Generators never hold the whole sequence in memory; they compute on demand. Compose them with plain function pipelines instead of array methods to avoid intermediate allocations.
function* map(iter, fn) {
for (const item of iter) yield fn(item);
}
function* take(iter, n) {
let i = 0;
for (const item of iter) {
if (i++ >= n) return;
yield item;
}
}
const first5Even = [...take(map(fib(), (x) => x * 2), 5)];Gotchas and decision criteria
- No backpressure for free: an unthrottled infinite generator still runs as fast as its consumer pulls; the pull model is the backpressure.
- Async generators (
async function*) yield promises and pair withfor await...of, ideal for paged API consumption — covered alongside async chapters. - Error handling is subtle: an exception thrown inside a generator during
next()propagates to the caller; the generator is done. - Use generators when the data is unbounded, expensive, or streaming. Use plain arrays when you need random access, repeated iteration, or
length. Reach for generators as eager factories (fib()) rather than exposing internal iterators.
Exercise
What happens if you spread an iterator twice?
Show answer
The second spread yields nothing. The first spread exhausts the iterator, and subsequent next() calls return { value: undefined, done: true }. Iterables stay fresh because [Symbol.iterator] is invoked again; bare iterators do not.
Exercise
How do you pass data into a generator?
Show answer
Pass it as the argument to next(): the value replaces the currently pending yield expression. The first next() call has no pending yield, so its argument is ignored.
Spotted a mistake, or code that does not work?