Iterators and Generators

Mental model: An iterator is a value that has not finished arriving yet, and a generator is a function you can pause in the middle and resume later with new information.

Level: advanced · about 20 minutes

An array hands you everything at once. An iterator hands you one value and a promise of nothing in particular: it might be finite, infinite, expensive, or arriving from a network. for...of, spread, destructuring and Array.from do not know or care which, because they all speak one tiny protocol.

iterable                iterator                 result
  {                       {                        {
    [Symbol.iterator]() ->  next() ------------->    value: 3,
  }                       }  <- called again ->      done: false
                                                   }
iterable
has a [Symbol.iterator]() method that returns an iterator
iterator
has a next() method returning { value, done }
iterable iterator
both: its [Symbol.iterator]() returns this. Generators are these
function makeCountdown(from) {
  let n = from;
  return {
    [Symbol.iterator]() {
      return this;          // makes it usable in for...of directly
    },
    next() {
      return n > 0 ? { value: n--, done: false } : { value: undefined, done: true };
    },
  };
}

const it = makeCountdown(3);
console.log(it.next());   // -> { value: 3, done: false }
console.log(it.next());   // -> { value: 2, done: false }
console.log([...it]);     // -> [ 1 ]  the first two were already consumed
console.log([...it]);     // -> [] an exhausted iterator stays exhausted

The protocol by hand. No syntax sugar, so you can see every moving part.

What for...of actually does

function forOf(iterable, body) {
  const iterator = iterable[Symbol.iterator]();
  try {
    while (true) {
      const step = iterator.next();
      if (step.done) break;
      body(step.value);
    }
  } finally {
    // break, return or throw inside the loop lands here
    if (typeof iterator.return === 'function') iterator.return();
  }
}

forOf(['a', 'b'], (v) => console.log(v));   // -> a  then  b

for...of desugared. Note the return() call on early exit.

Generators: the same thing, ten times shorter

Hand-written iterator

function range(a, b) {
  let n = a;
  return {
    [Symbol.iterator]() { return this; },
    next() {
      return n < b
        ? { value: n++, done: false }
        : { value: undefined, done: true };
    },
  };
}

Generator

function* range(a, b) {
  for (let n = a; n < b; n++) {
    yield n;
  }
}

Identical behaviour. The generator version also gets return() and throw() for free, and it cannot get the done bookkeeping wrong. Write generators unless you have a specific reason not to.

function* trace() {
  console.log('start');
  yield 1;
  console.log('resumed once');
  yield 2;
  console.log('finishing');
  return 'done';
}

const g = trace();
console.log('created, nothing has run yet');
console.log(g.next());   // -> start        then { value: 1, done: false }
console.log(g.next());   // -> resumed once then { value: 2, done: false }
console.log(g.next());   // -> finishing    then { value: 'done', done: true }
console.log(g.next());   // -> { value: undefined, done: true }

function* and yield. The function body runs only when you pull.

function* gen() {
  const a = yield 'first';
  console.log('got', a);
  const b = yield 'second';
  console.log('got', b);
}

const g = gen();
console.log(g.next('ignored').value);
console.log(g.next(10).value);
g.next(20);

The argument to the first next() has nowhere to go, because the generator has not reached a yield yet, so 'ignored' is discarded. Each later next(v) makes the paused yield expression evaluate to v. So the order is: first logged by the caller, then got 10 when the body resumes, then second, then got 20.

Two-way communication

yield is an expression, not a statement. It sends a value out, and when the caller resumes you with next(x), it evaluates to x. That turns a generator into a coroutine: the caller and the generator take turns, each one deciding what happens next.

function* interview() {
  const name = yield 'What is your name?';
  const years = yield `Hello ${name}. How many years of JS?`;
  return years > 2 ? `${name} is experienced` : `${name} is learning`;
}

const g = interview();
console.log(g.next().value);        // -> What is your name?
console.log(g.next('Ada').value);  // -> Hello Ada. How many years of JS?
console.log(g.next(5).value);      // -> Ada is experienced

A generator that asks questions and reacts to the answers.

