← back to the section

Why does for..of work with an array, a string, a Map, and a Set, but not with a plain object? Why does spread split a string into characters? Behind all of this stands one contract — the iteration protocol. It's small, and once you know it, you understand a whole layer of the language at once: from destructuring to generators.

The protocol: next() and {value, done}

An iterator is any object with a next() method returning { value, done }. An iterable is any object that can hand out an iterator via a method keyed by Symbol.iterator. That's all.

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 },
    };
  },
};

for (const n of range) console.log(n); // 1 2 3
[...range];                            // [1, 2, 3]
const [first] = range;                 // 1
Math.max(...range);                    // 3

One Symbol.iterator implementation — and the object automatically plays nicely with the entire family of consumers: for..of, spread, destructuring, Array.from, new Set(...), Promise.all. This is exactly why they work identically with arrays, strings, Map, Set, NodeList — they're all iterable. A plain {} is not: it has no Symbol.iterator; for iterating over objects there are Object.keys/values/entries (which return an array — already iterable).

Generators: an iterator without the bookkeeping

Writing next() by hand with manual state storage is clunky. A generator does the same thing with ordinary function syntax:

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

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

The asterisk declares a generator, yield "emits" a value and freezes the function at that spot. Each next() call unfreezes it up to the next yield. This is the only construct in the language where a function can stop in the middle and resume later — with all its local variables alive.

Hence the superpower — laziness. A generator doesn't build the whole collection up front; it emits one value at a time on demand:

function* ids() {
  let i = 1;
  while (true) yield i++; // infinite — and that's fine!
}
const gen = ids();
gen.next().value; // 1
gen.next().value; // 2 — no infinite loop: we compute on request

Practical uses: generating identifiers, pagination ("give me the next page"), tree traversal (yield inside recursion via yield*), processing pipelines where materializing intermediate arrays isn't worth it.

function* walk(node) {
  yield node;
  for (const child of node.children ?? []) yield* walk(child); // delegation
}

yield* is "hand the floor to another generator": recursive tree traversal without a manual stack reads like ordinary recursion.

for await..of: iterating the asynchronous

Cross generators with promises: an asynchronous iterator emits promises, and for await..of awaits them. The canonical example — download all pages of an API:

async function* fetchPages(url) {
  while (url) {
    const res = await fetch(url);
    const page = await res.json();
    yield page.items;
    url = page.next; // link to the next page or null
  }
}

for await (const items of fetchPages("/api/orders?page=1")) {
  render(items); // pages are processed as they arrive
}

The consumer knows nothing about pagination or the number of pages — it just iterates. The same "next → value/done" contract, only asynchronous: chunked file reading in Node and streaming responses are built the same way.

In short

  • The iteration protocol: iterator = next(){value, done}; iterable = an object with Symbol.iterator. One contract feeds for..of, spread, destructuring, Array.from.
  • A plain object is not iterable — it's traversed via Object.keys/values/entries.
  • A generator (function* + yield) is a function with pauses: written like ordinary code, works like an iterator. yield* delegates to another generator.
  • Laziness: values on demand, infinite sequences, tree traversal without materialization.
  • for await..of + async generators — iterating asynchronous sources: pagination, streams, chunks.
  • Promises from the inside — the asynchronous half of for await..of.
  • Closures — generator state between calls lives by the same laws.
  • JavaScript essentials for TypeScript — the bridge from this series back to the TypeScript phase of the program.