for...of works on arrays, strings, Maps, Sets and NodeLists, but not on plain objects. The reason is a small agreement called the iteration protocol. Learn it and you can make any object work with for...of, spread and destructuring, and you can write generators that produce values one at a time instead of building huge arrays.
The iteration protocol
The protocol has two parts:
- An object is iterable if it has a method under the key
Symbol.iteratorthat returns an iterator. - An iterator is an object with a
next()method. Each call returns{ value, done }. Whendoneistrue, the sequence is finished.
You can drive an array's iterator by hand:
const colours = ['red', 'green'];
const iterator = colours[Symbol.iterator]();
console.log(iterator.next()); // → { value: 'red', done: false }
console.log(iterator.next()); // → { value: 'green', done: false }
console.log(iterator.next()); // → { value: undefined, done: true }
for...of does exactly this: it asks for the iterator, calls next() until done is true, and gives you each value. Spread ([...x]), array destructuring, Array.from, new Map(x), new Set(x) and Promise.all(x) all use the same protocol.
Making an object iterable
Here is a range object that works with for...of:
const range = {
from: 1,
to: 4,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
},
};
},
};
for (const n of range) {
console.log(n);
}
// → 1
// → 2
// → 3
// → 4
console.log([...range]); // → [ 1, 2, 3, 4 ]
const [first, second] = range;
console.log(first, second); // → 1 2
Each call to range[Symbol.iterator]() creates a fresh iterator with its own current, so you can loop over range as many times as you like. Symbol.iterator is a well-known symbol; the symbols lesson later in the course explains them fully.
Writing next() by hand works, but tracking state between calls is fiddly. Generators do that bookkeeping for you.
Generator functions
A generator function is declared with function*. Calling it does not run the body. It returns a generator object, which is both an iterator and iterable. Each next() runs the body until the next yield, pauses there, and hands out the yielded value.
function* countdown() {
console.log('starting');
yield 3;
yield 2;
yield 1;
console.log('finished');
}
const gen = countdown(); // Nothing logged yet.
console.log(gen.next()); // → starting
// → { value: 3, done: false }
console.log(gen.next()); // → { value: 2, done: false }
console.log(gen.next()); // → { value: 1, done: false }
console.log(gen.next()); // → finished
// → { value: undefined, done: true }
The function's local variables survive between calls because the function is paused, not finished. The same range becomes much shorter:
const range = {
from: 1,
to: 4,
*[Symbol.iterator]() {
for (let n = this.from; n <= this.to; n++) {
yield n;
}
},
};
console.log([...range]); // → [ 1, 2, 3, 4 ]
*[Symbol.iterator]() is generator method syntax. It also works in classes:
class Playlist {
#tracks = [];
add(title) {
this.#tracks.push(title);
return this;
}
*[Symbol.iterator]() {
yield* this.#tracks;
}
}
const playlist = new Playlist().add('Intro').add('Theme');
console.log([...playlist]); // → [ 'Intro', 'Theme' ]
yield* delegates to another iterable, yielding each of its values in turn. Here it exposes the tracks without exposing the private array itself.
Lazy and infinite sequences
A generator only does work when asked for the next value. That makes infinite sequences safe, as long as you stop asking:
function* naturals() {
let n = 1;
while (true) {
yield n++;
}
}
function* take(iterable, count) {
if (count <= 0) return;
let taken = 0;
for (const value of iterable) {
yield value;
taken++;
if (taken >= count) return;
}
}
function* filter(iterable, test) {
for (const value of iterable) {
if (test(value)) yield value;
}
}
const firstEvenSquares = take(
filter(naturals(), (n) => n % 2 === 0),
3
);
for (const n of firstEvenSquares) {
console.log(n * n);
}
// → 4
// → 16
// → 36
naturals() never builds an array. Each value flows through filter and take one at a time, and when take returns, the loop ends. Never spread an infinite generator ([...naturals()]): it would try to build an infinite array and hang.
This style is useful for large or expensive data: reading records one at a time, generating IDs, or walking a tree.
function* walk(node) {
yield node.name;
for (const child of node.children ?? []) {
yield* walk(child);
}
}
const menu = {
name: 'Home',
children: [
{ name: 'Courses', children: [{ name: 'JavaScript' }, { name: 'CSS' }] },
{ name: 'About' },
],
};
console.log([...walk(menu)]);
// → [ 'Home', 'Courses', 'JavaScript', 'CSS', 'About' ]
Recursion with yield* turns a tree walk into a flat sequence that a caller can stop early.
Stopping early and cleaning up
When a for...of loop exits early with break, return or an exception, it calls the iterator's return() method. For a generator, that resumes the paused function as if a return statement were at the yield, so any finally block runs:
function* readLines() {
console.log('open file');
try {
yield 'line 1';
yield 'line 2';
yield 'line 3';
} finally {
console.log('close file');
}
}
for (const line of readLines()) {
console.log(line);
if (line === 'line 2') break;
}
// → open file
// → line 1
// → line 2
// → close file
This is how generators release resources reliably. A generator object can only be iterated once: after it finishes or is closed, further next() calls return { value: undefined, done: true }.
Sending values in
next(value) can also pass a value back into the generator; it becomes the result of the paused yield expression. The first next() call only starts the generator, so a value passed to it is ignored.
function* runningTotal() {
let total = 0;
while (true) {
const amount = yield total;
total += amount;
}
}
const tally = runningTotal();
tally.next(); // Start: runs to the first yield.
console.log(tally.next(5).value); // → 5
console.log(tally.next(10).value); // → 15
Two-way generators are powerful but harder to read. In everyday code, use generators to produce sequences and reach for this only when it clearly simplifies something.
Iterator helpers
ES2025 added iterator helpers: methods such as map, filter, take, drop, flatMap, reduce, toArray, some, every and find directly on iterators, so the take and filter functions above have built-in equivalents:
const result = naturals()
.filter((n) => n % 2 === 0)
.map((n) => n * n)
.take(3)
.toArray();
console.log(result); // → [ 4, 16, 36 ]
They are lazy, like the hand-written versions. They are newer than the rest of this lesson: they are not in Node.js 20 and some browsers added them only recently, so check MDN's compatibility table and keep your own small helpers as a fallback.
Summary
- An iterable has a
Symbol.iteratormethod that returns an iterator; an iterator hasnext()returning{ value, done }. for...of, spread, destructuring,Array.from,Map,SetandPromise.allall use this protocol.function*creates generators that pause at eachyieldand keep their local state.yield*delegates to another iterable, which makes recursive walks simple.- Generators are lazy, so infinite sequences are fine if you stop asking for values.
- Exiting a loop early calls
return(), which runs the generator'sfinallyblocks.