GUIDE · Behavioral

Iterator

Provide a way to access the elements of an aggregate object sequentially without exposing its underlying representation.

lesson2 sectionsruns in browserBehavioral18 asserts102 lines

At a glance

INTENT
Provide a way to access the elements of an aggregate object sequentially without exposing its underlying representation.
PROBLEM
Callers want for..of over your collection (paginated API, tree, linked list, ring buffer) without knowing whether it's an array, a Map, pages from a server, or computed lazily.
SAY
Participants: Iterator (next() → { value, done }), ConcreteIterator (holds cursor state), Aggregate (createIterator() — in JS: [Symbol.iterator]()), ConcreteAggregate. Pitch: "one protocol — next()/done — so any collection works with for..of, spread and destructuring."
SEEN IN
for..of, spread [...x], destructuring, Array.from, Promise.all — all drive Symbol.iterator; Map/Set/String iterators; generators function*; for await over Node streams, readline, fs.opendir; DB cursors (Mongo cursor.next(), pg-cursor); paginated SDK iterators (AWS paginate*, Octokit paginate.iterator); Node 22+ iterator helpers (.map/.filter/.take).
VS
Visitor pushes an operation INTO the structure; Iterator pulls elements OUT one at a time. Composite is the tree; Iterator is how you walk it. Observer PUSHES items to you; Iterator you PULL — async iterators blur the line.

The idea#

Picture a remote control pointed at a playlist. You press next. A song comes out. You press next again. You never open the speaker to see whether the songs live in one list, on three discs, or get fetched when you press the button. An empty disc is skipped. When nothing is left, next stays done. The only protocol is next, and done.

An iterator is that remote. It gives you the elements of a collection one at a time without exposing how the collection is stored. Callers want for...of over a paginated API, a tree, a linked list, or a ring buffer. They should not care that the pages are arrays, that one page is empty, or that the next page is a network call. The iterator's next returns { value, done }. The concrete iterator holds the cursor. The aggregate exposes createIterator, and in JavaScript that hook is Symbol.iterator. Spread, destructuring, and Array.from all press the same button.

Why it matters#

The bug this pattern prevents is a caller that knows your storage. If the orders are pages, and the caller loops page index and item index itself, every caller relearns that an empty page is not the end. Move the orders into a map, or start computing them lazily, and every loop breaks. Give them the remote. The storage can change. The button stays.

You already press this button. for...of, spread, destructuring, Array.from, and Promise.all all drive Symbol.iterator. Map, Set, and String each hand out iterators. A generator, function*, is a concrete iterator whose cursor is the suspended function. for await walks async iterators, which is how you read Node streams, readline, and a directory handle. Database cursors, paginated SDK helpers, and Node's map, filter, and take helpers are the same remote pointed at a server.

A visitor pushes an operation into the structure and the structure calls you. An iterator pulls elements out, one at a time, and you decide when to ask. A composite is the tree. An iterator is how you walk it. An observer pushes items at you. With an iterator, you pull. Async iterators blur that line, and you still pull.

A small example#

JS
function remote(discs) {  let disc = 0;  let track = 0;  return {    next() {      while (disc < discs.length) {        const songs = discs[disc];        if (track < songs.length) {          const song = songs[track];          track += 1;          return { value: song, done: false };        }        disc += 1;        track = 0;      }      return { value: undefined, done: true };    },  };} const player = remote([['intro'], [], ['chorus']]);const first = player.next();const second = player.next();

first.value is intro. second.value is chorus. The empty disc in the middle never became a song. The caller did not write a loop over discs.

What the code below is doing#

The sheet shows the same idea twice.

The first shape uses classes so the book names are visible. PagedOrders is the concrete aggregate. Its storage is an array of pages. createIterator builds a PagedOrdersIterator, and Symbol.iterator calls that, so the collection itself works with spread. The iterator keeps the page index, the index inside the page, and a fetch count. next walks pages. The first time it looks at a page it counts a fetch, which stands in for a network call. An empty page increments the page and resets the index, so the caller never sees it. Past the last page, next returns done: true and stays there. The iterator is also iterable, by returning itself, so you can spread a cursor you already hold.

The sample pages are two orders, an empty page, and one more order. The first three next calls yield ids 1, 2, and 3. The fourth is done, and the fifth is still done. Fetches equal 3, because the empty page was opened. Spread on the aggregate maps to ids 1, 2, and 3, using a fresh iterator rather than the exhausted cursor. Destructuring takes the first order and leaves a rest array of length 2. A brand-new iterator that only calls next once has a fetch count of 1. The empty page and the last page stay untouched.

The second shape lets the cursor live in a generator. OrderHistory stores orders per customer. Its default iterator walks each customer's list from the end, so the newest order comes first, and customers come in insertion order. Ada's a2 and a1, then Bob's b1. byCustomer yields that customer's list in the stored order, oldest first, and a missing name yields nothing. totals walks the default iterator and yields each total. The three totals sum to 35. ids is an infinite generator. take pulls only as many as you ask, so three ids are ord-1, ord-2, and ord-3 without building an endless array. A second generator remembers its place: the next call is inv-1, and the one after is inv-2.

