LLevelUp
0
← Back to topic

Iterators & Generators

Iterators and generators are two related features that give JavaScript a protocol for lazy, on-demand sequences. Once you understand them, for...of, spread, destructuring, and async/await all become easier to reason about — they all sit on top of this protocol.

The Iterable Protocol

An object is iterable if it has a [Symbol.iterator]() method that returns an iterator. An iterator is an object with a next() method that returns { value, done }:

// Manually consuming an iterator
const arr = [1, 2, 3];
const iter = arr[Symbol.iterator]();

iter.next(); // { value: 1, done: false }
iter.next(); // { value: 2, done: false }
iter.next(); // { value: 3, done: false }
iter.next(); // { value: undefined, done: true }

Built-in iterables include: Array, String, Map, Set, arguments, NodeList, generator objects, and anything returned by Array.prototype.entries() / .keys() / .values().

for...of simply calls [Symbol.iterator]() and repeatedly calls next() until done: true:

for (const n of [1, 2, 3]) {
  console.log(n); // 1, 2, 3
}

// Also works on strings (iterates over Unicode code points, not UTF-16 units)
for (const ch of "hello") {
  console.log(ch); // h, e, l, l, o
}

Custom Iterables

class Range {
  constructor(
    private readonly start: number,
    private readonly end: number
  ) {}

  [Symbol.iterator](): Iterator<number> {
    let current = this.start;
    const end = this.end;

    return {
      next(): IteratorResult<number> {
        if (current <= end) {
          return { value: current++, done: false };
        }
        return { value: undefined as unknown as number, done: true };
      },
    };
  }
}

const range = new Range(1, 5);

for (const n of range) {
  console.log(n); // 1, 2, 3, 4, 5
}

const arr = [...range]; // [1, 2, 3, 4, 5]
const [first, second] = range; // destructuring works too

TIP

If your class should be iterable, also make the iterator return itself from its own [Symbol.iterator](). This makes both the iterable and the iterator work in for...of interchangeably — it is called making the iterator “iterable-iterator”.

Generator Functions

Writing custom iterators by hand is verbose. Generator functions (function*) automate this — they return a generator object that implements both the iterator and iterable protocols:

function* count(start: number, end: number): Generator<number> {
  for (let i = start; i <= end; i++) {
    yield i; // pause here, hand `i` to the caller, resume next time
  }
}

const gen = count(1, 3);
gen.next(); // { value: 1, done: false }
gen.next(); // { value: 2, done: false }
gen.next(); // { value: 3, done: false }
gen.next(); // { value: undefined, done: true }

for (const n of count(1, 3)) {
  console.log(n); // 1, 2, 3
}

The generator function body is not executed when called — it returns a generator. The body runs only when next() is called, pausing at each yield.

Infinite Lazy Sequences

Because generators produce values one at a time, they can represent infinite sequences without consuming infinite memory:

function* naturals(): Generator<number> {
  let n = 0;
  while (true) {
    yield n++;
  }
}

function* take<T>(n: number, iter: Iterable<T>): Generator<T> {
  let count = 0;
  for (const value of iter) {
    if (count++ >= n) break;
    yield value;
  }
}

const first10 = [...take(10, naturals())]; // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
sequenceDiagram
    participant Caller
    participant Generator

    Caller->>Generator: gen.next()
    Generator-->>Caller: { value: 0, done: false }
    Caller->>Generator: gen.next()
    Generator-->>Caller: { value: 1, done: false }
    Caller->>Generator: gen.next()
    Generator-->>Caller: { value: 2, done: false }
    Note over Caller: break — generator paused, not finished

Passing Values Back In

yield is an expression — the value passed to next(value) becomes the result of the yield expression inside the generator. This enables two-way communication:

function* dialog(): Generator<string, void, string> {
  const name = yield "What is your name?";
  const colour = yield `Hello ${name}! What is your favourite colour?`;
  yield `${name} likes ${colour}.`;
}

const d = dialog();
console.log(d.next().value);        // "What is your name?"
console.log(d.next("Alice").value); // "Hello Alice! What is your favourite colour?"
console.log(d.next("green").value); // "Alice likes green."

NOTE

The first next() call cannot pass a value to the generator — it starts execution from the top of the function, where there is no yield expression yet to receive a value.

Practical Use Cases

  • Paginated API fetching — yield one page at a time, fetch the next only when needed
  • Stream processing — process large files line by line without loading everything into memory
  • State machines — each yield represents a state; the driver passes events back in
  • Redux-Saga — uses generators to express complex async workflows as a sequence of declarative effects

CAUTION

Generators were (and still are) used to implement async/await in transpilers. While understanding this is valuable, prefer async/await for async logic in production code — it is clearer and better supported by debuggers.

Further Learning

Search these terms to go deeper:

  • “MDN: Iteration protocols” — the formal spec for the iterable and iterator protocols
  • “MDN: function* — generator function reference including return values and throw()
  • “JavaScript generators explained” — Axel Rauschmayer’s exploringjs.com has an exhaustive chapter on generators
  • “Lazy evaluation JavaScript generators” — articles on using generators for memory-efficient data pipelines
  • “Redux-Saga” — real-world library that builds an entire async middleware system on top of generators