Iterators and the Iterable Protocol: How for...of Really Works
Chapter 33: Iterator Protocol and Iterable Protocol โ The Design Philosophy of a Unified Interface
for...ofcan traverse arrays, strings, Maps, Sets, generators, and DOM NodeLists not through special-casing, but through a unified duck-typed protocol โ any object implementing this protocol can be iterated.
Core question of this chapter: How does the ECMAScript iterator protocol unify data consumption and data production at the spec level, what is for...of's spec execution path, and how do you implement a complete custom iterator (including the resource-cleaning return() method)?
After reading this chapter you will understand:
- The spec definitions of the iterable protocol (
@@iterator/Symbol.iterator) and iterator protocol (next()/return()/throw()) - The complete spec execution steps for
for...of: GetIterator โ IteratorStep โ IteratorValue - Generator functions as iterator factories โ spec implementation and essential differences from regular functions
- Iteration behavior differences across built-in iterables (Array, String, Map, Set, arguments)
- The async iteration protocol (
@@asyncIterator) and howfor await...ofworks
Level 1 ยท What You Need to Know (1-3 Years Experience)
Before ES2015 introduced the iterator protocol, traversing different data structures required different APIs: arrays used indices, Maps used forEach, and custom collections had no unified approach. The iterator protocol solved this โ any object implementing the specified interface can be uniformly processed by all iterator-consuming syntax (for...of, spread, destructuring, Array.from).
Two Layers of Protocol
Iterable Protocol: An object must have a [Symbol.iterator] method that returns an iterator object.
Iterator Protocol: An iterator object must have a next() method that returns an object in the form { value: any, done: boolean } (called an IteratorResult).
// Check if an object is iterable
function isIterable(obj) {
return obj != null && typeof obj[Symbol.iterator] === 'function'
}
isIterable([1, 2, 3]) // true
isIterable('hello') // true
isIterable(new Map()) // true
isIterable(new Set()) // true
isIterable({ a: 1 }) // false (plain objects are not iterable)
isIterable(42) // false
isIterable(null) // false
Manually Using an Iterator
const arr = [10, 20, 30]
const iter = arr[Symbol.iterator]() // get the iterator
console.log(iter.next()) // { value: 10, done: false }
console.log(iter.next()) // { value: 20, done: false }
console.log(iter.next()) // { value: 30, done: false }
console.log(iter.next()) // { value: undefined, done: true }
console.log(iter.next()) // { value: undefined, done: true } (continued calls don't throw)
How for...of Works
for...of is syntactic sugar for the above manual steps:
// Using for...of
for (const x of [10, 20, 30]) {
console.log(x)
}
// Output: 10, 20, 30
// Equivalent manual implementation
const _iterable = [10, 20, 30]
const _iter = _iterable[Symbol.iterator]()
let _result
while (!(_result = _iter.next()).done) {
const x = _result.value
console.log(x)
}
Common Built-in Iterables
| Type | Iteration Result | Example |
|---|---|---|
Array |
Each element | for (const x of [1,2,3]) โ 1, 2, 3 |
String |
Each Unicode code point (not byte) | for (const c of '๐') โ '๐' (once) |
Map |
[key, value] pairs |
for (const [k,v] of map) |
Set |
Each value | for (const v of set) |
arguments |
Each argument value | for (const a of arguments) |
NodeList |
Each DOM node | for (const el of document.querySelectorAll('div')) |
generator |
Each yielded value | for (const v of gen()) |
String's Special Behavior
// String iterates by Unicode code points, not UTF-16 code units
const emoji = '๐๐'
console.log(emoji.length) // 4 (two emoji, each occupying 2 UTF-16 code units)
const chars = [...emoji]
console.log(chars) // ['๐', '๐'] (2 elements, iterated by code point)
console.log(chars.length) // 2
// Traditional for loop splits surrogate pairs:
for (let i = 0; i < emoji.length; i++) {
console.log(emoji[i]) // outputs 4 times, including garbled surrogate characters
}
Level 2 ยท How It Works (3-5 Years Experience)
Generator Functions as Iterator Factories
Generator functions are the most concise way to implement iterables. A generator function call returns an object that simultaneously implements both the iterable protocol and the iterator protocol (called a Generator object):
function* range(start, end, step = 1) {
for (let i = start; i < end; i += step) {
yield i
}
}
// The object returned by range() is simultaneously an iterator and an iterable
const r = range(0, 10, 2)
console.log(typeof r[Symbol.iterator]) // 'function'
console.log(typeof r.next) // 'function'
console.log(r[Symbol.iterator]() === r) // true! generator's @@iterator returns itself
for (const n of range(0, 5)) {
console.log(n) // 0, 1, 2, 3, 4
}
// The factory can be used multiple times (each range() call creates a new iterator)
const nums1 = [...range(1, 4)] // [1, 2, 3]
const nums2 = [...range(1, 4)] // [1, 2, 3] (fresh iterator)
Infinite Sequences and Lazy Evaluation
One of the core advantages of the iterator protocol is lazy evaluation โ the next value is only generated when needed:
// Infinite Fibonacci sequence
function* fibonacci() {
let [a, b] = [0, 1]
while (true) {
yield a
;[a, b] = [b, a + b]
}
}
// Take first 10: only 10 calculations, not generating the entire sequence
const fibIter = fibonacci()
const first10 = Array.from({ length: 10 }, () => fibIter.next().value)
console.log(first10) // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
// Chained lazy operations (no intermediate arrays)
function* take(n, iterable) {
let count = 0
for (const item of iterable) {
if (count >= n) return
yield item
count++
}
}
function* filter(predicate, iterable) {
for (const item of iterable) {
if (predicate(item)) yield item
}
}
function* map2(fn, iterable) {
for (const item of iterable) {
yield fn(item)
}
}
// Take first 5 even Fibonacci numbers from an infinite sequence
const result = [...take(5, filter(n => n % 2 === 0, fibonacci()))]
console.log(result) // [0, 2, 8, 34, 144]
// This operation only holds the current value in memory โ no intermediate arrays
Three Methods of an Iterator
The complete iterator protocol includes three methods:
// Complete iterator interface
const iterator = {
// Required
next(value) {
// value is what's passed into next() (used for communicating with generators)
return { value: any, done: boolean }
},
// Optional: called when iteration is terminated early
return(value) {
// Clean up resources (close files, release database connections, etc.)
return { value: value, done: true }
},
// Optional: throw an error into the iterator
throw(error) {
// Handle or re-throw the error
return { value: any, done: boolean }
}
}
return() is called in the following situations:
- A
for...ofloop is terminated early bybreak,return, orthrow - Destructuring doesn't fully consume the iterator (see Chapter 32)
// Complete custom iterator (with resource cleanup)
function createDBCursor(query) {
let connection = openConnection()
let cursor = connection.execute(query)
let closed = false
return {
[Symbol.iterator]() { return this },
next() {
if (closed || !cursor.hasNext()) {
closed = true
connection.close()
return { value: undefined, done: true }
}
return { value: cursor.next(), done: false }
},
return(value) {
if (!closed) {
closed = true
connection.close() // clean up connection on early termination
console.log('Connection closed early')
}
return { value, done: true }
}
}
}
// Using break to terminate early, return() is automatically called
for (const row of createDBCursor('SELECT * FROM users')) {
if (row.id === targetId) {
break // โ triggers return(), connection is properly closed
}
}
Iterable Object vs Iterator
An important distinction:
// Iterable object: each call to [Symbol.iterator]() returns a new iterator
// Iterator itself: typically can only be traversed once
const arr = [1, 2, 3] // iterable object
// Can be traversed multiple times
for (const x of arr) {} // first time
for (const x of arr) {} // second time (brand new iterator)
// But holding the iterator directly, you can only traverse once:
const iter = arr[Symbol.iterator]() // iterator
for (const x of iter) { console.log(x) } // 1, 2, 3
for (const x of iter) { console.log(x) } // no output! iterator is exhausted
// Generator objects are iterators (not iterable factories):
function* gen() { yield 1; yield 2 }
const g = gen() // iterator
for (const x of g) {} // 1, 2
for (const x of g) {} // no output! g is exhausted
// To iterate again, call gen() again
Async Iteration Protocol
ES2018 introduced the async iteration protocol for handling asynchronous data streams:
// Async iterable protocol: [Symbol.asyncIterator]() returns an async iterator
// Async iterator protocol: next() returns Promise<{value, done}>
// Async iterator for reading data streams
async function* readLines(stream) {
const reader = stream.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { value, done } = await reader.read()
if (done) break
yield decoder.decode(value)
}
} finally {
reader.releaseLock() // clean up resources, whether normal or early termination
}
}
// Using for await...of
async function processStream(stream) {
for await (const line of readLines(stream)) {
console.log(line)
}
}
// Async generator implementing paginated loading
async function* fetchAllPages(url) {
let nextUrl = url
while (nextUrl) {
const response = await fetch(nextUrl)
const data = await response.json()
yield* data.items // yield* expands an iterable
nextUrl = data.nextPage
}
}
for await (const item of fetchAllPages('/api/items')) {
console.log(item)
}
Level 3 ยท How the Spec Defines It (Senior Developers)
The GetIterator Spec Algorithm
The first step of for...of is GetIterator, defined in spec ยง7.4.1:
7.4.1 GetIterator ( obj, kind [ , method ] )
- If
methodis not present, then a. Ifkindisasync, then i. Setmethodto ?GetMethod(obj, @@asyncIterator). ii. Ifmethodisundefined, then 1. LetsyncMethodbe ?GetMethod(obj, @@iterator). 2. LetsyncIteratorRecordbe ?GetIterator(obj, sync, syncMethod). 3. ReturnCreateAsyncFromSyncIterator(syncIteratorRecord). b. Otherwise, setmethodto ?GetMethod(obj, @@iterator).- Let
iteratorbe ?Call(method, obj).- If
iteratoris not an Object, throw a TypeError exception.- Let
nextMethodbe ?GetMethod(iterator, "next").- Let
iteratorRecordbe the Iterator Record{ [[Iterator]]: iterator, [[NextMethod]]: nextMethod, [[Done]]: false }.- Return
iteratorRecord.
Commentary:
Step 1b: Calls obj[Symbol.iterator], which is why non-iterable objects throw TypeError: obj is not iterable in for...of โ GetMethod(obj, @@iterator) returns undefined, then Call(undefined, obj) throws TypeError.
Step 3: iterator must be an object! If [Symbol.iterator]() returns a primitive (like 42), a TypeError is thrown immediately. This requires custom iterators' [Symbol.iterator]() methods to return an object.
Step 4: nextMethod is cached at GetIterator time, not dynamically looked up at each IteratorStep. This means changing iterator.next after iteration starts won't affect the ongoing iteration.
Step 1a: For async iteration, if an object lacks [Symbol.asyncIterator], the engine automatically falls back to [Symbol.iterator] and wraps it with CreateAsyncFromSyncIterator, allowing synchronous iterators to be used with for await...of.
The IteratorStep Spec Algorithm
7.4.5 IteratorStep ( iteratorRecord )
- Let
resultbe ?IteratorNext(iteratorRecord).- Let
donebe ?IteratorComplete(result).- If
doneistrue, returnfalse.- Return
result.
IteratorNext (ยง7.4.4):
- Let
resultbe ?Call(iteratorRecord.[[NextMethod]], iteratorRecord.[[Iterator]]).- If
resultis not an Object, throw a TypeError exception.- Return
result.
Commentary:
IteratorNext calls the cached [[NextMethod]] (rather than looking it up each time) and requires the return value to be an object. If next() returns null, 42, "string", or any other primitive, a TypeError is thrown immediately. This is a common error in custom iterators:
// Broken iterator implementation
const badIterator = {
[Symbol.iterator]() { return this },
next() {
return true // โ returns a primitive! TypeError: Iterator result true is not an object
}
}
// Correct iterator implementation
const goodIterator = {
[Symbol.iterator]() { return this },
_count: 0,
next() {
return this._count < 3
? { value: this._count++, done: false } // โ returns object
: { value: undefined, done: true } // โ returns object
}
}
Complete for...of Spec Execution Steps
In the ECMAScript spec, the for...of statement (for ( LeftHandSideExpression of AssignmentExpression )) runtime semantics involve ForIn/OfHeadEvaluation and ForIn/OfBodyEvaluation:
ForIn/OfHeadEvaluation (simplified):
- Let
exprRefbe the result of evaluatingAssignmentExpression.- Let
exprValuebe ?GetValue(exprRef).- If
iterationKindisenumerate, ... (for-in path)- Else, let
iteratorRecordbe ?GetIterator(exprValue, iterationKind).- Return
iteratorRecord.
ForIn/OfBodyEvaluation (simplified):
- Repeat, a. Let
nextResultbe ?IteratorStep(iteratorRecord). b. IfnextResultisfalse, returnNormalCompletion(V). c. LetnextValuebe ?IteratorValue(nextResult). d. PerformIteratorBindingInitializationoflhswithnextValue. e. Letresultbe the result of evaluatingstmt. f. IfLoopContinues(result, labelSet)isfalse, return ?IteratorClose(iteratorRecord, UpdateEmpty(result, V)). g. Ifresult.[[Value]]is notempty, setVtoresult.[[Value]].
Step f is critical: when break, return, or throw occurs inside the loop body (LoopContinues returns false), the spec calls IteratorClose, which triggers the iterator's return() method, completing resource cleanup.
The IteratorClose Spec Algorithm
7.4.7 IteratorClose ( iteratorRecord, completionRecord )
- Let
iteratorbeiteratorRecord.[[Iterator]].- Let
innerResultbeGetMethod(iterator, "return").- If
innerResult.[[Type]]isnormal, then a. LetreturnbeinnerResult.[[Value]]. b. Ifreturnisundefined, return ?completionRecord. c. SetinnerResulttoCall(return, iterator).- If
completionRecord.[[Type]]isthrow, return ?completionRecord.- If
innerResult.[[Type]]isthrow, return ?innerResult.- If
innerResult.[[Value]]is not an Object, throw a TypeError exception.- Return ?
completionRecord.
Commentary:
Step 2: Looking up the return method โ if it doesn't exist (step 3b), it's skipped without an error. The return() method is optional.
Steps 4-5: Error handling priority โ if the caller is already handling a throw (e.g., an exception was thrown inside the for...of body), the original error takes priority; only when there's no original error does an error from return() propagate.
Step 6: If the return() method returns a primitive, a TypeError is thrown โ return() must also return an object.
Generator Spec Implementation
A generator function call returns a Generator object. The spec defines its internal state as:
A Generator object is created by invoking a generator function. It is a suspended function execution that can be resumed.
Generator objects have the following internal slots:
[[GeneratorState]]:suspended-start|executing|suspended-yield|completed[[GeneratorContext]]: the execution context (containing local variables, suspension point)[[GeneratorBrand]]: identifier (for distinguishing different generator types)
GeneratorNext (ยง27.4.3.2):
- Return ?
GeneratorResume(generator, value, empty).
GeneratorResume:
- Let
statebe generator.[[GeneratorState]].- If
stateiscompleted, returnCreateIterResultObject(undefined, true).- Assert:
stateissuspended-startorsuspended-yield.- Let
genContextbe generator.[[GeneratorContext]].- Let
methodContextbe the running execution context.- Suspend
methodContext.- Set generator.
[[GeneratorState]]toexecuting.- Push
genContextonto the execution context stack;genContextis now the running execution context.- Resume the suspended evaluation of
genContextusingNormalCompletion(value)as the result of the operation that suspended it.- Assert: When we reach this step,
genContexthas already been removed from the execution context stack andmethodContextis the currently running execution context.- Return
result.
Commentary: This reveals the core generator mechanism: generators don't save ordinary data โ they save an entire Execution Context, including the current position (PC pointer), local variables, and the scope chain. Each next() call suspends the current execution context and restores the generator's execution context to the top of the stack. This is why generators can "freeze" at a yield point and resume in exactly the same state.
Implementation Details of Symbol.iterator
The spec defines the @@iterator method behavior of built-in iterables:
Array.prototype[@@iterator] (ยง23.1.3.14):
Returns an ArrayIterator object whose
[[ArrayIteratorNextIndex]]is 0 and[[ArrayIterationKind]]isvalue.
Array iterators record the current array and starting index at creation. Modifying the array during iteration affects subsequent iteration results:
const arr3 = [1, 2, 3, 4, 5]
const iter2 = arr3[Symbol.iterator]()
console.log(iter2.next()) // { value: 1, done: false }
arr3.splice(2, 0, 99) // insert 99 at index 2, arr3 = [1, 2, 99, 3, 4, 5]
console.log(iter2.next()) // { value: 2, done: false }
console.log(iter2.next()) // { value: 99, done: false } (iterated to the inserted element!)
console.log(iter2.next()) // { value: 3, done: false }
Map.prototype[@@iterator] (ยง24.1.3.11) and Set.prototype[@@iterator] (ยง24.2.3.10) follow "insertion order" iteration; adding/removing elements during iteration:
const map = new Map([[1, 'a'], [2, 'b'], [3, 'c']])
const mapIter = map[Symbol.iterator]()
console.log(mapIter.next()) // { value: [1, 'a'], done: false }
map.set(4, 'd') // add new element
console.log(mapIter.next()) // { value: [2, 'b'], done: false }
map.delete(3) // delete element
console.log(mapIter.next()) // { value: [4, 'd'], done: false } (3 is skipped!)
console.log(mapIter.next()) // { value: undefined, done: true }
Level 4 ยท Edge Cases and Traps (All Experience Levels)
Trap 1: Resource Cleanup Timing for Generator return()
function* resourceGenerator() {
const resource = acquireResource()
try {
yield* processItems(resource)
} finally {
releaseResource(resource) // will this be called?
}
}
const gen3 = resourceGenerator()
gen3.next() // start executing, acquire resource
// Case 1: Normal completion
for (const item of gen3) {} // finally WILL execute
// Case 2: Early break
for (const item of gen3) {
break // for...of calls gen3.return(), triggers finally
}
// Case 3: Directly discard the iterator (without break)
const gen4 = resourceGenerator()
gen4.next()
// gen4 gets garbage collected, finally NEVER executes!
// JavaScript's GC does not call return()
// Correct resource management: explicitly call return() or wrap for...of in try...finally
try {
for (const item of resourceGenerator()) {
if (shouldStop) break // break triggers return()
}
} catch (e) {
// Even if an exception is thrown, for...of calls return()
}
The nature of this trap: If you directly manipulate an iterator (without for...of) and discard the reference before completion, return() is not automatically called. Only for...of, array destructuring, Array.from, and similar syntax automatically call return() on early termination.
Trap 2: Modifying Collections During for...of
// Trap: deleting already-visited elements when traversing a Set with for...of
const set = new Set([1, 2, 3, 4, 5])
for (const item of set) {
if (item % 2 === 0) {
set.delete(item) // delete even numbers
}
console.log(item)
}
// Output: 1, 2, 3, 4, 5 (all elements are visited!)
// Deletion takes effect, but the iterator already held a reference to the next element
// Set iterators work by insertion order; deleting already-visited elements doesn't affect subsequent iteration
// Contrast: adding new elements
const set2 = new Set([1, 2, 3])
for (const item of set2) {
if (item === 1) set2.add(4) // add during iteration
console.log(item)
}
// Output: 1, 2, 3, 4 (4 was iterated! because it was added before its position in the iteration)
Trap 3: Concurrency Issues in Async Iteration
// Trap: concurrent calls to an async iterator's next()
async function* asyncCounter() {
for (let i = 0; i < 5; i++) {
await new Promise(r => setTimeout(r, 10))
yield i
}
}
// Wrong: initiating multiple next() calls simultaneously
const iter3 = asyncCounter()
const p1 = iter3.next() // 1st next()
const p2 = iter3.next() // 2nd next() (during 1st's await!)
const p3 = iter3.next() // 3rd next()
// The spec allows this behavior, but the generator has only one execution context
// Result: p1, p2, p3 will resolve in sequence, not concurrently executing the generator body
// This is not true concurrency โ it's queued sequential calling
// Correct: use for await...of to guarantee sequential processing
async function processAll() {
for await (const value of asyncCounter()) {
// Only requests the next value after each await completes
console.log(value)
}
}
Trap 4: The Symbol.iterator Self-Reference in Iterator Protocol
// Spec requirement: iterators should themselves be iterable
// i.e., iterator[Symbol.iterator]() should return this
// This allows iterators to be used directly with for...of:
function* gen5() { yield 1; yield 2; yield 3 }
const iter4 = gen5()
iter4.next() // consume the first value
// iter4 is now both an iterator and an iterable
for (const x of iter4) {
console.log(x) // 2, 3 (continues from current position, not from the beginning)
}
// If a custom iterator doesn't implement [Symbol.iterator]() returning self:
const brokenIter = {
_count: 0,
next() {
return this._count < 3
? { value: this._count++, done: false }
: { value: undefined, done: true }
}
// Missing [Symbol.iterator]!
}
for (const x of brokenIter) { } // TypeError: brokenIter is not iterable
// Fix: add [Symbol.iterator]() { return this }
Trap 5: Behavioral Details of Generator.prototype.return()
// Generator's return() method behaves differently from plain iterator's return()
function* gen6() {
try {
yield 1
yield 2
yield 3
} finally {
yield 4 // yield in finally block prevents return() from completing immediately!
yield 5
}
}
const g2 = gen6()
console.log(g2.next()) // { value: 1, done: false }
console.log(g2.return(9)) // { value: 4, done: false } (NOT { value: 9, done: true }!)
// Because finally block has yield, generator continues to the yield in finally
console.log(g2.next()) // { value: 5, done: false }
console.log(g2.next()) // { value: 9, done: true } (now returns the value passed to return)
This behavior is defined in the spec's GeneratorReturn algorithm. It passes a return(value) completion record to the generator's resume point, and the generator propagates this completion record only after executing through the finally block.
Implementing a Complete Custom Iterator
// Production-grade custom iterable implementation
class Range {
constructor(start, end, step = 1) {
this.start = start
this.end = end
this.step = step
}
[Symbol.iterator]() {
let current = this.start
const { end, step } = this
return {
next() {
if (current < end) {
const value = current
current += step
return { value, done: false }
}
return { value: undefined, done: true }
},
return(value) {
// Clean up resources if any
console.log(`Range iterator closed at ${current}`)
return { value, done: true }
},
// Make the iterator itself iterable
[Symbol.iterator]() { return this }
}
}
}
const range2 = new Range(1, 10, 2)
// Spread
console.log([...range2]) // [1, 3, 5, 7, 9]
// for...of
for (const n of range2) {
if (n > 5) break // โ triggers return()
}
// Destructuring
const [first, second] = range2
// Array.from
const arr4 = Array.from(range2)
Chapter Summary
-
The iterator protocol consists of two layers: the iterable protocol (
[Symbol.iterator]()) and the iterator protocol (next()/return()/throw()). The former is the entry point for "entering iteration," the latter is the engine for "advancing iteration";return()is not an optional convenience feature โ it's critical for resource management. -
for...of's spec path has three steps: GetIterator (get the iterator) โ loop calling IteratorStep (advance iteration) โ call IteratorClose on early termination (clean up resources). Any operation causing the loop to exit early (break, return, throw) triggers IteratorClose. -
Generator functions are fundamentally execution context factories: each generator function call returns a Generator object that saves a complete execution context; the entire call stack (including local variables) is frozen at
yield, and restored whennext()is called. This makes generators the lowest-cost way to implement coroutines. -
Built-in iterable iteration is live: modifying Arrays, Maps, or Sets during iteration affects iteration results. Array iterators use current array length checks; Map/Set iterators follow insertion order; elements added during iteration will be visited; deleting already-"passed" elements doesn't affect remaining iteration.
-
Async iteration is a minimal extension of sync iteration:
[Symbol.asyncIterator]()andnext()return Promises;for await...ofawaits between each iteration step. If an object lacks[Symbol.asyncIterator], the engine automatically wraps a sync iterator usingCreateAsyncFromSyncIterator.