The async generator fetchAllPages is the remote pointed at a server. It asks for a page, yields every item, and follows next until the cursor is null. The fake API returns 1 and 2 with a cursor, then 3 with a null cursor. for await collects [1, 2, 3]. Node streams work the same way.

When you talk about it, say this: one protocol, next and done, so any collection works with for...of, spread, and destructuring.

Everything below is the demonstration. Same idea, written as code you can run.

JSsystem-design/design-patterns/behavioral/iterator.js · setup
'use strict';const assert = require('node:assert/strict');
01

Classic shape (classes, so the participant names are visible)#

JS
class PagedOrders {                                       // ConcreteAggregate — representation: array of pages  constructor(pages) { this.pages = pages; }  createIterator() { return new PagedOrdersIterator(this.pages); }   // Aggregate.createIterator()  [Symbol.iterator]() { return this.createIterator(); }  // the JS protocol hook}class PagedOrdersIterator {                               // ConcreteIterator — cursor state hidden from client  #pages; #page = 0; #idx = 0; #fetches = 0;  constructor(pages) { this.#pages = pages; }  get fetches() { return this.#fetches; }  next() {    while (this.#page < this.#pages.length) {      const page = this.#pages[this.#page];      if (this.#idx === 0) this.#fetches += 1;            // one "network call" per page      if (this.#idx < page.length) return { value: page[this.#idx++], done: false };      this.#page += 1; this.#idx = 0;    }    return { value: undefined, done: true };  }  [Symbol.iterator]() { return this; }                    // iterators are themselves iterable} const orders = new PagedOrders([[{ id: 1 }, { id: 2 }], [], [{ id: 3 }]]);const it = orders.createIterator();const [first, ...rest] = orders;                                    // destructuring tooconst it2 = orders[Symbol.iterator]();it2.next();
Verified by 10 asserts
JS
assert.deepEqual(it.next(), { value: { id: 1 }, done: false });assert.deepEqual(it.next(), { value: { id: 2 }, done: false });assert.deepEqual(it.next(), { value: { id: 3 }, done: false });   // empty page skipped transparentlyassert.deepEqual(it.next(), { value: undefined, done: true });assert.deepEqual(it.next(), { value: undefined, done: true });    // stays doneassert.equal(it.fetches, 3);assert.deepEqual([...orders].map((o) => o.id), [1, 2, 3]);         // spread uses Symbol.iteratorassert.equal(first.id, 1);assert.equal(rest.length, 2);assert.equal(it2.fetches, 1);                                       // lazy: only page 1 touched so far
02

Idiomatic JS shape: generators#

cursor state lives in the suspended function frame

JS
class OrderHistory {  #byCustomer = new Map();  add(customer, order) {    if (!this.#byCustomer.has(customer)) this.#byCustomer.set(customer, []);    this.#byCustomer.get(customer).push(order);    return this;  }  *[Symbol.iterator]() {                                  // default traversal: all orders, newest first per customer    for (const list of this.#byCustomer.values()) for (let i = list.length - 1; i >= 0; i--) yield list[i];  }  *byCustomer(name) { yield* this.#byCustomer.get(name) ?? []; }   // alternative traversal, same protocol  *totals() { for (const o of this) yield o.total; }}const hist = new OrderHistory()  .add('ada', { id: 'a1', total: 10 }).add('ada', { id: 'a2', total: 20 }).add('bob', { id: 'b1', total: 5 }); // Lazy / infinite sequences — impossible with arrays, trivial with generatorsfunction* ids(prefix) { let n = 0; while (true) yield `${prefix}-${++n}`; }const take = (iter, n) => { const out = []; for (const v of iter) { out.push(v); if (out.length === n) break; } return out; };const gen = ids('inv'); // Async iterator — pages from a (fake) API consumed with for await (Node streams work exactly like this)async function* fetchAllPages(api) {  let cursor = null;  do { const page = await api(cursor); yield* page.items; cursor = page.next; } while (cursor);}const fakeApi = async (cursor) => (cursor === null ? { items: [1, 2], next: 'p2' } : { items: [3], next: null }); (async () => {  const seen = [];  for await (const item of fetchAllPages(fakeApi)) seen.push(item);  console.log('ok: design-patterns/behavioral/iterator.js');})();
Verified by 8 asserts
JS
assert.deepEqual([...hist].map((o) => o.id), ['a2', 'a1', 'b1']);assert.deepEqual([...hist.byCustomer('ada')].map((o) => o.id), ['a1', 'a2']);assert.equal([...hist.totals()].reduce((a, b) => a + b, 0), 35);assert.deepEqual(Array.from(hist.byCustomer('nobody')), []);assert.deepEqual(take(ids('ord'), 3), ['ord-1', 'ord-2', 'ord-3']);assert.equal(gen.next().value, 'inv-1');assert.equal(gen.next().value, 'inv-2');                  // resumable cursor  assert.deepEqual(seen, [1, 2, 3]);

See also