Generators and the Iteration Protocol: Complete Symbol.iterator Implementation
Calling a generator function does not execute the function body — it returns a generator object. Each call to next() advances execution. This is not syntactic sugar; it is a cooperative multitasking mechanism that passes execution control back and forth between the caller and the function body.
🔹 Level 1 · What You Need to Know
Basic Generator Usage
function* defines a generator function; yield is the suspension point.
function* counter(start = 0) {
while (true) {
yield start++ // pause, return current value of start, then increment
}
}
const gen = counter(10)
console.log(gen.next()) // { value: 10, done: false }
console.log(gen.next()) // { value: 11, done: false }
console.log(gen.next()) // { value: 12, done: false }
// can be called indefinitely — values are computed lazily
Calling counter(10) does not execute the body; it only creates a generator object. The first gen.next() starts execution and runs until the first yield.
Consuming Iterables with for...of
for...of automatically invokes the iteration protocol. Any object with a [Symbol.iterator]() method can be traversed with for...of:
// Built-in iterables: Array, String, Map, Set, arguments, NodeList, etc.
for (const char of 'hello') {
console.log(char) // 'h', 'e', 'l', 'l', 'o'
}
// Generator objects are themselves iterable
function* range(start, end) {
for (let i = start; i < end; i++) {
yield i
}
}
for (const n of range(1, 5)) {
console.log(n) // 1, 2, 3, 4
}
// Spread and destructuring also use the iteration protocol
const arr = [...range(1, 5)] // [1, 2, 3, 4]
const [first, second] = range(1, 5) // first=1, second=2
Sending Values Into a Generator
The argument to next(value) becomes the value of the corresponding yield expression, enabling two-way communication:
function* dialog() {
const name = yield 'What is your name?'
const age = yield `${name}, how old are you?`
yield `${name}, ${age} years old — nice to meet you!`
}
const gen = dialog()
console.log(gen.next().value) // 'What is your name?'
console.log(gen.next('Alice').value) // 'Alice, how old are you?'
console.log(gen.next(28).value) // 'Alice, 28 years old — nice to meet you!'
Lazy Evaluation with Infinite Sequences
Generators are a natural tool for lazy evaluation. A Fibonacci generator:
function* fibonacci() {
let [a, b] = [0, 1]
while (true) {
yield a
;[a, b] = [b, a + b]
}
}
// Take the first 10 Fibonacci numbers
function take(n, iterable) {
const result = []
for (const value of iterable) {
result.push(value)
if (result.length >= n) break
}
return result
}
console.log(take(10, fibonacci()))
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
🔸 Level 2 · How It Actually Works
The Iteration Protocol: Two Interfaces
ECMAScript's iteration protocol is composed of two separate interfaces:
The Two Interfaces of the Iteration Protocol:
Iterable Interface
┌────────────────────────────────────────┐
│ [Symbol.iterator](): Iterator │
│ Returns an Iterator object │
└────────────────────────────────────────┘
Iterator Interface
┌────────────────────────────────────────┐
│ next(): IteratorResult │
│ Required — advances the iterator │
│ │
│ return(value?): IteratorResult │
│ Optional — early termination │
│ (for resource cleanup) │
│ │
│ throw(exception?): IteratorResult │
│ Optional — inject an exception │
└────────────────────────────────────────┘
IteratorResult
┌────────────────────────────────────────┐
│ value: any the current value │
│ done: boolean true means exhausted │
└────────────────────────────────────────┘
Generator objects implement both interfaces simultaneously: they are Iterable (gen[Symbol.iterator]() === gen) and Iterator (they have next/return/throw methods).
The 4-State Generator State Machine
The generator object's [[GeneratorState]] internal slot tracks the current state:
Generator State Machine:
on creation
│
▼
┌──────────────────┐
│ suspendedStart │ ← created but body not yet started
└──────────────────┘
│
│ first next()
▼
┌──────────────────┐ yield expr
│ executing │ ──────────────► ┌──────────────────┐
│ (body running) │ │ suspendedYield │
└──────────────────┘ ◄────────────── └──────────────────┘
│ next() call
│
│ return / throw / body exhausted
▼
┌──────────────────┐
│ completed │ ← terminal; further next() returns {value:undefined, done:true}
└──────────────────┘
State transitions:
suspendedStart → executing : first next() call
executing → suspendedYield : execution reaches a yield expression
suspendedYield → executing : next()/return()/throw() call
executing → completed : return statement / body exhausted / uncaught exception
any state + return() : force transition to completed
function* gen() {
console.log('A')
yield 1
console.log('B')
yield 2
console.log('C')
}
const g = gen()
// state: suspendedStart — no output yet
g.next()
// prints 'A', pauses at yield 1
// returns { value: 1, done: false }
// state: suspendedYield
g.next()
// prints 'B', pauses at yield 2
// returns { value: 2, done: false }
// state: suspendedYield
g.next()
// prints 'C', body exhausted
// returns { value: undefined, done: true }
// state: completed
How for...of Executes Internally
for (const x of expr) expands to the following in the spec:
for...of execution:
1. Evaluate expr → obj
2. Call GetIterator(obj, sync):
a. Call obj[Symbol.iterator]() → iterator
b. Verify iterator is an object (TypeError otherwise)
3. Loop:
a. Call iterator.next() → iterResult
b. Check iterResult.done:
- done === true: exit loop
- done === false: assign iterResult.value to loop variable, execute body
4. If loop body exits via break:
a. If iterator.return exists, call iterator.return()
(allows the generator to run finally blocks and release resources)
5. If loop body throws:
a. If iterator.return exists, call iterator.return()
b. Rethrow the exception
function* withCleanup() {
try {
yield 1
yield 2
yield 3
} finally {
console.log('cleanup!') // triggered when break is used
}
}
for (const n of withCleanup()) {
console.log(n)
if (n === 2) break // triggers iterator.return(), which triggers finally
}
// output: 1 2 cleanup!
How yield* Works
yield* delegates iteration to another iterable and forwards return()/throw() calls:
yield* delegation mechanism:
function* outer() {
const result = yield* inner()
console.log(result)
}
yield* does the following:
1. Call inner()[Symbol.iterator]() → innerIterator
2. Loop: call innerIterator.next(value),
forwarding each {value, done:false} result to the outer caller
3. When innerIterator.done === true:
the final value becomes the result of the yield* expression
4. Values passed to the outer next(value) are forwarded to innerIterator.next(value)
5. Exceptions from outer throw(e) are forwarded to innerIterator.throw(e)
6. Calls to outer return(v) are forwarded to innerIterator.return(v)
function* inner() {
yield 'a'
yield 'b'
return 'inner done' // this is the return value of yield*
}
function* outer() {
const result = yield* inner()
console.log('inner returned:', result) // 'inner done'
yield 'c'
}
console.log([...outer()]) // ['a', 'b', 'c']
// and console logs: inner returned: inner done
Injecting Exceptions into a Generator
generator.throw(error) resumes the generator but causes the current yield expression to throw. The generator can catch it with try/catch:
throw() execution flow:
gen.throw(new Error('boom'))
│
▼
generator resumes, but the current yield expression throws Error('boom')
│
├── yield is inside try/catch:
│ catch block handles the error, execution continues
│ next yield value is returned to the throw() caller
│
└── no try/catch:
exception propagates outside the generator
generator transitions to completed
function* safeDivide() {
let result
try {
const a = yield 'Enter dividend'
const b = yield 'Enter divisor'
result = a / b
} catch (e) {
console.log('Error caught:', e.message)
result = 0
}
return result
}
const gen = safeDivide()
gen.next() // { value: 'Enter dividend', done: false }
gen.next(10) // { value: 'Enter divisor', done: false }
gen.throw(new Error('Cannot divide by zero'))
// logs: Error caught: Cannot divide by zero
// returns: { value: 0, done: true }
🔺 Level 3 · What the Spec Says
27.1 The Iteration Protocol
Spec 27.1.1 defines the Iterable Interface: objects must support the @@iterator method (i.e., [Symbol.iterator]), which when called with no arguments returns an object satisfying the Iterator Interface.
Spec 27.1.2 defines the Iterator Interface:
next()method: required; returns an IteratorResultreturn(value)method: optional; for early terminationthrow(value)method: optional; for exception injection
Spec 27.1.3 defines the AsyncIterator Interface: identical to the synchronous version, but next()/return()/throw() return Promises (used with for await...of).
GeneratorObject Internal Slots
Generator objects are Generator Exotic Objects with the following internal slots:
GeneratorObject Internal Slots:
┌──────────────────────┬──────────────────────────────────────────┐
│ [[GeneratorState]] │ suspendedStart / executing / │
│ │ suspendedYield / completed │
├──────────────────────┼──────────────────────────────────────────┤
│ [[GeneratorContext]] │ Saved execution context when suspended │
│ │ (includes variable bindings, current PC) │
├──────────────────────┼──────────────────────────────────────────┤
│ [[GeneratorBrand]] │ Generator brand (used by │
│ │ AsyncGeneratorObject to distinguish) │
└──────────────────────┴──────────────────────────────────────────┘
[[GeneratorContext]] is a complete snapshot of the execution context at suspension: it preserves the current variable environment, lexical environment, and instruction pointer (which yield statement we paused at). When next() is called, the engine pushes this context back onto the execution context stack and resumes from the breakpoint.
The GeneratorResume Algorithm
Spec 27.5.3.3 GeneratorResume(generator, value, generatorBrand):
GeneratorResume(generator, value, generatorBrand):
1. state = generator.[[GeneratorState]]
2. If state === 'completed':
return CreateIterResultObject(undefined, true)
3. Assert: state is 'suspendedStart' or 'suspendedYield'
4. genContext = generator.[[GeneratorContext]]
5. methodContext = the running execution context
6. Push genContext onto the execution context stack
(it becomes the running execution context)
7. Set generator.[[GeneratorState]] to 'executing'
8. If state === 'suspendedStart':
start executing the function body from the beginning
Else (suspendedYield):
return value as the result of the yield expression to the function body
9. Execute function body until next yield or body end
10. Execution context stack is restored
11. If yield expr encountered: call GeneratorYield(value)
If body ends (return value):
generator.[[GeneratorState]] = 'completed'
return CreateIterResultObject(returnValue, true)
The GeneratorYield Algorithm
Spec 27.5.3.7 GeneratorYield(iterNextObj):
GeneratorYield(iterNextObj):
1. genContext = the running execution context
// genContext is the generator's execution context
2. generator = the generator object associated with genContext
3. Assert: generator.[[GeneratorState]] === 'executing'
4. Set generator.[[GeneratorState]] to 'suspendedYield'
5. Pop genContext from the execution context stack
Set generator.[[GeneratorContext]] = genContext
// saves current execution state including PC and all locals
6. callerContext = top of execution context stack (caller's context)
Resume callerContext
7. Return iterNextObj to the caller — {value: yieldValue, done: false}
8. Note: GeneratorYield does not return immediately
It "returns" on the next GeneratorResume call —
the value of the yield expression is the next next(value) argument
The GetIterator Algorithm
Spec 7.4.1 GetIterator(obj, hint, method):
GetIterator(obj, sync):
1. If method is not specified:
- hint is 'sync': method = GetMethod(obj, @@iterator)
- hint is 'async': method = GetMethod(obj, @@asyncIterator)
2. Call(method, obj) → iterator
3. If iterator is not an object: throw TypeError
4. nextMethod = GetMethod(iterator, 'next')
5. Return { [[Iterator]]: iterator, [[NextMethod]]: nextMethod, [[Done]]: false }
// The spec calls this an IteratorRecord
💎 Level 4 · Edge Cases and Traps
Trap 1: The Return Value of yield* Is Easily Overlooked
yield* collects the final return value from the inner iterator (the value when done: true), but only const result = yield* inner() captures it. When consuming with for...of, the final value (from a return statement) is discarded:
function* gen() {
return 'final' // value when done: true is 'final'
}
// Captured via yield*
function* outer() {
const result = yield* gen()
console.log(result) // 'final'
}
[...outer()]
// Consuming via for...of discards the return value
for (const v of gen()) {
console.log(v) // nothing printed (gen() has no yield)
}
// no output — 'final' is discarded
// Only manual next() reveals the return value
const g = gen()
console.log(g.next()) // { value: 'final', done: true }
Trap 2: generator.return() Interaction with finally
Calling generator.return(value) causes the generator to transition to completed, but if there is a finally block inside the generator, it will execute. And if finally contains a yield, the generator pauses again:
function* gen() {
try {
yield 1
yield 2
} finally {
console.log('finally executing')
yield 'from finally' // yield inside finally!
console.log('finally done')
}
}
const g = gen()
console.log(g.next()) // { value: 1, done: false }
console.log(g.return('end'))
// logs: finally executing
// returns: { value: 'from finally', done: false } — paused again due to yield in finally!
console.log(g.next())
// logs: finally done
// returns: { value: 'end', done: true } — 'end' returned after finally completes
This is explicitly defined behavior (spec 27.5.3.5 GeneratorReturn): if the finally block executes a yield, the return value is held in suspension and returned only after finally completes.
Trap 3: The First next() Value Is Always Discarded
When calling next(value) for the first time, value is always discarded. The first next() starts execution from the beginning of the function body; there is no yield expression waiting to receive a value:
function* gen() {
const x = yield 'first' // value from the 2nd next(value) becomes x
const y = yield 'second' // value from the 3rd next(value) becomes y
return x + y
}
const g = gen()
console.log(g.next(999)) // { value: 'first', done: false } — 999 is discarded
console.log(g.next(10)) // { value: 'second', done: false } — x = 10
console.log(g.next(20)) // { value: 30, done: true } — y = 20, returns x+y=30
Spec reason: in GeneratorResume, when state is suspendedStart (first call), execution begins from the start and the value parameter is unused.
Trap 4: Iterators vs Iterables — the Confusion
for...of and the spread operator expect an Iterable (an object with [Symbol.iterator]()), not just an Iterator (an object with next()). Generator objects happen to be both:
// Hand-written iterator (has next, no Symbol.iterator)
const iterator = {
count: 0,
next() {
return this.count < 3
? { value: this.count++, done: false }
: { value: undefined, done: true }
}
}
// for...of fails!
for (const v of iterator) { // TypeError: iterator is not iterable
console.log(v)
}
// Fix: make the iterator also iterable
const fixedIterator = {
count: 0,
next() {
return this.count < 3
? { value: this.count++, done: false }
: { value: undefined, done: true }
},
[Symbol.iterator]() { return this } // return self
}
for (const v of fixedIterator) { // works now
console.log(v) // 0, 1, 2
}
// Generator objects naturally implement both interfaces
function* g() { yield 1; yield 2 }
const gen = g()
console.log(gen[Symbol.iterator]() === gen) // true — returns self
Trap 5: Resource Leaks with Async Generators and for await...of
When using async generators with for await...of, an early break will call return() to close the generator, but without a finally block, external resources (database connections, file handles, etc.) may not be released:
async function* dbRows(connection) {
try {
let cursor = await connection.openCursor()
while (cursor.hasMore()) {
yield await cursor.fetchNext()
}
} finally {
await connection.close() // essential — guarantees resource release
}
}
// Correct usage
for await (const row of dbRows(conn)) {
process(row)
if (shouldStop(row)) break // break triggers return(), finally closes connection
}
Without finally, the break exit leaves the connection open. Every generator holding external resources must use try...finally.
Chapter Summary
- The iteration protocol consists of two interfaces: Iterable (has
[Symbol.iterator]()) and Iterator (hasnext()). Generator objects satisfy both simultaneously:gen[Symbol.iterator]() === gen. - Generators have 4 states:
suspendedStart→executing→suspendedYield→completed. Eachyieldsaves the complete execution context; the nextnext()call resumes from the breakpoint. - The argument to the first
next()call is always discarded. Subsequent arguments become the value of the precedingyieldexpression, enabling two-way communication. generator.return(value)forces the generator to terminate, but if there is afinallyblock, it executes first; ayieldinsidefinallywill pause the generator again before it completes.- Generators holding external resources (database connections, file handles, etc.) must use
try...finallyto ensure cleanup, because callers maybreakout of iteration at any point.