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 withSymbol.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.
What to read next
- 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.