Iterators and Generators
for...of, spread, destructuring and Promise.all all speak one protocol. Once you know what it is, you can make your own objects work with all of them, and you can produce values lazily instead of building an array you may not need.
The iteration protocol
An object is iterable if it has a method at the key Symbol.iterator that returns an iterator. An iterator is any object with a next() method returning { value, done }.
const range = {
from: 1,
to: 4,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
return current <= last
? { value: current++, done: false }
: { value: undefined, done: true };
},
};
},
};
console.log([...range]); // [1, 2, 3, 4]
for (const n of range) process.stdout.write(n + ' '); // 1 2 3 4
console.log();
const [first, second] = range;
console.log(first, second); // 1 2
Open this example in the editor and change it. Nothing you type is saved.
That is the whole protocol. Thirty lines of ceremony, and now range works with every construct in the language that consumes iterables - for...of, spread, array and parameter destructuring, Array.from, new Set, new Map, Promise.all, yield*.
What you already use it for
Every one of these is the protocol, and knowing that explains why they compose:
console.log([...'hey']); // ['h', 'e', 'y']
console.log([...new Set([1, 1, 2])]); // [1, 2]
console.log([...new Map([['a', 1]])]); // [['a', 1]]
console.log([...[10, 20].entries()]); // [[0, 10], [1, 20]]
console.log([...Object.entries({ a: 1 })]); // [['a', 1]]
// Maps and Sets iterate in insertion order, and give you three views.
const m = new Map([['a', 1], ['b', 2]]);
console.log([...m.keys()], [...m.values()]); // ['a','b'] [1,2]
for (const [key, value] of m) console.log(key, value);
Open this example in the editor and change it. Nothing you type is saved.
A plain object is not iterable - [...{a: 1}] throws. That is deliberate: object iteration is ambiguous (keys? values? inherited?), so the language makes you say which with Object.keys, Object.values or Object.entries.
Generators write the iterator for you
The range above took thirty lines. A generator writes the same thing in four.
function* range(from, to) {
for (let n = from; n <= to; n++) yield n;
}
console.log([...range(1, 4)]); // [1, 2, 3, 4]
Open this example in the editor and change it. Nothing you type is saved.
function* returns a generator object, which is both an iterator and iterable. yield suspends the function, hands a value out, and keeps the entire local state - variables, loop counters, position - until next() is called again.
function* range(from, to) {
for (let n = from; n <= to; n++) yield n;
}
const gen = range(1, 3);
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: undefined, done: true }
console.log(gen.next()); // { value: undefined, done: true }
Open this example in the editor and change it. Nothing you type is saved.
That suspension is the feature. A function that can pause mid-loop and resume later is how you express a sequence without materialising it.
Generators are lazy, which is the point
function* naturals() {
let n = 1;
while (true) yield n++; // infinite, and harmless
}
function* take(iterable, count) {
let taken = 0;
for (const value of iterable) {
if (taken++ >= count) return;
yield value;
}
}
function* map(iterable, fn) {
for (const value of iterable) yield fn(value);
}
function* filter(iterable, predicate) {
for (const value of iterable) if (predicate(value)) yield value;
}
// Nothing has run yet. Each of these returns a generator object.
const pipeline = take(map(filter(naturals(), (n) => n % 3 === 0), (n) => n * n), 5);
console.log([...pipeline]); // [9, 36, 81, 144, 225]
Open this example in the editor and change it. Nothing you type is saved.
naturals() is an infinite sequence and the program terminates, because nothing asks for a value until [...pipeline] does, and it stops asking after five. An array-based version would need a bound guessed in advance and would allocate four intermediate arrays.
yield* delegates to another iterable, which is how you compose generators without a loop:
function* inorder(node) {
if (!node) return;
yield* inorder(node.left);
yield node.value;
yield* inorder(node.right);
}
const tree = {
value: 5,
left: { value: 3, left: { value: 1 }, right: { value: 4 } },
right: { value: 8 },
};
console.log([...inorder(tree)]); // [1, 3, 4, 5, 8]
Open this example in the editor and change it. Nothing you type is saved.
A recursive tree walk that yields rather than pushing into an accumulator, in six lines, and the caller can stop halfway through without the walk completing.
Two-way: next(value), return and throw
yield is an expression. Whatever is passed to the next next() call becomes its value, which makes a generator a coroutine rather than only a producer.
function* conversation() {
const name = yield 'What is your name?';
const year = yield 'Hello ' + name + '. What year were you born?';
return name + ' is about ' + (2026 - Number(year)) + ' years old.';
}
const chat = conversation();
console.log(chat.next().value); // 'What is your name?'
console.log(chat.next('Aisha').value); // 'Hello Aisha. What year...'
console.log(chat.next('1997').value); // 'Aisha is about 29 years old.'
Open this example in the editor and change it. Nothing you type is saved.
The first next() takes no argument - there is no suspended yield for it to answer yet. That off-by-one is the thing to remember.
Two more methods complete the picture:
function* withCleanup() {
try {
yield 1;
yield 2;
} finally {
console.log('cleaned up'); // runs on return(), throw(), or break
}
}
const g = withCleanup();
console.log(g.next().value); // 1
console.log(g.return('stopped')); // 'cleaned up' then { value: 'stopped', done: true }
// And a for...of that breaks early calls return() for you.
for (const n of withCleanup()) {
console.log(n); // 1
break; // 'cleaned up' prints here
}
Open this example in the editor and change it. Nothing you type is saved.
The finally running on early exit is what makes generators safe for resources. A generator that opens a file can close it in finally and trust that break, throw and return all reach it.
Async generators, for streams
async function* pages(total) {
for (let page = 1; page <= total; page++) {
// In real code: await fetch(...). Here, a delay stands in for it.
await new Promise((r) => setTimeout(r, 5));
yield { page, rows: [page * 10, page * 10 + 1] };
}
}
for await (const { page, rows } of pages(3)) {
console.log('page', page, rows);
}
// page 1 [10, 11]
// page 2 [20, 21]
// page 3 [30, 31]
Open this example in the editor and change it. Nothing you type is saved.
for await...of is how you consume anything that produces values over time - a paginated API, a file read in chunks, a WebSocket. Node streams are async iterables, so for await (const chunk of readable) works with no adapter at all.
A worked example
// Paging through a large result set without ever holding it all in
// memory, with a limit, a transformation and early exit - the shape
// this feature actually gets used for.
const DATA = Array.from({ length: 95 }, (_, i) => ({
id: i + 1,
name: 'row ' + (i + 1),
score: (i * 37) % 100,
}));
// Pretend this is a network call returning one page.
async function fetchPage(page, size = 10) {
await new Promise((r) => setTimeout(r, 1));
const start = (page - 1) * size;
return { rows: DATA.slice(start, start + size), hasMore: start + size < DATA.length };
}
// An async generator that hides paging entirely from the caller.
async function* allRows(size = 10) {
let page = 1;
let calls = 0;
try {
while (true) {
calls++;
const { rows, hasMore } = await fetchPage(page, size);
yield* rows;
if (!hasMore) return;
page++;
}
} finally {
console.log('stopped after ' + calls + ' request(s)');
}
}
// The caller writes a loop over rows and never mentions pages.
const highScorers = [];
for await (const row of allRows()) {
if (row.score > 90) highScorers.push(row.id);
if (highScorers.length === 3) break; // early exit, mid-page
}
console.log(highScorers);
// stopped after 3 request(s)
// [ 9, 17, 28 ]
// Three requests, not ten: the break reached the generator's finally,
// which is how you know the remaining pages were never fetched.
// Synchronous composition over the same data, still lazy.
function* take(iterable, n) {
let i = 0;
for (const value of iterable) {
if (i++ >= n) return;
yield value;
}
}
function* map(iterable, fn) {
for (const value of iterable) yield fn(value);
}
const names = [...take(map(DATA, (r) => r.name.toUpperCase()), 3)];
console.log(names); // ['ROW 1', 'ROW 2', 'ROW 3']
Open this example in the editor and change it. Nothing you type is saved.
When not to reach for one
Generators are the wrong tool more often than they are the right one.
- The collection is small and in memory.
array.map(...)is faster, more familiar and reads better. Laziness is overhead when there is nothing to save. - You need the length, random access or to sort. An iterator has none of those. Build the array.
- You need to iterate twice. A generator is consumed once; the second
for...ofsees nothing. An iterable object with aSymbol.iteratorthat returns a fresh generator each time can be re-iterated; the generator object itself cannot.
const gen = (function* () { yield 1; yield 2; })();
console.log([...gen]); // [1, 2]
console.log([...gen]); // [] - already exhausted
Open this example in the editor and change it. Nothing you type is saved.
That silent empty array on the second pass is the commonest generator bug, and it is silent precisely because an exhausted iterator is indistinguishable from an empty one.
When it goes wrong
| Symptom | Cause |
|---|---|
| The second loop over a generator saw nothing | Generators are consumed once |
is not iterable on a plain object |
Use Object.entries or add Symbol.iterator |
.length is undefined |
Iterators have no length; spread into an array |
| An infinite generator hung the program | Nothing bounded it; use take or a break |
| Cleanup never ran | return() was not called - avoid while with a manual next |
The first next(value) argument was ignored |
There is no suspended yield to receive it |
for await on a sync iterable of promises |
It awaits each value; usually what you want, occasionally not |
| A generator function returned a generator, not a value | Calling it only creates the object; next() runs it |
A check you can run
The worked example prints "stopped after 3 request(s)". Delete the break and run it again: it prints "stopped after 10 request(s)" and finds every high scorer.
That difference is the entire value proposition. The consumer decided how much work the producer did, without the producer knowing anything about the consumer - and the finally proves the producer was told to stop rather than left dangling.