function* withCleanup() {
  try {
    yield 'open';
    yield 'work';
  } catch (err) {
    console.log('caught inside:', err.message);
    yield 'recovered';
  } finally {
    console.log('cleanup ran');
  }
}

const a = withCleanup();
a.next();
console.log(a.return('stopped'));           // -> cleanup ran, then { value: 'stopped', done: true }

const b = withCleanup();
b.next();
console.log(b.throw(new Error('boom')));    // -> caught inside: boom, then { value: 'recovered', done: false }

return() and throw() inject control flow from outside. finally still runs.

Delegation with yield*

function* inner() {
  yield 'b';
  yield 'c';
  return 'inner done';
}

function* outer() {
  yield 'a';
  const result = yield* inner();   // the value of yield* is inner's return value
  console.log('inner returned:', result);
  yield 'd';
}

console.log([...outer()]);   // -> inner returned: inner done, then [ 'a', 'b', 'c', 'd' ]

// It works on any iterable, not just generators.
function* letters() {
  yield* 'xy';
  yield* [1, 2];
}
console.log([...letters()]);  // -> [ 'x', 'y', 1, 2 ]

yield* hands control to another iterable and forwards everything, including the return value.

Infinite and lazy

function* naturals() {
  let n = 1;
  while (true) yield n++;
}

function* map(iterable, fn) {
  for (const v of iterable) yield fn(v);
}

function* filter(iterable, keep) {
  for (const v of iterable) if (keep(v)) yield v;
}

function* take(iterable, n) {
  if (n <= 0) return;
  let i = 0;
  for (const v of iterable) {
    yield v;
    if (++i >= n) return;
  }
}

console.log([...take(filter(map(naturals(), (n) => n * n), (n) => n % 2 === 1), 5)]);
// -> [ 1, 9, 25, 49, 81 ]   only nine numbers were ever generated

An infinite sequence is fine as long as nobody asks for all of it.

Since ES2025 you do not have to write those helpers. Any iterator gets map, filter, take, drop, flatMap, reduce, toArray, some, every and find from Iterator.prototype, and they stay lazy.

function* naturals() {
  let n = 1;
  while (true) yield n++;
}

if (typeof naturals()[Symbol.iterator]().map === 'function') {
  const result = naturals()
    .map((n) => n * n)
    .filter((n) => n % 2 === 1)
    .take(5)
    .toArray();
  console.log(result);   // -> [ 1, 9, 25, 49, 81 ]
} else {
  console.log('This engine has no iterator helpers yet.');
}

Iterator helpers. Baseline in current browsers and Node 22 or newer.

WantArray methodIterator helperDifference
transformarr.map(fn)it.map(fn)iterator version is lazy, one value at a time
first narr.slice(0, n)it.take(n)iterator version never builds the rest
skip narr.slice(n)it.drop(n)same laziness
collectalready an arrayit.toArray()this is where the work actually happens
chain infiniteimpossiblefinearrays must be finite, iterators need not be

Generators as state machines

function* trafficLight() {
  while (true) {
    yield 'green';
    yield 'amber';
    yield 'red';
  }
}

const light = trafficLight();
console.log([light.next().value, light.next().value, light.next().value, light.next().value]);
// -> [ 'green', 'amber', 'red', 'green' ]

The state is the position in the code, so there is no state variable to get wrong.

  1. Reach for a generator when the sequence is infinite, expensive, or you want to stop early without computing the rest.
  2. Reach for an array when the data is small, already in memory, and you want to iterate it more than once.
  3. Reach for an async generator when each value needs awaiting, for example a paginated API. Covered in module 12.

Interactive visualiser: iterator. Enable JavaScript to use it.

Try it yourself

Zip and unzip lazily

function* naturals() {
  let n = 1;
  while (true) yield n++;
}

function* zip(...iterables) {
  const iterators = iterables.map((it) => it[Symbol.iterator]());
  while (true) {
    const steps = iterators.map((it) => it.next());
    if (steps.some((s) => s.done)) return;
    yield steps.map((s) => s.value);
  }
}

