Iterators and Generators
Mental model: An iterator is a value that has not finished arriving yet, and a generator is a function you can pause in the middle and resume later with new information.
Level: advanced · about 20 minutes
An array hands you everything at once. An iterator hands you one value and a promise of nothing in particular: it might be finite, infinite, expensive, or arriving from a network. for...of, spread, destructuring and Array.from do not know or care which, because they all speak one tiny protocol.
iterable iterator result
{ { {
[Symbol.iterator]() -> next() -------------> value: 3,
} } <- called again -> done: false
}
iterable- has a
[Symbol.iterator]()method that returns an iterator iterator- has a
next()method returning{ value, done } iterable iterator- both: its
[Symbol.iterator]()returnsthis. Generators are these
function makeCountdown(from) {
let n = from;
return {
[Symbol.iterator]() {
return this; // makes it usable in for...of directly
},
next() {
return n > 0 ? { value: n--, done: false } : { value: undefined, done: true };
},
};
}
const it = makeCountdown(3);
console.log(it.next()); // -> { value: 3, done: false }
console.log(it.next()); // -> { value: 2, done: false }
console.log([...it]); // -> [ 1 ] the first two were already consumed
console.log([...it]); // -> [] an exhausted iterator stays exhaustedThe protocol by hand. No syntax sugar, so you can see every moving part.
What for...of actually does
function forOf(iterable, body) {
const iterator = iterable[Symbol.iterator]();
try {
while (true) {
const step = iterator.next();
if (step.done) break;
body(step.value);
}
} finally {
// break, return or throw inside the loop lands here
if (typeof iterator.return === 'function') iterator.return();
}
}
forOf(['a', 'b'], (v) => console.log(v)); // -> a then bfor...of desugared. Note the return() call on early exit.
Generators: the same thing, ten times shorter
Hand-written iterator
function range(a, b) {
let n = a;
return {
[Symbol.iterator]() { return this; },
next() {
return n < b
? { value: n++, done: false }
: { value: undefined, done: true };
},
};
}
Generator
function* range(a, b) {
for (let n = a; n < b; n++) {
yield n;
}
}Identical behaviour. The generator version also gets return() and throw() for free, and it cannot get the done bookkeeping wrong. Write generators unless you have a specific reason not to.
function* trace() {
console.log('start');
yield 1;
console.log('resumed once');
yield 2;
console.log('finishing');
return 'done';
}
const g = trace();
console.log('created, nothing has run yet');
console.log(g.next()); // -> start then { value: 1, done: false }
console.log(g.next()); // -> resumed once then { value: 2, done: false }
console.log(g.next()); // -> finishing then { value: 'done', done: true }
console.log(g.next()); // -> { value: undefined, done: true }function* and yield. The function body runs only when you pull.
function* gen() {
const a = yield 'first';
console.log('got', a);
const b = yield 'second';
console.log('got', b);
}
const g = gen();
console.log(g.next('ignored').value);
console.log(g.next(10).value);
g.next(20);The argument to the first next() has nowhere to go, because the generator has not reached a yield yet, so 'ignored' is discarded. Each later next(v) makes the paused yield expression evaluate to v. So the order is: first logged by the caller, then got 10 when the body resumes, then second, then got 20.
Two-way communication
yield is an expression, not a statement. It sends a value out, and when the caller resumes you with next(x), it evaluates to x. That turns a generator into a coroutine: the caller and the generator take turns, each one deciding what happens next.
function* interview() {
const name = yield 'What is your name?';
const years = yield `Hello ${name}. How many years of JS?`;
return years > 2 ? `${name} is experienced` : `${name} is learning`;
}
const g = interview();
console.log(g.next().value); // -> What is your name?
console.log(g.next('Ada').value); // -> Hello Ada. How many years of JS?
console.log(g.next(5).value); // -> Ada is experiencedA generator that asks questions and reacts to the answers.
function* withCleanup() {
try {
yield 'open';
yield 'work';
} catch (err) {
console.log('caught inside:', err.message);
yield 'recovered';
} finally {
console.log('cleanup ran');
}
}
const a = withCleanup();
a.next();
console.log(a.return('stopped')); // -> cleanup ran, then { value: 'stopped', done: true }
const b = withCleanup();
b.next();
console.log(b.throw(new Error('boom'))); // -> caught inside: boom, then { value: 'recovered', done: false }return() and throw() inject control flow from outside. finally still runs.
Delegation with yield*
function* inner() {
yield 'b';
yield 'c';
return 'inner done';
}
function* outer() {
yield 'a';
const result = yield* inner(); // the value of yield* is inner's return value
console.log('inner returned:', result);
yield 'd';
}
console.log([...outer()]); // -> inner returned: inner done, then [ 'a', 'b', 'c', 'd' ]
// It works on any iterable, not just generators.
function* letters() {
yield* 'xy';
yield* [1, 2];
}
console.log([...letters()]); // -> [ 'x', 'y', 1, 2 ]yield* hands control to another iterable and forwards everything, including the return value.
Infinite and lazy
function* naturals() {
let n = 1;
while (true) yield n++;
}
function* map(iterable, fn) {
for (const v of iterable) yield fn(v);
}
function* filter(iterable, keep) {
for (const v of iterable) if (keep(v)) yield v;
}
function* take(iterable, n) {
if (n <= 0) return;
let i = 0;
for (const v of iterable) {
yield v;
if (++i >= n) return;
}
}
console.log([...take(filter(map(naturals(), (n) => n * n), (n) => n % 2 === 1), 5)]);
// -> [ 1, 9, 25, 49, 81 ] only nine numbers were ever generatedAn infinite sequence is fine as long as nobody asks for all of it.
Since ES2025 you do not have to write those helpers. Any iterator gets map, filter, take, drop, flatMap, reduce, toArray, some, every and find from Iterator.prototype, and they stay lazy.
function* naturals() {
let n = 1;
while (true) yield n++;
}
if (typeof naturals()[Symbol.iterator]().map === 'function') {
const result = naturals()
.map((n) => n * n)
.filter((n) => n % 2 === 1)
.take(5)
.toArray();
console.log(result); // -> [ 1, 9, 25, 49, 81 ]
} else {
console.log('This engine has no iterator helpers yet.');
}Iterator helpers. Baseline in current browsers and Node 22 or newer.
| Want | Array method | Iterator helper | Difference |
|---|---|---|---|
| transform | arr.map(fn) | it.map(fn) | iterator version is lazy, one value at a time |
| first n | arr.slice(0, n) | it.take(n) | iterator version never builds the rest |
| skip n | arr.slice(n) | it.drop(n) | same laziness |
| collect | already an array | it.toArray() | this is where the work actually happens |
| chain infinite | impossible | fine | arrays must be finite, iterators need not be |
Generators as state machines
function* trafficLight() {
while (true) {
yield 'green';
yield 'amber';
yield 'red';
}
}
const light = trafficLight();
console.log([light.next().value, light.next().value, light.next().value, light.next().value]);
// -> [ 'green', 'amber', 'red', 'green' ]The state is the position in the code, so there is no state variable to get wrong.
- Reach for a generator when the sequence is infinite, expensive, or you want to stop early without computing the rest.
- Reach for an array when the data is small, already in memory, and you want to iterate it more than once.
- Reach for an async generator when each value needs awaiting, for example a paginated API. Covered in module 12.
Interactive visualiser: iterator. Enable JavaScript to use it.
Try it yourself
Zip and unzip lazily
function* naturals() {
let n = 1;
while (true) yield n++;
}
function* zip(...iterables) {
const iterators = iterables.map((it) => it[Symbol.iterator]());
while (true) {
const steps = iterators.map((it) => it.next());
if (steps.some((s) => s.done)) return;
yield steps.map((s) => s.value);
}
}
console.log([...zip(['a', 'b', 'c'], [1, 2])]);
// -> [ [ 'a', 1 ], [ 'b', 2 ] ]
const labelled = zip('xyz', naturals());
console.log([...labelled]);
// -> [ [ 'x', 1 ], [ 'y', 2 ], [ 'z', 3 ] ]
Change zip to stop at the longest input instead of the shortest, filling the gaps with undefined. Then prove your version still works with an infinite generator on one side.
Lazy tree traversal
const tree = {
value: 'root',
children: [
{ value: 'a', children: [{ value: 'a1', children: [] }, { value: 'a2', children: [] }] },
{ value: 'b', children: [{ value: 'b1', children: [] }] },
],
};
function* walk(node) {
yield node.value;
for (const child of node.children) yield* walk(child);
}
console.log([...walk(tree)]);
// -> [ 'root', 'a', 'a1', 'a2', 'b', 'b1' ]
// Laziness: stop as soon as you find what you came for.
function findFirst(node, predicate) {
for (const value of walk(node)) {
if (predicate(value)) return value;
}
return null;
}
console.log(findFirst(tree, (v) => v.startsWith('a'))); // -> a
Add a depth to each yielded value. Then write a breadth-first version using a queue and compare the output order.
Exercises
Lazy take
Write take(iterable, n) returning an iterable of at most the first n values. It must be lazy: pulling three values from take(source, 3) must pull exactly three values from source, no more. n of zero or less yields nothing, and it must close the source when it stops early.
A two-way accumulator
Write a generator accumulate(start = 0) that yields a running total. The first next() yields the starting total. Each later next(n) adds the number n and yields the new total. next() with no argument (or a non-number) leaves the total unchanged and yields it again. It never finishes on its own.
Check yourself
- What does this log?
2 0— A generator object is its own iterator, so the first spread drains it and the second findsdone: trueimmediately. Spreadingg()twice (calling the function again) would give2 2. This is the single most common iterator bug in real code.- What is the value of
yield*inconst x = yield* inner();? - the value
innerreturned when it finished —yield*forwards every yielded value to the outer consumer, and then evaluates to the delegate'sreturnvalue. That is the mechanism that made generator-based coroutine libraries (and early async/await transpilation) possible: the yielded values go outward, the return value comes back inward. - Which of these is guaranteed by the iterator protocol?
- An iterator that has returned
done: truekeeps returningdone: true— Once an iterator says it is done, it must stay done. There is nolength(that is exactly why iterators can be infinite), re-iterability is a property of the iterable and not the protocol, and async iterators return promises fromnext(), so synchronicity is not required either. - You
breakout of afor...ofloop over a generator that has afinallyblock. What happens? for...ofcallsiterator.return(), which resumes the generator at theyieldand runsfinally— Leaving afor...ofearly triggers the iterator close protocol: the language callsreturn()if the iterator has one, and a generator implementsreturn()by resuming as if areturnstatement appeared at the pausedyield, sofinallyruns. That is what makes generators safe for holding file handles and subscriptions.
Common mistakes
- Iterating the same generator object twice and getting nothing the second time.
- Passing a value to the first
next()and expecting it to arrive somewhere. - Forgetting that
[...infiniteGenerator()]hangs the tab. Always bound it withtake. - Calling a generator function and expecting the body to run. Nothing runs until the first
next(). - Writing
yieldinside a nested arrow or aforEachcallback.yieldonly works in the generator body itself. - Assuming
Symbol.iteratormust return a generator. Any object withnext()qualifies. - Using an iterator where you need
lengthor random access. Materialise it with[...it]first.
Takeaways
- Iterable means "has
[Symbol.iterator]()". Iterator means "hasnext()". Generators are both. - Nothing in a generator body runs until the first
next(), and it pauses again at eachyield. yieldis an expression:next(v)decides what it evaluates to, which makes generators two-way.yield*forwards a whole iterable and evaluates to its return value.- Leaving a
for...ofearly callsreturn()on the iterator, which is howfinallyand cleanup get to run. - Laziness is the point: infinite sequences and early exit cost nothing you did not consume.
- Since ES2025 iterator helpers give you lazy
map,filterandtakewithout hand-written generators.