Iterators and Generators
Date: 2026-09-28
for...of, spread and destructuring don’t know about arrays — they speak a two-method protocol that any object can implement. Generators are the easy way to implement it, and because they pause, they make lazy and infinite sequences ordinary code.
The iteration protocol is a contract. An object is iterable if it has a [Symbol.iterator]() method that returns an iterator. An iterator is any object with a next() method returning { value, done }. A generator is a function declared with function* that returns an iterator, and runs its body only as far as the next yield each time next() is called.
The protocol
for (const x of thing)
│
├─ it = thing[Symbol.iterator]() get an iterator
│
├─ it.next() → { value: 'a', done: false } → x = 'a', run body
├─ it.next() → { value: 'b', done: false } → x = 'b', run body
├─ it.next() → { value: undefined, done: true } → stop
│
└─ break / throw / return early → it.return() (cleanup hook)
Everything that consumes it: for...of, spread ([...x]), array destructuring, Array.from, new Map(x) / new Set(x), Promise.all(x), yield*.
Built-in iterables: arrays, strings (by code point, not UTF-16 unit — so emoji survive), Map, Set, arguments, NodeList. Plain objects are not iterable — use Object.entries(obj). That’s also the difference from for...in, which walks enumerable keys, inherited ones included.
Hand-written versus generator — equivalent
// the protocol by hand
const range = (start, end) => ({
[Symbol.iterator]() {
let n = start;
return { next: () => n < end ? { value: n++, done: false }
: { value: undefined, done: true } };
},
});
// the same thing as a generator
function* range(start, end) {
for (let n = start; n < end; n++) yield n; // pauses here; locals survive
}
[...range(0, 3)]; // [0, 1, 2] — either versionA generator is a function that can pause. Each next() resumes it from the last yield with its local variables intact. The engine turns the body into a state machine for you — which is what the hand-written version was doing badly.
Laziness
Values are produced only when asked for, so a sequence can be infinite, or expensive per item, without cost up front:
function* ids() { let n = 1; while (true) yield `ORD-${n++}`; } // infinite, harmless
const it = ids();
it.next().value; // 'ORD-1'
it.next().value; // 'ORD-2' — nothing computed beyond what was pulledIterator helpers — .map, .filter, .take, .drop, .toArray directly on iterators, lazily, without converting to an array first — were standardised in ES2025. [CHECK: current browser and Node support for iterator helpers before relying on them unpolyfilled]
Async iteration — the load-bearing use
The same protocol with promises: [Symbol.asyncIterator](), a next() that returns a promise, consumed with for await...of. Paginated APIs are the natural fit — the pagination logic lives in one place and the consumer sees a flat stream:
async function* allOrders(api) {
let cursor = null;
do {
const page = await api.getOrders({ cursor }); // one request per page, on demand
yield* page.items; // hand items out one at a time
cursor = page.nextCursor;
} while (cursor);
}
for await (const order of allOrders(api)) {
if (order.total > 1000) break; // stops fetching: no further pages requested
}Streams follow the same protocol: a ReadableStream can be consumed with for await [CHECK: ReadableStream async iteration support in Safari], and Node streams have done so for years — Streaming Responses.
Two-way generators
next(value) sends a value in, becoming the result of the paused yield expression. throw(err) resumes the generator by throwing at the yield. Before async/await existed, this is how libraries like co wrote async code: yield a promise, get its result sent back in. Transpilers still compile async functions to generator-style state machines for old targets — Transpilation.
Where it goes wrong
- Iterators are single-use. Spread a generator twice and the second spread is empty — the first consumed it. An iterable (like an array) makes a fresh iterator each time; an iterator doesn’t
- Cleanup only runs if someone calls
return().for...ofdoes it onbreak, so afinallyin the generator runs. Callingnext()manually and abandoning it never triggers thefinally, so a connection it opened stays open - Materialising by accident.
[...hugeGenerator]orArray.frompulls everything into memory, which throws away the laziness. On an infinite one, it never returns - Per-item overhead. Each
next()allocates a result object. Engines optimise well, but for a hot numeric loop over a million items, a plainforover an array is still faster