← Design patterns
Behavior Iteration protocols and generators

Iterator / generator

Ask for the next pairing.

The floor marshal has room for three matches on the call-up board. The schedule is paginated and ordered by start time. Who keeps the next page and its unread pairings, then stops fetching when the board is full?

TypeScriptGoRust One traversal, in each language’s own protocol.

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):

board.ts
// 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:

Iterators you already use
Where you meet oneWhat it hands youWhat 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 yieldValues 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:

schedule.ts
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.

Who owns what
ParticipantOwnsDoes not need to know
MarshalWhich pairings to post, and when the board is full.Which page holds the next pairing.
IteratorThe page number, the page it fetched, its place in it, and the session.Why the marshal wants a pairing.
Schedule serviceServing 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.

schedule.ts
// 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 };
  }
}
The production iterator’s terminal outcomes
OutcomeMeaningA later pull
EndedThe page in hand is used up and no next page remains.Returns the end, with no more work for the service.
Stopped earlyThe caller ended this traversal itself.Returns the end; it cannot resume.
FailedA 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:

schedule.ts
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();
}
Suppose the generator closed the session after its loop, as above, instead of in finally. The marshal still breaks out once the board holds three pairings. What does the log line show?
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:

report.ts
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.

When does an iterator earn its extra state?
Caller’s jobCollection and loopIncremental iterator
Five known pairings in memoryA loop is simple and repeatable.Adds position and lifetime rules without a useful gain.
Post three from a paginated scheduleFetching 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 scheduleMaterialize 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 pageFetch 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.

The marshal has posted two pairings and pulls a third from page 2. The board has no slots left, and the schedule has a next page. What should the marshal do, and what may it report?
A nightly report reads every order from a paginated orders API. It prints the total, then the orders sorted by region. What shape fits?

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