Iterators and generators: values made only when pulled
Nothing is made until something pulls; whoever stops pulling must say so, and a generator cleans up in finally.
Predict first
Before you read: predict
ExamHall grades a candidate's answers as they are read. The team wants only the first two graded:
In Node 24, what does main.ts print?
// answers.tsexport function* answers() { try { console.log('start'); yield 'q1'; yield 'q2'; yield 'q3'; } finally { console.log('finally'); }} // main.tsimport { answers } from './answers';const graded = answers() .map((a) => { console.log(`grade ${a}`); return a.toUpperCase(); }) .take(2);console.log('pipeline built');for (const a of graded) console.log(a);The 30-second version
An iterator hands out values one at a time through next(). A generator is a function that makes them on demand: nothing runs until something asks. Iterator helpers like map and take stay lazy too. Leaving a loop early tells the iterator to stop, and a generator then runs its finally.
Nothing is made until something pulls; whoever stops pulling must say so, and a generator cleans up in finally.
How it works
Step 1 of 11
next(), which returns { value, done }. An iterable has [Symbol.iterator](), which returns an iterator.In the kitchen
- he shapes one shoe each time he is asked, then waits → each
next()runs to the nextyield(steps 1, 3)
Analogy: A travelling farrier, one hoof at a time (equine care)
| With the farrier | In code |
|---|---|
| booking the farrier lights no forge; he lights it when the owner asks for the first shoe | calling a generator runs nothing (step 3) |
| he shapes one shoe each time he is asked, then waits | each next() runs to the next yield (steps 1, 3) |
| "just the front two today": after the second shoe he packs up | take(2) closes its source (step 8) |
| an owner who says "that will do" still sees him put out the forge before he drives off | leaving early calls return(), and finally runs (steps 7, 9) |
| a finished visit cannot be asked for one more shoe; that needs a new booking | one pass (step 4) |
In real terms: a generator does work only for the values a consumer takes, and its finally is the one place that runs however the consumer stops.
Breaks down when: a farrier who drove off can come back and pick up where he left off. A closed generator never resumes.
Contrasting case: a foundry that casts every shoe in the catalogue before anyone asks. That is an array and its methods (step 6): every element is computed, and held, whether it is used or not.
Examples, run for real
4.1 ExamHall: the review screen that held every attempt
EXH-4090: ExamHall's review screen shows the first three of a candidate's ECON-201 attempts. The attempts API returns two attempts per page, and this candidate has three pages. The shipped code collected every attempt before choosing three:
// review.ts (as shipped)
const all = await Array.fromAsync(attemptPages(candidate));
show(all.slice(0, 3));
MR !4390 reads the same async generator with a loop that stops once it has three:
// review.ts (MR !4390)
const shown = [];
for await (const attempt of attemptPages(candidate)) {
shown.push(attempt);
if (shown.length === 3) break;
}
show(shown);
4.1 files: attempts.ts, review.ts (as shipped and MR !4390) · tsc no diagnostics
4.1 EXH-4090 as shipped (Array.fromAsync): shown cand-17:a1 cand-17:a2 cand-17:a3 · attempts held 6 · cursor open false
4.1 MR !4390 (for await, break after 3): shown cand-17:a1 cand-17:a2 cand-17:a3 · attempts held 3 · cursor open false
Both show the same three attempts. The shipped code held all six, because Array.fromAsync reads to the end (step 11). The MR holds three, and leaving its loop closed the generator, so its finally released the API cursor (step 7).
4.2 The protocol by hand
Any object with the right methods works with for...of. Here is a countdown written without a generator:
const countdown = (from: number) => ({
[Symbol.iterator]() {
let n = from;
return { next: (): IteratorResult<number> => (n > 0 ? { value: n--, done: false } : { value: undefined, done: true }) };
},
});
4.2 files: countdown.ts · tsc no diagnostics
4.2 next() x3: {"value":2,"done":false} {"value":1,"done":false} {"done":true}
4.2 for...of: 3, 2, 1 · spread: 3, 2, 1
for...of and spread call next() until done is true, and never see the value that comes with it (steps 1–2).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A box office's hold service is written as a generator:
What does main.ts print?
// holds.tsexport function* holds(seats: string[]) { console.log('hold service opened'); for (const s of seats) { console.log(`holding ${s}`); yield s; }} // main.tsimport { holds } from './holds';const h = holds(['A1', 'A2', 'A3']);console.log('created');console.log(JSON.stringify(h.next()));Question 2 of 7
A box office offers a buyer open seats, one at a time, until the buyer takes one:
What does main.ts print?
// seats.tsexport function* openSeats() { try { for (const s of ['A1', 'A2', 'A3', 'A4']) yield s; } finally { console.log('hold released'); }} // main.tsimport { openSeats } from './seats';for (const s of openSeats()) { console.log(`offer ${s}`); if (s === 'A2') break;}console.log('buyer done');console.log('next buyer');Question 3 of 7
A chat app's support inbox shows the first three messages that ask for a refund. The team runs two ways to find them, once each, and counts the checks:
What does main.ts print?
// search.tsexport const calls = { matches: 0 };export const messages = Array.from({ length: 400 }, (_, i) => ({ id: i + 1, text: (i + 1) % 5 === 0 ? 'refund please' : 'hello',}));export function matches(m: { text: string }) { calls.matches += 1; return m.text.includes('refund');} // main.tsimport { calls, messages, matches } from './search';const viaArray = messages.filter(matches).slice(0, 3);const arrayChecks = calls.matches;calls.matches = 0;const viaHelpers = messages.values().filter(matches).take(3).toArray();console.log(`matches ran ${arrayChecks + calls.matches} times`);Question 4 of 7
A payments ledger reviews a bank's settlement entries until it meets the first large one:
What does main.ts print?
// ledger.tstype Entry = { id: number; amount: number };type Page = { entries: Entry[]; next?: number };export const bank = { fetched: 0, closed: false, async page(n: number): Promise<Page> { this.fetched += 1; const entries = Array.from({ length: 50 }, (_, i) => { const id = (n - 1) * 50 + i + 1; return { id, amount: id === 60 ? 25_000 : 120 }; }); return { entries, next: n < 4 ? n + 1 : undefined }; }, async close() { this.closed = true; },};export async function* allEntries() { let n: number | undefined = 1; try { while (n !== undefined) { const page: Page = await bank.page(n); yield* page.entries; n = page.next; } } finally { await bank.close(); }} // main.tsimport { bank, allEntries } from './ledger';async function review() { for await (const e of allEntries()) { if (e.amount > 10_000) break; } console.log(`pages fetched ${bank.fetched}`);}review();Question 5 of 7
The interviewer says: "Our box office's orders API is paged by cursor. Write paginate(source) so a caller can for await over every order without loading them all." source.page(cursor) resolves { items, next }: the first call gets cursor undefined, and next is undefined on the last page. source.close() releases the API cursor. Fetch a page only when the caller needs an item from it; call close() exactly once however the loop ends: finished, left early, or rejected because a page failed (which rejects the loop with that error). You have 15 minutes and a plain editor.
Say your test list first, then write it.
Question 6 of 7
A video platform decodes a film's segments as a viewer watches:
One viewer leaves at segment 2 and another watches to the end. After a busy day the platform runs out of decoders.
Which change makes main.ts print decoders open: 0?
// decoder.tsexport const decoders = { open: 0 };export function openDecoder(film: string) { decoders.open += 1; return { read: (i: number) => `${film}#${i}`, close: () => { decoders.open -= 1; }, };} // segments.tsimport { openDecoder } from './decoder';export function* segments(film: string, count: number) { const decoder = openDecoder(film); for (let i = 1; i <= count; i++) yield decoder.read(i); decoder.close();} // main.tsimport { decoders } from './decoder';import { segments } from './segments';function watch(film: string, leaveAt: number | null) { for (const seg of segments(film, 600)) { if (seg === `${film}#${leaveAt}`) break; }}watch('film-7', 2);watch('film-8', null);console.log(`decoders open: ${decoders.open}`);Question 7 of 7
A chat client finds which page of history holds a message. It reads the history API through an async generator (closeCursor stands in for the API call):
What does main.ts print?
// history.tsexport const api = { async closeCursor() { await null; },};export async function* pages() { try { yield ['m1', 'm2']; yield ['m3', 'm4']; yield ['m5', 'm6']; } finally { console.log('releasing cursor'); await api.closeCursor(); console.log('cursor released'); }} // main.tsimport { pages } from './history';async function pageOf(id: string) { let n = 0; for await (const page of pages()) { n += 1; if (page.includes(id)) return n; } return 0;}pageOf('m3').then((n) => console.log(`m3 is on page ${n}`));Go deeper
Pro tips
- Check what your runtime ships before you rely on a helper. Node 24 has the sync iterator helpers but no async ones: on a
for awaitsource, write the loop yourself, or useArray.fromAsynconly when you want every value. When: moving a pipeline from arrays to an async source (step 11). Verify (RAN,tips.mjs): the first line below. - Give a hand-written iterator the helpers with
Iterator.from(). An object that only hasnext()getsmap,filterandtakeonce wrapped. When: an SDK returns its own cursor object (steps 1, 5). Verify (RAN,tips.mjs): the second line below.
tip 1 node 24.21.0: Iterator.prototype.take function · AsyncIterator undefined · async generator .map undefined · Array.fromAsync function
tip 2 cursor with only next(), wrapped by Iterator.from: filter(even).take(2) -> r2, r4 · rows read 4 of 6
Source
Every graded behaviour is executed, so no item is keyed on these pages; they back the teaching. Valid as of 2026-10-13.
- MDN, Iteration protocols (the row's first source), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols (
mdn-iteration-protocols.txt).next()is "A function that accepts zero or one argument and returns an object conforming to the IteratorResult interface (see below)." (step 1); onreturn(): "This can happen, for example, if a break or return is encountered in a for...of loop, or if all identifiers are already bound in an array destructuring." and "The return() method is only called for premature exits—if next() returns done: true , the return() method is not called, with the assumption that the iterator has already cleaned up." (step 7). - MDN, Iterator.prototype.take() (the row's second source), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/take (
mdn-iterator-take.txt). "The returned iterator helper yields the elements in the original iterator one-by-one, and then completes (the next() method produces { value: undefined, done: true } ) once limit elements have been yielded, or when the original iterator is exhausted, whichever comes first." (steps 5, 8; thattakethen closes its source is executed, not quoted: t1, t2). - MDN, Generator.prototype.return() (not named by the row; teaching only), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator/return (
mdn-generator-return.txt). "The return() method of Generator instances acts as if a return statement is inserted in the generator's body at the current suspended position, which finishes the generator and allows the generator to perform any cleanup tasks when combined with a try...finally block." (step 9). - The row's third source, Node.js's "Consuming readable streams with async iterators", is quoted and taught by
node-streams-backpressure; this lesson serves no item on streams.
Related lessons
Checked against the tools and docs as of 2026-10-13.