Definition
An iterator gives a way to reach the elements of a collection one after another without exposing how the collection stores them.
After Gamma et al., Design Patterns (1994), Iterator
01Start with the whole schedule
The marshal wants three pairings. The schedule has pages.
When five pairings already live in an array, a normal loop is enough. If the marshal needs every match from a short schedule, fetching all pages first can also be a clear design. Here is that first version, against the tournament’s schedule service, which opens a session, serves one page at a time by number and tells you the next page’s number (the next section shows it whole):
// Fetch every page, then post the first three.
schedule.open();
const all: Pairing[] = [];
let page: number | null = 1;
while (page !== null) {
const fetched = schedule.fetch(page);
all.push(...fetched.pairings);
page = fetched.next;
}
schedule.close();
const board = all.slice(0, 3).map((p) => `${p.white} vs ${p.black}`); It is right when the marshal needs the whole schedule: to sort it, count it or read it twice. The pressure changes when the schedule grows, or the board fills before the schedule ends. Fetching everything first requests pairings the marshal will not post. Moving the paging into the marshal’s own loop, stopping at three, fixes that until a second caller, the arbiter asking when a player’s next game is, needs the same pages with a different stop. Then both repeat the page number, the page in hand, the place in it and the cleanup.
An iterator hands the caller the values one at a time and keeps the traversal’s progress itself, without exposing how the source stores or reaches them. The iterator owns the current page and its place in it. The marshal asks for the next pairing and decides when it has enough.
A generator is one way to write that protocol. It can pause at a yield and resume with its local variables intact. An explicit iterator keeps the
same progress in fields instead. The responsibility is the same whatever the language’s loop
syntax looks like. You already use iterators like these:
| Where you meet one | What it hands you | What it keeps for you |
|---|---|---|
| for (const x of array) | Each element, in order. | The index; a Map or Set keeps its own place. |
| function* and yield | Values as the caller asks for them. | The function’s local variables, paused at the last yield. |
| for await (const chunk of stream) | Chunks of a Node or web stream. | Where the stream had got to, and when to release it. |
02Let the marshal pull
The marshal asks; the iterator remembers.
The schedule service serves pairings in start-time order, two to a page. The iterator opens a session on the first pull, fetches a page when it has handed out everything on the one it holds, and follows each page’s next page until there is none. The marshal’s loop asks for pairings and breaks when the board’s three slots are full. This is the whole program the lab runs:
type Pairing = { white: string; black: string };
type Page = { pairings: Pairing[]; next: number | null };
// The schedule service: pairings in start-time order, a page at a time.
class Schedule {
log: string[] = [];
pages: Pairing[][];
constructor(pages: Pairing[][]) {
this.pages = pages;
}
open(): void {
this.log.push('open');
}
fetch(page: number): Page {
this.log.push(`fetch ${page}`);
const next = page < this.pages.length ? page + 1 : null;
return { pairings: this.pages[page - 1], next };
}
close(): void {
this.log.push('close');
}
}
// The iterator: it keeps the page, its place and the session.
function* pairings(schedule: Schedule): Generator<Pairing> {
schedule.open();
try {
let page: number | null = 1;
while (page !== null) {
const fetched = schedule.fetch(page);
for (const pairing of fetched.pairings) yield pairing;
page = fetched.next;
}
} finally {
schedule.close();
}
}
// The floor marshal posts pairings until the board's three slots are full.
const schedule = new Schedule([
[{ white: 'Ayla', black: 'Boris' }, { white: 'Camila', black: 'Dinesh' }],
[{ white: 'Edda', black: 'Finn' }, { white: 'Gita', black: 'Hugo' }],
[{ white: 'Ines', black: 'Jamal' }],
]);
const board: string[] = [];
for (const pairing of pairings(schedule)) {
board.push(`${pairing.white} vs ${pairing.black}`);
if (board.length === 3) break;
}
const log = schedule.log.join(', ');
console.log(board.join(' | '));
console.log(log); The marshal posts Ayla vs Boris, Camila vs Dinesh and Edda vs Finn, then stops. Two pairings fit on a page, so that takes two fetches; page 3 is never requested, and Gita vs Hugo stays unread on the page the iterator was holding.
| Participant | Owns | Does not need to know |
|---|---|---|
| Marshal | Which pairings to post, and when the board is full. | Which page holds the next pairing. |
| Iterator | The page number, the page it fetched, its place in it, and the session. | Why the marshal wants a pairing. |
| Schedule service | Serving one page, and opening and closing a session. | How the marshal posts pairings. |
What a production iterator addsFailure, cancellation, and why the caller stopped
The schedule service is remote, so the iterator becomes an async generator, pulled with for await. An AbortSignal reaches every call,
so a marshal who leaves can cancel a slow fetch. A next page that is not later than the
current one is an error, since it would loop forever, and each pairing is handed out as a
copy, so a caller that edits one cannot edit the page.
The marshal now says why it stopped: full (no slots left), ended (the schedule ran out) or failed (a fetch broke, so the board may be short). Pairings already posted from a failed read are a partial board, not a complete one.
// The iterator, for a remote schedule: asynchronous and cancellable.
export async function* pairings(
schedule: Schedule,
signal: AbortSignal,
): AsyncGenerator<Pairing, void, undefined> {
await schedule.open(signal);
try {
let page: number | null = 1;
while (page !== null) {
const current: number = page;
const fetched = await schedule.fetch(current, signal).catch((cause) => {
throw new Error(`fetch page ${current}`, { cause });
});
// A next page that is not later would loop forever.
if (fetched.next !== null && fetched.next <= current) {
throw new Error(`page ${current} points back to page ${fetched.next}`);
}
// A copy, so a caller that edits its pairing cannot edit the page.
for (const pairing of fetched.pairings) yield { ...pairing };
page = fetched.next;
}
} finally {
await schedule.close();
}
}
// The floor marshal fills the board and says why it stopped:
// full (no slots left), ended (the schedule ran out) or failed.
export async function fillBoard(
schedule: Schedule,
slots: number,
signal: AbortSignal,
): Promise<BoardResult> {
const board: string[] = [];
if (slots <= 0) return { board, outcome: 'full' };
try {
for await (const pairing of pairings(schedule, signal)) {
board.push(`${pairing.white} vs ${pairing.black}`);
if (board.length === slots) return { board, outcome: 'full' };
}
return { board, outcome: 'ended' };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { board, outcome: 'failed', error: message };
}
} | Outcome | Meaning | A later pull |
|---|---|---|
| Ended | The page in hand is used up and no next page remains. | Returns the end, with no more work for the service. |
| Stopped early | The caller ended this traversal itself. | Returns the end; it cannot resume. |
| Failed | A fetch failed; the pairings already handed out are partial. | Returns the end, while the reported failure stands. |
Every terminal path drops the page in hand and closes an opened session once. Stopping before the first pull opens nothing. A failure is reported once, when it happens; ignoring that error and seeing the end later does not make the traversal complete.
03Predict the early stop
The board is full. Who closes the session?
Experiment
The marshal pulls; the iterator fetches
The generator, written with the close after its loop instead of in finally:
function* pairings(schedule: Schedule): Generator<Pairing> {
schedule.open();
let page: number | null = 1;
while (page !== null) {
const fetched = schedule.fetch(page);
for (const pairing of fetched.pairings) yield pairing;
page = fetched.next;
}
schedule.close();
} Reading the TypeScriptGenerators, next(), return() and finally
Calling pairings(schedule) runs none of its body: it makes a generator object.
Its body starts at the first next(), which is why the session opens on the first
pull, and pauses at each yield. next() returns { done, value }; a for...of loop calls it for you.
When the loop breaks or returns, it calls the generator’s return(), which
finishes the generator from the paused yield and runs its finally blocks. A caller that calls next() by hand and stops early
must call return() itself; dropping the reference closes nothing. A generator
object is single-use: once finished, it stays finished, and calling pairings(schedule) again starts a new traversal.
Generator<Pairing> is the generator’s type: it yields pairings. An object
with a next() method is an iterator; one with a [Symbol.iterator]() method that returns one is iterable, which is what for...of needs, and a generator is both.
04Meet it in your language
An iterator you already use, and the bug it hides.
You already hand iterators around: a parameter typed Iterable<Pairing> accepts an array, a Set, a Map’s values or a generator. They differ
the first time a function reads its input twice:
type Pairing = { white: string; black: string };
// Before: the report reads its input twice. An array can be read twice;
// a generator is used up by the first loop.
function roundReportBefore(pairings: Iterable<Pairing>): string {
let games = 0;
for (const _ of pairings) games += 1;
const lines: string[] = [];
for (const p of pairings) lines.push(`${p.white} vs ${p.black}`);
return [`${games} games`, ...lines].join('\n');
}
// After: one pass collects, and both reads use the array.
function roundReport(pairings: Iterable<Pairing>): string {
const all = Array.from(pairings);
const lines = all.map((p) => `${p.white} vs ${p.black}`);
return [`${all.length} games`, ...lines].join('\n');
}
// Or stay lazy: iterator helpers pull only what the answer needs,
// and take(3) closes the generator once it has three.
function nextGames(games: Generator<Pairing>, player: string): Pairing[] {
return games
.filter((p) => p.white === player || p.black === player)
.take(3)
.toArray();
} With an array, the first version prints the count and the lines; with a generator, the first
loop uses it up, and the report says “3 games” over an empty list. Take an array when you need
two passes, or collect once with Array.from. When one pass is enough, iterator
helpers (ES2025: Node 22, and TypeScript’s es2025 or esnext lib) keep
it lazy.
05Decide when it helps
A collection, or one value at a time?
A collection is a direct fit when the caller needs the full result, indexing, sorting, or another pass. An iterator hands out values one at a time through a shared protocol; a generator can keep that progress in paused control flow.
| Caller’s job | Collection and loop | Incremental iterator |
|---|---|---|
| Five known pairings in memory | A loop is simple and repeatable. | Adds position and lifetime rules without a useful gain. |
| Post three from a paginated schedule | Fetching every page first requests pairings the board will not use. | Pull until the board is full; the iterator still needs page, place and cleanup rules. |
| Sort or revisit the complete schedule | Materialize the result and choose the order or pass you need. | A one-pass iterator gives neither indexing nor a second pass by itself. |
| Retry after a failed page | Fetch again; the pairings already read are still in the list. | Retry inside the iterator, which knows the page; a new traversal starts from page 1. |
The rule for what goes where: the iterator owns how to reach the next value, the page number, the page in hand, the place in it and the session. The caller owns why it wants one and when it has enough. An iterator keeps memory bounded to one page, but a second pass or a retry needs explicit state and cleanup rules; a collection costs memory and is easy to reuse.
Its neighbours: Composite builds the trees an iterator often walks; Adapter can translate a pagination API into the source an iterator consumes; Observer is the other direction, where the source decides when the next value arrives and pushes it, instead of the consumer pulling.
What the pattern leaves openPage size, retries, ordering and late changes
Page size. Larger pages mean fewer requests when the caller reads far, and more unused rows when it stops early. The right size also depends on latency and memory.
Retries. Starting a new traversal after a failure repeats every pairing already posted. The iterator knows which page failed, so a retry belongs there; the caller still needs a policy for a read that fails for good, such as a partial board marked as partial.
Ordering and late changes. A schedule that changes while it is read can skip or repeat a pairing at a page boundary. A production source needs a contract for stable order, what a next page means, and bounds on empty or looping responses. Cancellation and deadlines must reach the source: a board with three slots can still wait too long if one pull crosses an unbounded run of empty pages.
06Make the call
Stop, or read to the end?
A full board and an ended schedule are different facts, and the marshal is the one who knows which it has.
Before you open the answer below, say it in your own words: what progress does the iterator own, and when must the caller stop it and release it?
One way to say itWho keeps the place, and who decides to stop
The iterator owns the progress: the page number, the page it fetched, its place in that page,
and the session it opened, so a caller asks for the next value and never touches a page. The
caller owns the stop: the marshal knows when three pairings are enough, and stopping early is a
normal end, not an error. Cleanup goes where every exit reaches it, whether the schedule ran
out, the caller stopped, or a fetch failed: finally in a generator, a deferred Close behind a yield that is checked, Drop on an iterator
the loop owns. A full board is not an ended schedule, and a failed read is neither. And when the
caller needs the whole result, two passes or a sort, collect it once: that is a collection’s
job.
Practice in the lab
A hiring team’s review app reads one job post’s applications from a job board’s API, newest first, a page at a time: the first request gets page 1, and each page carries a token for the next. The board logs every session it opens, every page it serves and every close, and the cases read that log. Each level starts from the previous level’s finished code, so you can begin at any of them.
- ITERATOR-101 (easy) Two screens page through the job board by hand
- ITERATOR-102 (medium) Load more starts again from the top Pro
- ITERATOR-103 (hard) Review: after a hiccup, applicants appear twice Pro