console.log([...zip(['a', 'b', 'c'], [1, 2])]);
// -> [ [ 'a', 1 ], [ 'b', 2 ] ]

const labelled = zip('xyz', naturals());
console.log([...labelled]);
// -> [ [ 'x', 1 ], [ 'y', 2 ], [ 'z', 3 ] ]

Change zip to stop at the longest input instead of the shortest, filling the gaps with undefined. Then prove your version still works with an infinite generator on one side.

Lazy tree traversal

const tree = {
  value: 'root',
  children: [
    { value: 'a', children: [{ value: 'a1', children: [] }, { value: 'a2', children: [] }] },
    { value: 'b', children: [{ value: 'b1', children: [] }] },
  ],
};

function* walk(node) {
  yield node.value;
  for (const child of node.children) yield* walk(child);
}

console.log([...walk(tree)]);
// -> [ 'root', 'a', 'a1', 'a2', 'b', 'b1' ]

// Laziness: stop as soon as you find what you came for.
function findFirst(node, predicate) {
  for (const value of walk(node)) {
    if (predicate(value)) return value;
  }
  return null;
}

console.log(findFirst(tree, (v) => v.startsWith('a')));   // -> a

Add a depth to each yielded value. Then write a breadth-first version using a queue and compare the output order.

Exercises

Lazy take

Write take(iterable, n) returning an iterable of at most the first n values. It must be lazy: pulling three values from take(source, 3) must pull exactly three values from source, no more. n of zero or less yields nothing, and it must close the source when it stops early.

A two-way accumulator

Write a generator accumulate(start = 0) that yields a running total. The first next() yields the starting total. Each later next(n) adds the number n and yields the new total. next() with no argument (or a non-number) leaves the total unchanged and yields it again. It never finishes on its own.

Check yourself

What does this log?
2 0 — A generator object is its own iterator, so the first spread drains it and the second finds done: true immediately. Spreading g() twice (calling the function again) would give 2 2. This is the single most common iterator bug in real code.
What is the value of yield* in const x = yield* inner();?
the value inner returned when it finished — yield* forwards every yielded value to the outer consumer, and then evaluates to the delegate's return value. That is the mechanism that made generator-based coroutine libraries (and early async/await transpilation) possible: the yielded values go outward, the return value comes back inward.
Which of these is guaranteed by the iterator protocol?
An iterator that has returned done: true keeps returning done: true — Once an iterator says it is done, it must stay done. There is no length (that is exactly why iterators can be infinite), re-iterability is a property of the iterable and not the protocol, and async iterators return promises from next(), so synchronicity is not required either.
You break out of a for...of loop over a generator that has a finally block. What happens?
for...of calls iterator.return(), which resumes the generator at the yield and runs finally — Leaving a for...of early triggers the iterator close protocol: the language calls return() if the iterator has one, and a generator implements return() by resuming as if a return statement appeared at the paused yield, so finally runs. That is what makes generators safe for holding file handles and subscriptions.

Common mistakes

  • Iterating the same generator object twice and getting nothing the second time.
  • Passing a value to the first next() and expecting it to arrive somewhere.
  • Forgetting that [...infiniteGenerator()] hangs the tab. Always bound it with take.
  • Calling a generator function and expecting the body to run. Nothing runs until the first next().
  • Writing yield inside a nested arrow or a forEach callback. yield only works in the generator body itself.
  • Assuming Symbol.iterator must return a generator. Any object with next() qualifies.
  • Using an iterator where you need length or random access. Materialise it with [...it] first.

Takeaways

  • Iterable means "has [Symbol.iterator]()". Iterator means "has next()". Generators are both.
  • Nothing in a generator body runs until the first next(), and it pauses again at each yield.
  • yield is an expression: next(v) decides what it evaluates to, which makes generators two-way.
  • yield* forwards a whole iterable and evaluates to its return value.
  • Leaving a for...of early calls return() on the iterator, which is how finally and cleanup get to run.
  • Laziness is the point: infinite sequences and early exit cost nothing you did not consume.
  • Since ES2025 iterator helpers give you lazy map, filter and take without hand-written generators.