JavaScript · Functions & FP
Generators
⚡ TL;DR — A generator is a function that can pause and resume, yielding values one at a time and preserving its entire local state between calls.
yieldis a two-way door: it sends a value out and, on the nextnext(v), receives a value in.
🧠 Mental model
A normal function runs to completion the moment you call it. A generator, marked function*, does the opposite: calling it runs nothing — it returns an iterator frozen at the top. Each .next() runs the body until the next yield, then freezes the stack — locals, loop counters, the instruction pointer, all preserved — and hands control back to you.
gen() → returns iterator (nothing executed yet)
.next()→ runs to first yield, pauses, returns { value, done: false }
.next()→ resumes, runs to next yield, returns { value, done: false }
.next()→ resumes, hits return/end, returns { value, done: true }
The killer insight: yield is bidirectional. It emits a value, but it also evaluates to whatever you pass into the next next(x). That turns a generator into a coroutine — a function you can converse with — which is the whole basis of how async/await was originally polyfilled.
⚙️ How it actually works
A generator object is an iterator that is also iterable ([Symbol.iterator] returns itself), so it drops straight into for...of, spread, and destructuring.
The protocol has three control methods, and the two beyond next are what separate a strong answer:
gen.next(v)— resume;vbecomes the value the pausedyieldexpression evaluates to. The firstnext's argument is discarded (there's no yield waiting yet — a favourite gotcha).gen.return(v)— force early completion: setsdone: true, and critically runsfinallyblocks so cleanup happens. This is howfor...ofbreaking early releases resources.gen.throw(err)— inject an exception at the pausedyield, which the generator's owntry/catchcan handle. This is how error propagation works in coroutine-style async.
yield* delegates to another iterable, forwarding next/return/throw through it and evaluating to that inner generator's return value — the composition primitive for generators.
Because state lives on the frozen stack rather than in the heap, generators produce lazy, infinite, O(1)-memory sequences: an infinite ID generator holds one number, not an array.
💻 Code
// Lazy infinite sequence — constant memory, values on demand.
function* naturals() {
let n = 1;
while (true) yield n++; // pauses here forever, resumes on next()
}
const ids = naturals();
ids.next().value; // 1
ids.next().value; // 2 (n survived across calls)
// yield is two-way: it RECEIVES the argument of the next next().
function* echo() {
while (true) {
const received = yield; // paused here; value comes from next(x)
console.log('got', received);
}
}
const e = echo();
e.next(); // prime it: run up to the first yield
e.next('hello'); // logs "got hello"
// return() runs finally → guaranteed cleanup on early exit.
function* withCleanup() {
try { yield 1; yield 2; }
finally { console.log('cleanup'); } // runs even if consumer breaks early
}
for (const x of withCleanup()) { if (x === 1) break; } // logs "cleanup"
// yield* delegates and captures the delegate's return value.
function* inner() { yield 'a'; return 'done'; }
function* outer() { const r = yield* inner(); yield r; } // yields 'a', then 'done'
⚖️ Trade-offs
- Laziness is the point. Generators shine for infinite/unbounded streams, pagination ("give me the next page only when asked"), and pipelines that shouldn't materialise the whole collection. If you just need an array now,
map/filterare simpler and faster. - When NOT to use them: hot numeric loops. Each
yieldis a suspend/resume with real overhead — a generator iterating millions of numbers is markedly slower than a plainfor. Don't reach for elegance in a bottleneck. - They're single-pass. Once exhausted, a generator is dead; you can't rewind or reuse it. Re-invoke the generator function to get a fresh one.
- Debugging is harder — the call stack pauses and resumes, so stepping through in DevTools jumps around. Worth it for the model, painful under pressure.
💣 Gotchas interviewers probe
- The first
next()argument is ignored. There's no suspendedyieldto receive it yet. You must "prime" the generator with a bare.next()before sending values. Nearly everyone gets this wrong. - Calling a generator runs no code. It returns an iterator; the body executes only on
.next(). Candidates expect side effects to fire on call. return()andfinally. Early termination (abreakinfor...of) callsreturn(), which runsfinallyfor cleanup. Not knowing this means your resource cleanup silently never runs.throw()resumes inside the generator — the injected error surfaces at the pausedyieldand hits itstry/catch, not the caller's.- Generators vs. async generators.
async function*+for await...ofis the streaming form; a plain generator can'tawait. Conflating them is common. - Spreading an infinite generator hangs the tab.
[...naturals()]never terminates. Laziness only helps if the consumer is also lazy.
🎯 Say this in the interview
"A generator is a pausable function.
function*plusyieldlets it emit a value, freeze its entire local state on the stack, and resume exactly where it left off on the next.next(). The subtle part is thatyieldis two-way — it sends a value out and evaluates to whatever you pass intonext(v)— which is what makes it a coroutine and is howasync/awaitwas originally implemented. Calling the generator runs nothing; it returns an iterator, and it's iterable too, so it works withfor...ofand spread. Beyondnext, I usereturn()— which runsfinallyblocks for cleanup on early exit — andthrow(), which injects an error at the paused yield. The practical win is laziness: infinite or paginated sequences in constant memory. The classic trap is that the firstnext()'s argument is discarded, so I prime the generator before sending values in."
🔗 Go deeper
- javascript.info — Generators — the two-way
yield,yield*, and the full protocol, clearly worked. - MDN — function* — the exact semantics of
next/return/throw. - MDN — Iteration protocols — how generators satisfy iterator and iterable.