Lesson 24 of 28
Iterators and Generators
for...of, spread and destructuring are not array features — they are one protocol you can implement yourself. Generators, laziness, and for await...of.
You spread a NodeList into an array and it works. You spread a plain object
into an array and it throws is not iterable. Neither is an array, so
something other than being an array is being checked. That something is a
protocol, it is public, and you can implement it on anything you own.
The protocol
An object is iterable if it has a method under the key Symbol.iterator that
returns an iterator. An iterator is any object with a next() method returning
{ value, done }. That is the entire contract.
Everything downstream of it is syntax over that contract:
for (const x of thing) {} // calls thing[Symbol.iterator]()
const copy = [...thing]; // same
const [a, b] = thing; // same
Array.from(thing); // same, or falls back to length
Promise.all(thing); // same
new Map(thing); // sameMap, Set, NodeList, FormData, URLSearchParams, arguments objects and
strings all implement it. Plain objects deliberately do not, which is why
{...obj} in object literal position is a separate feature with different
rules.
Strings iterate by code point rather than UTF-16 code unit, which is the one place this protocol quietly fixes a bug for you:
const thumb = "👍";
thumb.length; // 2 — UTF-16 code units
[...thumb].length; // 1 — code pointsWriting one by hand
Nothing magic is involved. Here is a cursor over a fixed-size window of rows, the shape you write when wrapping a paged result set:
function range(start, end, step = 1) {
let current = start;
return {
[Symbol.iterator]() {
return this;
},
next() {
if (current >= end) return { value: undefined, done: true };
const value = current;
current += step;
return { value, done: false };
},
};
}
console.log([...range(0, 5)]); // [0, 1, 2, 3, 4]Returning this from Symbol.iterator makes the iterator itself iterable,
which is the convention every built-in follows. It also makes the object
single-use: iterate it twice and the second pass is empty. Built-in collections
avoid that by returning a fresh iterator each time, and you should too unless
one-shot consumption is the point.
Making a class iterable
The usual reason is to stop leaking your internal storage:
class LessonSet {
#byId = new Map();
add(lesson) {
this.#byId.set(lesson.id, lesson);
return this;
}
*[Symbol.iterator]() {
yield* this.#byId.values();
}
}
const set = new LessonSet().add({ id: 1 }).add({ id: 2 });
for (const lesson of set) {
console.log(lesson.id); // 1 then 2
}Callers get iteration without a reference to the Map, so you can swap the
storage later without breaking them.
Generators
That *[Symbol.iterator]() was a generator, and generators exist because
hand-written iterators are tedious to get right. function* gives you a
function that builds an iterator, and yield produces the values.
Two things about them are load-bearing.
Calling a generator runs nothing. You get an iterator back and the body
stays parked at the top until someone calls next(). And each yield suspends
the function with its local variables, its position in a loop, and its try
blocks all intact.
function* counter() {
console.log("starting");
yield 1;
console.log("resumed");
yield 2;
}
const it = counter();
// nothing logged yet
it.next(); // "starting" -> { value: 1, done: false }
it.next(); // "resumed" -> { value: 2, done: false }
it.next(); // -> { value: undefined, done: true }Talking back
yield is bidirectional. The value you pass to next(value) becomes the
result of the yield expression that was suspended.
function* negotiate() {
const name = yield "name?";
const age = yield `hello ${name}, age?`;
return { name, age };
}
const it = negotiate();
it.next(); // { value: "name?", done: false }
it.next("Anish"); // { value: "hello Anish, age?", done: false }
it.next(41); // { value: { name: "Anish", age: 41 }, done: true }The first next() argument is thrown away — there is no suspended yield
waiting for it. This is exactly the mechanism async/await is built on: a
generator that yields promises, driven by a runner that feeds resolved values
back in. Every transpiled async function you have ever shipped was this.
One trap worth knowing before you rely on it: the value a generator returns
arrives with done: true, and every consumer built on the protocol throws it
away. for...of ignores it, and so does spread.
function* withSummary() {
yield "a";
yield "b";
return { count: 2 };
}
[...withSummary()]; // ["a", "b"] — the summary is lost
for (const v of withSummary()) {} // never sees it eitherOnly yield* and a manual next() loop can read it. If callers need a total,
yield it as the last value or expose it another way.
it.return(v) finishes the generator early and it.throw(err) raises an error
at the suspended yield. Both run your finally blocks, and for...of calls
return() for you when you break. That makes cleanup reliable:
function* readRows(handle) {
try {
while (handle.hasNext()) yield handle.read();
} finally {
handle.close(); // runs on break, on throw, on completion
}
}Laziness that eager code cannot express
Because nothing runs until asked, a generator can describe a sequence with no end.
function* naturals() {
let n = 1;
while (true) yield n++;
}
function* map(source, fn) {
for (const item of source) yield fn(item);
}
function* filter(source, predicate) {
for (const item of source) if (predicate(item)) yield item;
}
function* take(source, count) {
for (const item of source) {
if (count-- <= 0) return;
yield item;
}
}
const firstFive = [...take(filter(map(naturals(), (n) => n * n), (n) => n % 2 === 1), 5)];
// [1, 9, 25, 49, 81]Write that with .map().filter().slice() and the first call never terminates.
Here, exactly nine numbers are ever produced, because take stops pulling.
That is the difference between a pipeline that pushes and one that pulls.
yield* delegates to another iterable, forwarding its values and its return
value. It is how you compose generators without flattening by hand, and it is
what made the LessonSet above one line.
Async iterators
The synchronous protocol assumes the next value already exists. When it does
not, use Symbol.asyncIterator, whose next() returns a promise, and consume
it with for await...of.
Paging an API is the case where this earns its keep:
async function* fetchAllIssues(repo) {
let url = `https://api.github.com/repos/${repo}/issues?per_page=100`;
while (url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`GitHub said ${response.status}`);
yield* await response.json();
const link = response.headers.get("link") ?? "";
const next = link.match(/<([^>]+)>;\s*rel="next"/);
url = next?.[1] ?? null;
}
}
const matches = [];
for await (const issue of fetchAllIssues("vercel/next.js")) {
if (issue.title.includes("hydration")) matches.push(issue.number);
if (matches.length === 20) break; // no further pages are requested
}The caller writes a flat loop over issues and never sees a page, a cursor or a
Link header. Breaking out stops the requests, because break closes the
generator. Node streams have been async iterable since Node 10; in the browser,
async iteration over ReadableStream is still uneven across engines, so a
manual reader.read() loop remains the portable form.
Where they actually belong
Be honest about the frequency. In a typical application codebase you will write
maybe two generators a year, and a plain array with map will be clearer every
other time. Reach for them when one of these is true:
- The sequence is unbounded, or expensive per item and usually abandoned early.
- The data arrives in pages or chunks and you want callers to ignore that.
- You need cleanup guaranteed when a consumer stops early.
- You are building the library layer — a parser, a scheduler, a state machine.
There is a cost, too. Each step allocates a { value, done } object and
resumes a suspended frame, so a hand-written generator in a tight numeric loop
is measurably slower than an index loop over an array — enough to matter in a
per-frame hot path, irrelevant anywhere else. Engines optimise the built-in
iterators of Array and Map heavily; they do not give yours the same
treatment.
If you find yourself explaining a generator in a code review, the reviewer is usually right. Depth is knowing the mechanism; judgement is knowing that most loops do not need it.
What to remember
- One protocol powers
for...of, spread, destructuring andArray.from. - Calling a generator runs nothing;
next()drives it, oneyieldat a time. next(value)sends data in,return/throwfinish it, andfinallystill runs.- Laziness makes infinite and early-exit pipelines expressible, and cheap.
for await...ofhides paging and chunking from the caller. That is its best use.
Check yourself
4 questions · pass 3/4 to unlock Functional Patterns
1.A generator function logs "start" as its very first statement. You call it and assign the result. What has happened at that point?
2.What must an object provide before
for...ofand spread will work on it?3.Inside a generator you write
const reply = yield 1;. Where doesreplycome from?4.What does
for await...ofgive you thatfor...ofover an array of promises does not?
4 left to answer