AniUI Academy

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.

15 min read

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);                 // same

Map, 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 points

Writing 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 either

Only 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.

Try it yourself
Loading playground...

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 and Array.from.
  • Calling a generator runs nothing; next() drives it, one yield at a time.
  • next(value) sends data in, return/throw finish it, and finally still runs.
  • Laziness makes infinite and early-exit pipelines expressible, and cheap.
  • for await...of hides paging and chunking from the caller. That is its best use.

Check yourself

4 questions · pass 3/4 to unlock Functional Patterns

up to 50
  1. 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. 2.What must an object provide before for...of and spread will work on it?

  3. 3.Inside a generator you write const reply = yield 1;. Where does reply come from?

  4. 4.What does for await...of give you that for...of over an array of promises does not?

4 left to answer