Iterators, Generators and Async Iteration
for…of works on anything that implements the iterator protocol. Generators let
you implement that protocol with a function — producing values on demand, even
infinitely, even asynchronously. This lesson covers the protocol, yield,
delegation, and the lazy pipelines they enable.
The iterator protocol
By hand first — then never again
// An ITERABLE has a Symbol.iterator method returning an ITERATOR:
const handMade = {
upTo: 3,
[Symbol.iterator]() {
let i = 1;
return {
next: () => (i <= this.upTo
? { value: i++, done: false } // a step
: { value: undefined, done: true }), // the end
};
},
};
console.log([...handMade].join(", ")); // spread and for…of both speak this protocol
// Expected output:
// 1, 2, 3
Arrays, strings, Maps, Sets, and NodeLists all implement this — which is exactly one
protocol, and why for…of works uniformly on all of them.
Generators: the protocol, written for you
yield pauses the function; next() resumes it
function* fibonacci() {
let [a, b] = [0, 1];
while (true) { // an INFINITE loop — safe, because nothing runs until asked
yield a; // pause here, hand out a, wait to be asked again
[a, b] = [b, a + b];
}
}
const fib = fibonacci();
console.log(fib.next().value, fib.next().value, fib.next().value, fib.next().value);
// Expected output:
// 0 1 1 2
Calling fibonacci() runs no code — it returns a paused
generator. Each .next() runs to the next yield and freezes again.
Contrast with an array: a generator holds no history, only the recipe.
Lazy pipelines: cap the infinite
// take(n) stops PULLING — the generator freezes at its yield, computing nothing more.
function* take(iterable, n) {
let count = 0;
for (const value of iterable) {
if (count++ >= n) return;
yield value;
}
}
console.log([...take(fibonacci(), 8)].join(", "));
// Expected output:
// 0, 1, 1, 2, 3, 5, 8, 13
yield* — delegation
// yield* hands control to ANOTHER generator — its yields flow through ours.
function* tree(node) {
if (typeof node !== "object" || node === null) {
yield node; // a leaf: produce its value
return;
}
for (const child of Object.values(node)) {
yield* tree(child); // recursive delegation: depth-first traversal
}
}
const doc = { id: "root", children: [{ id: "a" }, { id: "b", children: [{ id: "ba" }] }] };
console.log([...tree(doc)].join(", "));
// Expected output:
// root, a, b, ba (every string leaf, depth first)
This is the natural shape for walking trees, flattening structures, and composing pipelines — recursion that produces a stream instead of building an array.
Async iteration: for await…of
An async generator yields promises; for await…of awaits each
value in order. This is the foundation of streaming data — event sources, paginated APIs,
reading a file chunk by chunk:
async function* ticks(count) {
for (let i = 1; i <= count; i++) {
await new Promise((r) => setTimeout(r, 10)); // simulate an async event source
yield `tick ${i}`; // produced only when the consumer asks
}
}
for await (const message of ticks(3)) {
console.log(message); // the loop awaits each value before the next iteration
}
// Expected output (one per 10ms):
// tick 1
// tick 2
// tick 3
Because values are produced on demand, a slow consumer applies natural
backpressure: the generator simply pauses at its yield until
the next pull. No buffer grows, nothing is computed early.
Practice: infinite, capped
The task
Run async/29_generators_lab.js with node. Then build
function* unique(iterable) that yields each value only the first time it appears
(a lazy Set filter), and chain it:
[...unique(take(fibonacci(), 20))]. Predict the result before running.
The checklist
- You can implement Symbol.iterator by hand — and explain what a generator saves you.
- You know calling a generator function runs no code until the first next().
- You use
yield*for recursive delegation instead of manual forwarding. - You can explain backpressure: consumers pull, producers pause at yield.