Chapter 33

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...of can 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:


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:

// 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 ] )

  1. If method is not present, then a. If kind is async, then i. Set method to ? GetMethod(obj, @@asyncIterator). ii. If method is undefined, then 1. Let syncMethod be ? GetMethod(obj, @@iterator). 2. Let syncIteratorRecord be ? GetIterator(obj, sync, syncMethod). 3. Return CreateAsyncFromSyncIterator(syncIteratorRecord). b. Otherwise, set method to ? GetMethod(obj, @@iterator).
  2. Let iterator be ? Call(method, obj).
  3. If iterator is not an Object, throw a TypeError exception.
  4. Let nextMethod be ? GetMethod(iterator, "next").
  5. Let iteratorRecord be the Iterator Record { [[Iterator]]: iterator, [[NextMethod]]: nextMethod, [[Done]]: false }.
  6. 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 )

  1. Let result be ? IteratorNext(iteratorRecord).
  2. Let done be ? IteratorComplete(result).
  3. If done is true, return false.
  4. Return result.

IteratorNext (ยง7.4.4):

  1. Let result be ? Call(iteratorRecord.[[NextMethod]], iteratorRecord.[[Iterator]]).
  2. If result is not an Object, throw a TypeError exception.
  3. 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):

  1. Let exprRef be the result of evaluating AssignmentExpression.
  2. Let exprValue be ? GetValue(exprRef).
  3. If iterationKind is enumerate, ... (for-in path)
  4. Else, let iteratorRecord be ? GetIterator(exprValue, iterationKind).
  5. Return iteratorRecord.

ForIn/OfBodyEvaluation (simplified):

  1. Repeat, a. Let nextResult be ? IteratorStep(iteratorRecord). b. If nextResult is false, return NormalCompletion(V). c. Let nextValue be ? IteratorValue(nextResult). d. Perform IteratorBindingInitialization of lhs with nextValue. e. Let result be the result of evaluating stmt. f. If LoopContinues(result, labelSet) is false, return ? IteratorClose(iteratorRecord, UpdateEmpty(result, V)). g. If result.[[Value]] is not empty, set V to result.[[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 )

  1. Let iterator be iteratorRecord.[[Iterator]].
  2. Let innerResult be GetMethod(iterator, "return").
  3. If innerResult.[[Type]] is normal, then a. Let return be innerResult.[[Value]]. b. If return is undefined, return ? completionRecord. c. Set innerResult to Call(return, iterator).
  4. If completionRecord.[[Type]] is throw, return ? completionRecord.
  5. If innerResult.[[Type]] is throw, return ? innerResult.
  6. If innerResult.[[Value]] is not an Object, throw a TypeError exception.
  7. 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):

  1. Return ? GeneratorResume(generator, value, empty).

GeneratorResume:

  1. Let state be generator.[[GeneratorState]].
  2. If state is completed, return CreateIterResultObject(undefined, true).
  3. Assert: state is suspended-start or suspended-yield.
  4. Let genContext be generator.[[GeneratorContext]].
  5. Let methodContext be the running execution context.
  6. Suspend methodContext.
  7. Set generator.[[GeneratorState]] to executing.
  8. Push genContext onto the execution context stack; genContext is now the running execution context.
  9. Resume the suspended evaluation of genContext using NormalCompletion(value) as the result of the operation that suspended it.
  10. Assert: When we reach this step, genContext has already been removed from the execution context stack and methodContext is the currently running execution context.
  11. 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]] is value.

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

  1. 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.

  2. 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.

  3. 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 when next() is called. This makes generators the lowest-cost way to implement coroutines.

  4. 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.

  5. Async iteration is a minimal extension of sync iteration: [Symbol.asyncIterator]() and next() return Promises; for await...of awaits between each iteration step. If an object lacks [Symbol.asyncIterator], the engine automatically wraps a sync iterator using CreateAsyncFromSyncIterator.

Rate this chapter
4.7  / 5  (3 ratings)

๐Ÿ’ฌ Comments