codejump
Academy

Iterators and generators

The protocol behind for...of and spread, how a generator writes one for you, and why a paused function is the point.

Updated

The protocol first

for...of, spread, destructuring and Array.from all speak one protocol. An object is iterable if it has a [Symbol.iterator] method returning an iterator — an object with a next() that returns { value, done }.

That is the entire contract. Implement it and every syntax above starts working on your type:

const range = {
  from: 1,
  to: 3,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next: () => current <= last
        ? { value: current++, done: false }
        : { value: undefined, done: true },
    };
  },
};

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

Arrays, strings, Map, Set, arguments and NodeLists are iterable. Plain objects are not — which is why for...of on an object throws, and why Object.entries exists.

A generator writes that for you

The same range, as a generator:

function* range(from, to) {
  for (let i = from; i <= to; i++) yield i;
}

[...range(1, 3)];        // [1, 2, 3]

Calling a generator function runs no code. It returns a generator object, which is both an iterator and iterable. Each next() runs until the next yield and then pauses, keeping the whole local state — variables, loop position, the call stack of that function — until asked again.

A function that can pause is the actual feature. Everything below follows from it.

Laziness, and sequences that do not fit in memory

function* naturals() {
  let n = 0;
  while (true) yield n++;    // no, this does not hang
}

function* take(it, count) {
  for (const value of it) {
    if (count-- <= 0) return;
    yield value;
  }
}

[...take(naturals(), 5)];    // [0, 1, 2, 3, 4]

The infinite loop is safe because nothing runs until something pulls. This is the difference between generators and array methods: .map().filter() builds a full intermediate array at every step; a generator pipeline produces one value at a time and never holds the whole sequence.

The same shape reads a large file, or pages an API, without loading it all:

async function* pages(url) {
  let next = url;
  while (next) {
    const res = await fetch(next);
    const data = await res.json();
    yield* data.items;        // yield* delegates to another iterable
    next = data.nextUrl;
  }
}

for await (const item of pages('/api/items')) { /* ... */ }

for await...of and async function* are the async half of the same protocol, built on Symbol.asyncIterator.

yield is two-way

yield also *receives*. Whatever you pass to next(value) becomes the result of the paused yield expression:

function* conversation() {
  const name = yield 'What is your name?';
  return `Hello ${name}`;
}

const it = conversation();
it.next();          // { value: 'What is your name?', done: false }
it.next('Ada');     // { value: 'Hello Ada', done: true }

The first next() has nowhere to deliver a value, so its argument is always discarded — a detail that catches people writing their first coroutine.

This two-way channel is what redux-saga is built on: the generator yields a description of an effect, the library performs it, and sends the result back in. The business logic stays synchronous-looking and, because it only yields plain objects, trivially testable.

What to reach for

You wantUse
your type to work with for...of[Symbol.iterator]
a finite sequence you already havean array
a sequence too large or infinitea generator
a stream of awaited valuesasync function* + for await
to pause and resume logica generator, and only a generator

Generators are stateful and single-use: once exhausted, next() keeps returning { done: true }. Iterate one twice and the second pass is empty — a bug that looks like missing data.

Now practice it

Reading this page is the cheap half. These are the exercises that make you use it.