Appearance
3.6.6 — Iterators, Generators & Symbols
for…of, spread ..., destructuring, Array.from, new Map(pairs), Promise.all — half of modern JavaScript's nicest syntax works on "anything loop-able." That is not magic; it is one small, public protocol any object can implement. This page builds that protocol from nothing, then meets the language feature that makes implementing it almost free — the generator function, a function that can pause in the middle and resume later, which turns out to be one of the most powerful control-flow tools in the language (it is, historically, the machinery async/await was built from). Along the way we need Symbols — the odd primitive that makes protocols possible without name collisions — and we finish with async iteration, the version of all this that powers paginated APIs and Node streams.
1. Symbols first: keys that cannot collide
A Symbol is JavaScript's seventh primitive type (alongside string, number, boolean, null, undefined, bigint): a guaranteed-unique identifier.
javascript
const s1 = Symbol("id"); // the string is only a debugging label
const s2 = Symbol("id"); // same label…
s1 === s2; // → false — every Symbol() call is unique
const user = { [s1]: 42 }; // symbols can be property KEYS
user[s1]; // → 42Why does the language need this? Safe extension. Suppose the language wants to define "an object is loop-able if it has a method named X." If X were a string like "iterator", any existing object that happened to have an iterator property would break overnight. A symbol key can't collide with anything, ever — so the language defines its protocols as well-known symbols, published on the Symbol constructor:
- Symbol.iterator — "how to loop over me" (this page's star)
Symbol.asyncIterator— the async version (section 6)Symbol.hasInstance— customizeinstanceofSymbol.toPrimitive— customize coercion (3.6.7)Symbol.toStringTag— whatObject.prototype.toStringreports
Two practical notes: symbol-keyed properties are skipped by for…in, Object.keys, and JSON.stringify (semi-hidden — good for metadata), and Symbol.for("app.id") uses a global registry so separate modules can deliberately share one symbol.
2. The iteration protocols — the contract behind for…of
Two tiny interfaces, and you know them by shape already:
An object is an iterable if it has a
[Symbol.iterator]()method returning an iterator. An iterator is any object with anext()method returning{ value, done }.
That's the whole contract. Let's implement it by hand — a range that counts — so nothing stays abstract:
javascript
const range = {
from: 1, to: 5,
[Symbol.iterator]() { // (1) "I am iterable" — called once per loop
let current = this.from; // (2) loop state lives in a closure (3.6.2)
return { // (3) the ITERATOR object:
next: () => current <= this.to
? { value: current++, done: false } // (4) hand out one value…
: { value: undefined, done: true } // (5) …until exhausted
};
}
};
for (const n of range) console.log(n); // → 1 2 3 4 5
[...range]; // → [1, 2, 3, 4, 5]
const [a, b] = range; // → a=1, b=2 (destructuring pulls lazily)What for…of actually does under the hood: call range[Symbol.iterator]() to get a fresh iterator, then call next() repeatedly, binding each value, until done: true. Spread, destructuring, Array.from, new Set(x), new Map(pairs), Promise.all — all of them run this same loop. Implement one method, gain every consumer.
Note the separation of roles: the iterable is the collection ("you may traverse me"); the iterator is one traversal in progress (its current is the bookmark). That's why you can loop the same iterable twice and get fresh runs — each loop asks for a new iterator. Strings, Arrays, Maps, Sets, arguments, DOM NodeLists are all built-in iterables; notably plain objects are not (use Object.keys/values/entries to get an iterable view).
Also meet for…of's older sibling honestly: for…in iterates property keys (strings, including inherited enumerable ones) and is for objects; for…of iterates values of an iterable. Using for…in on an array is a classic bug (keys are strings, order isn't guaranteed, inherited props leak in).
3. Generator functions: pausable functions
Writing iterator objects by hand — manual state, manual {value, done} — is boilerplate. The language noticed. A generator function, declared function*, is a function whose body can pause at yield and resume later, keeping all its local state. Calling it does not run the body; it returns a generator object — which is both an iterator and an iterable:
javascript
function* rangeGen(from, to) { // (1) the * makes it a generator function
for (let n = from; n <= to; n++) {
yield n; // (2) PAUSE here, hand n out, keep all state
} // (3) resumed → loop continues where it left off
}
const g = rangeGen(1, 3); // (4) nothing has run yet — g is a paused machine
g.next(); // → { value: 1, done: false } runs UNTIL the first yield
g.next(); // → { value: 2, done: false } resumes, hits yield again
g.next(); // → { value: 3, done: false }
g.next(); // → { value: undefined, done: true } body finished
for (const n of rangeGen(1, 5)) console.log(n); // → 1 2 3 4 5 — it's iterable tooCompare with the hand-written range: the closure state, the bookkeeping, the {value, done} wrapping — all gone. You write the loop as if producing values normally; yield marks the hand-off points; the engine builds the iterator machinery. Our whole range object becomes three lines:
javascript
const range = {
from: 1, to: 5,
*[Symbol.iterator]() { // shorthand generator method
for (let n = this.from; n <= this.to; n++) yield n;
}
};next() moves it from suspended to running; yield moves it back, carrying a value out — and the next next(v) carries a value in. Locals and the paused position survive every suspension.How is pausing even possible? A normal function's locals live in its stack frame and die on return (3.6.1). A generator's frame is stored off the stack, on the heap, inside the generator object; next() puts it back on the stack, yield takes it off. Same reachability trick as closures (3.6.2) applied to an entire execution state — locals, loop counters, and the exact paused position.
3.1 Two-way communication: yield is an expression
The under-taught half: yield doesn't just send values out — it evaluates to whatever the next next(value) sends in:
javascript
function* dialogue() {
const name = yield "What is your name?"; // pauses; resumes with next()'s argument
const lang = yield `Hi ${name}! Favorite language?`;
return `${name} likes ${lang}`;
}
const d = dialogue();
d.next(); // → { value: "What is your name?", done: false } (start; arg ignored)
d.next("Ada"); // name = "Ada" → { value: "Hi Ada! Favorite language?", done: false }
d.next("JS"); // lang = "JS" → { value: "Ada likes JS", done: true }Read the timeline carefully — it trips everyone once: the argument to next() becomes the value of the yield where the generator is currently paused. (The first next() starts the body; there's no paused yield yet, so its argument goes nowhere.) This turns a generator into a coroutine — two routines passing control and values back and forth — and it is precisely the mechanism that made async/await possible: before engines had it natively, libraries ran generators that yielded promises, and a driver called next(result) when each promise resolved. await is that pattern, blessed with syntax (3.6.8).
Completing the control surface: g.return(x) force-finishes the generator (runs its finally blocks — so generators can hold cleanup, like closing a file, that runs even on early exit); g.throw(err) injects an exception at the paused yield, catchable by a try/catch inside the body. Consumers like for…of call return() automatically when you break out — cleanup is not optional politeness; the protocol really runs it.
3.2 yield* — delegation and recursion
yield* other hands control to another iterable/generator until it's exhausted, forwarding every next/throw/return through:
javascript
function* inorder(node) { // binary-tree traversal, lazily (Chapter 4.13)
if (!node) return;
yield* inorder(node.left); // recurse left — values flow straight out
yield node.value;
yield* inorder(node.right);
}
[...inorder(root)]; // → sorted values, no visitor callbacks neededRecursive lazy traversal in four lines — compare with hand-writing an iterator object holding an explicit stack. This is the idiom for trees, nested comments, file-system walks, and any "flatten while walking" task.
3.3 Laziness: infinite sequences and pipelines
Because values are produced on demand, a generator may be infinite:
javascript
function* naturals() { let n = 1; while (true) yield n++; } // infinite — and fine
function* take(iter, count) {
for (const v of iter) {
if (count-- <= 0) return;
yield v;
}
}
function* map(iter, fn) { for (const v of iter) yield fn(v); }
[...take(map(naturals(), n => n * n), 5)]; // → [1, 4, 9, 16, 25]Nothing computes until the spread pulls; each value flows through map and take one at a time — no intermediate arrays, constant memory, work proportional to what you consume. This pull-based pipeline is the same shape as Node streams (3.8.4) and the design behind the (stage-3, verify current status) Iterator-helpers proposal that adds .map/.filter/.take natively. Everyday uses that earn generators their keep: unique-ID mints (function* ids() { let i = 0; while (true) yield ++i; }), round-robin schedulers, pagination cursors, permutation walks, and state machines whose "current state" is simply where the body is paused — the position variable you'd otherwise maintain by hand comes free.
4. Async iteration: for await…of
One protocol level remains. What if each value takes time — pages from an API, chunks from a file? Marry the two big ideas: an async iterator's next() returns a Promise of {value, done}; the iterable advertises itself with Symbol.asyncIterator; and an async generator (async function*) may use both await and yield:
javascript
async function* fetchAllUsers() {
let url = "/api/users?page=1";
while (url) {
const res = await fetch(url); // wait for the network (3.6.8)…
const page = await res.json();
yield* page.items; // …then hand items out one by one
url = page.nextUrl; // pagination cursor, remembered in state
}
}
for await (const user of fetchAllUsers()) { // [!code highlight] // awaits each next()
console.log(user.name); // consumers never see pages or URLs
}The consumer writes a plain loop; pagination, waiting, and state are the generator's private business. for await…of also consumes Node Readable streams directly (for await (const chunk of stream) — 3.8.4), which has quietly become the nicest way to read streams. This is the everyday face of async iteration: an unbounded sequence arriving over time, consumed with loop syntax.
5. The expert lens
Protocols beat inheritance. for…of doesn't care what class you are — only that you speak [Symbol.iterator]. That's duck typing formalized with collision-proof keys, and it's the structural-typing philosophy TypeScript later adopted (3.3) applied at runtime. When you design a library, exposing/consuming protocols ("anything iterable") instead of concrete types (Array only) is what makes it compose with everything else — accept iterables, return iterables.
Generators are the general control-flow primitive. Pausable functions subsume: lazy sequences (this page), coroutines (section 3.1), state machines (state = paused position), and asynchronous sequencing — async/await is a specialized generator driver, and libraries like Redux-Saga run whole effect systems on yield. Recognize the shape "produce/consume step by step, keep state between steps, maybe forever" and reach for a generator before you hand-roll a stateful object.
Laziness is a performance stance, not a trick. Pull-based pipelines process exactly what's consumed with O(1) memory — decisive for large files, search-until-found, and top-K over big data. Its cost: work happens at iteration time (surprising a profiler), a generator object allocates per sequence, and single-pass semantics (an exhausted iterator is done — looping twice needs a fresh call; caching needs an explicit [...xs]). Arrays remain right for small, reused, random-access data; generators win when data is big, infinite, expensive, or arriving over time.
Next: the other half of "JavaScript's weird values" — 3.6.7 puts coercion, equality, and UTF-16 strings under the same derive-don't-memorize lamp.
Recall
- Symbol: unique primitive, usable as a collision-proof property key; well-known symbols (Symbol.iterator,
asyncIterator,toPrimitive…) are how the language defines extensible protocols; symbol keys hide fromObject.keys/JSON. - Iterable = has
[Symbol.iterator]()returning an iterator ={ next() → { value, done } }.for…of, spread, destructuring,Array.from,new Map/Set,Promise.allall consume it.for…in≠for…of(keys of an object vs values of an iterable). - function* + yield = a pausable function: frame lives on the heap;
next()resumes,yieldsuspends carrying a value out;next(v)carriesvin (coroutines — the mechanism behindasync/await);return()/throw()complete the control surface andfor…oftriggers cleanup onbreak; yield* delegates (recursive lazy traversal). - Generators make lazy, infinite, constant-memory pipelines (
take(map(naturals(), f), 5)) and free state machines; single-pass, pay-per-pull semantics. - Async generators (
async function*) + for await…of = sequences over time — pagination, stream chunks — with loop syntax.
Self-test: Write the two iteration interfaces from memory. Why must the protocol key be a symbol, not a string? Trace what three next() calls do to a function* with two yields. What does next("x") deliver, and to which yield? Why can a generator be infinite without hanging? What pairs await with yield, and what loop consumes it?
Quiz Bank
FoundationalWhat are the iterable and iterator protocols, exactly?
Two minimal interfaces. An iterable is any object with a method keyed by the well-known symbol Symbol.iterator that returns an iterator — it means "I can be traversed." An iterator is any object with next() returning { value, done } — it is one traversal in progress, holding the bookmark. for…of calls [Symbol.iterator]() once for a fresh iterator, then next() until done: true; spread, destructuring, Array.from, new Map/Set(...), and Promise.all run the same loop. The split matters: collection vs traversal — two loops over one iterable get independent iterators. Built-in iterables: strings, arrays, Maps, Sets, NodeLists; plain objects are not (use Object.entries and friends).
FoundationalWhat is a Symbol and why does the iteration protocol use one as its key?
A Symbol is a primitive whose every instance is globally unique — Symbol("x") !== Symbol("x") — and which can serve as a property key. The protocol key must be collision-proof: if loop-ability were signaled by a string property like "iterator", every pre-existing object that coincidentally had that property would suddenly change behavior. A well-known symbol can't collide with user code by construction, so the language can retrofit protocols (Symbol.iterator, Symbol.asyncIterator, Symbol.toPrimitive…) onto a 30-year-old ecosystem safely. Bonus semantics: symbol-keyed properties are invisible to for…in, Object.keys, and JSON.stringify — protocol wiring stays out of data's way. Symbol.for(key) provides a global registry when two modules must deliberately share one symbol.
AppliedWhat does calling a generator function do, and what happens on each next()?
Calling function* g(){…} runs none of the body; it allocates a generator object — simultaneously an iterator and an iterable — whose execution frame (locals + current position) lives on the heap, not the stack. Each next() pushes that frame onto the stack and runs the body until the next yield (suspend: frame off the stack, caller receives { value: yieldedValue, done: false }), or until return/end-of-body (caller receives { value: returnValue, done: true }, generator finished). All locals, loop counters, and the paused position survive across suspensions — the closure trick (3.6.2) applied to an entire execution state. That heap-frame mechanism is exactly what makes a pausable function implementable.
InterviewExplain two-way communication with yield — what does next(value) do?
yield is an expression, not just a statement: it sends a value out when suspending, and it evaluates to the argument of the next(value) that resumes it. Timeline: const name = yield "Q?" suspends delivering "Q?"; when the caller later invokes next("Ada"), the generator resumes at that paused yield, which now evaluates to "Ada", so name = "Ada". Caveat everyone hits once: the first next() merely starts the body — no yield is paused yet, so its argument is discarded. Completing the surface: g.return(x) finishes the generator early (running finally cleanup — for…of calls this on break), g.throw(e) raises e at the paused yield (catchable inside). This makes generators coroutines, and it is the historical engine of async/await: drivers yielded promises and fed resolutions back via next(result) — await is that, as syntax.
InterviewWhat real problems do generators solve better than arrays or hand-written iterators? Give concrete use cases.
(1) Lazy/infinite sequences with constant memory — naturals(), ID mints, pagination cursors: values exist only when pulled, so take(map(naturals(), f), 5) does 5 units of work and allocates no intermediate arrays (pull-pipeline shape shared with Node streams). (2) Boilerplate-free iterables — a *[Symbol.iterator]() method turns any object loop-able in one line versus a hand-maintained {next()} state object. (3) Recursive lazy traversal via yield* — in-order tree walks, nested-comment flattening, filesystem walks in a few lines, values streaming out mid-recursion. (4) State machines & coroutines — the "current state" is the paused position, so multi-step dialogues, parsers, and effect systems (e.g. Redux-Saga) read as straight-line code. (5) Async sequences — async function* + for await…of hides pagination/chunking behind a plain loop. Anti-cases: small reusable datasets (arrays: random access, multi-pass, engine-optimized) and hot inner loops (generator next() has call overhead).
StaffDesign review: a service loads a 2 GB JSONL export, JSON.parses every line into an array, filters it, and returns the first 100 matches. Memory blows up. Rework it with the concepts of this page, including the async variant.
The pipeline is eager end-to-end: all lines → all objects → all filtered → take 100; peak memory is the whole dataset for an answer needing 100 items. Rework it pull-based so demand flows backward: take(filter(map(lines, JSON.parse), pred), 100) where each stage is a generator — now parsing happens per-line, at most one object per stage is alive, and the whole computation stops itself after the 100th match because nothing pulls further (laziness gives you early termination for free — no break bookkeeping). Since the source is a file/network stream, make the source an async iterable: read with a Readable stream, split lines in an async function*, and the stages become async generators consumed by for await…of — identical shape, promises per step, and backpressure comes naturally because the loop only awaits the next item when it's ready for it (3.8.4 formalizes this). Constant memory, work ∝ output, cancellation on break (the protocol's automatic return() closes file handles in finally). Flag the trade-offs like a senior: single-pass (cache to an array only if re-scanned), per-item call overhead (irrelevant next to I/O here), and observability (log progress inside stages, since a profiler sees work at pull time).
Flashcards
FlashThe two protocols
Iterable: [Symbol.iterator]() → iterator. Iterator: next() → {value, done}. for…of / spread / destructuring / Array.from all consume them.
Flashfunction* / yield in one line
Pausable function: heap-stored frame; next() resumes to the next yield; state survives suspension; generator object is iterator + iterable.
Flashnext(v) delivers to…
The yield where the generator is currently paused — that yield expression evaluates to v. First next()'s argument is discarded.
Flashyield*
Delegate to another iterable until exhausted, forwarding next/throw/return — the recursive lazy-traversal idiom.
FlashGenerator cleanup
g.return() finishes early and runs finally blocks; for…of calls it automatically on break — put resource cleanup in finally.
FlashAsync iteration
async function* may await and yield; consumed by for await…of; next() returns a Promise of {value, done}. Pagination & stream chunks.
FlashSymbol
Unique primitive usable as a collision-proof key; well-known symbols define language protocols; hidden from Object.keys/JSON.
Scenario Drill
DrillYou must export every record from a third-party REST API that returns 200 records per page with a nextCursor, respect a 5-requests-per-second limit, stop cleanly if the caller aborts, and stream results into a file without holding more than a page in memory. Design it with this page's tools and justify each choice.
The requirements map one-to-one onto async generators. Shape: an async function* records(signal) owns the pagination loop — while (cursor): await the fetch (passing { signal }), yield* the page's items, advance cursor, and await a delay tuned to the rate limit (e.g. 200 ms between requests; a token-bucket helper if bursts are allowed). Because a generator only runs between pulls, memory never exceeds one page: items are handed out one at a time and the next HTTP call cannot even start until the consumer has drained the current page — backpressure by construction, no queue to size.
Consumption: for await (const rec of records(signal)) write(rec) — or pipe the async iterable into a Writable via pipeline() (3.8.4), which also propagates file-write backpressure upstream. Cancellation: the caller's AbortController.abort() does two jobs — in-flight fetch rejects, and the consumer's loop exit triggers the protocol's automatic return(), so a try/finally inside the generator reliably logs progress / flushes state even on abort (put cleanup in finally; the protocol genuinely runs it on break/abort alike).
Resilience: wrap the fetch in retry-with-backoff inside the generator — the paused position is the checkpoint, so a retry resumes exactly at the failed page; persisting cursor in finally makes the whole export resumable across process restarts. Why not eager alternatives: collecting pages into an array breaks the memory bound; a callback pump reimplements by hand the state machine (cursor, in-flight flag, pause/resume, cleanup) that the generator's paused position, yield, and finally give you natively. State the principle: an async generator turns "unbounded remote sequence with pacing, cancellation, and cleanup" into a while-loop.