Module 6 · Modern JavaScript & Tooling Concepts ⏱ 18 min

Iterators & Generators

By the end of this lesson you will be able to:
  • Implement the iterator protocol with Symbol.iterator to make custom objects loopable
  • Use generator functions to build lazy sequences with minimal boilerplate
  • Explain the relationship between generators, iterators, and the for...of loop

Up to now you have looped over arrays with for...of and spread them with ... . That works because JavaScript's built-in types secretly speak a common language called the iterator protocol. But what happens when you build your own data structure — a deck of cards, a range of numbers, a paginated result — and try to loop over it? Without the protocol, for...of throws a TypeError and you are back to manual indexing, which is brittle and easy to get wrong by one.

The iterator protocol is the bridge. Any object that implements it becomes loopable. It is not magic baked into arrays alone; it is a contract you can teach your own objects to fulfill. Once they do, every tool that speaks the protocol — for...of, the spread operator, Array.from, destructuring — starts working on your custom types too. You write the protocol once, and the entire ecosystem of looping tools understands your object immediately.

flowchart LR
  O["Object with<br/>Symbol.iterator"] --> I["Iterator<br/>{ next() }"]
  I --> R["{ value, done }"]
  R --> C{"done?"}
  C -- No --> I
  C -- Yes --> E["Loop ends"]
  style O fill:#e0900b,color:#fff
  style I fill:#3776ab,color:#fff
  style E fill:#1e7f3d,color:#fff
The iterator protocol: an object provides a Symbol.iterator method that returns an iterator, and the iterator's next() yields { value, done }.

The anatomy of the protocol

The protocol has three pieces. First, the object must have a method keyed by the special built-in Symbol.iterator. That method returns an iterator object. Second, the iterator must have a next() method. Third, every call to next() returns an object with two properties: value, the current item, and done, a boolean that is true when there is nothing left to give. No inheritance is required; the engine simply checks for these exact names and shapes.

When a for...of loop begins, it looks for [Symbol.iterator] on the right-hand side. It calls that method once to get the iterator, then repeatedly calls next() until done becomes true. The value from each step is assigned to your loop variable. This is why [1, 2, 3] and "abc" both work with for...of: they each provide their own iterator behind the scenes. Strings iterate over Unicode code points, arrays over indices, but the loop itself never needs to know the difference.

A hand-rolled iterator that makes a range object spreadable. Press Run.
const range = {
  from: 1,
  to: 3,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next() {
        if (current <= last) {
          return { value: current++, done: false };
        }
        return { done: true };
      }
    };
  }
};

console.log([...range]);
for (const n of range) {
  console.log(n);
}

Generators: iterators without the boilerplate

Writing [Symbol.iterator] by hand is instructive, but it is also tedious. You must manage the current state, the termination condition, and the return object manually. For most real code you will reach for a generator function instead. A generator is a special kind of function that can pause its execution, yield a value to its caller, and resume later when the caller asks for the next item. This pause-and-resume behavior makes it ideal for infinite sequences, lazy calculations, or consuming data that arrives a chunk at a time.

You declare one with the function* keyword. Inside, yield produces a value and freezes the function mid-sentence. The next time the iterator's next() method is called, the function thaws and continues from exactly where it left off, with all its local variables intact. This gives you the power of a hand-rolled iterator with almost no boilerplate.

flowchart TD
  G["function* gen()"] --> Y1["yield 1"]
  Y1 --> P1["Paused"]
  P1 --> N1["next() called"]
  N1 --> Y2["yield 2"]
  Y2 --> P2["Paused"]
  P2 --> N2["next() called"]
  N2 --> D["return { done: true }"]
  style G fill:#e0900b,color:#fff
  style P1 fill:#3776ab,color:#fff
  style P2 fill:#3776ab,color:#fff
  style D fill:#1e7f3d,color:#fff
A generator pauses at each yield and resumes on the next call to next().
A generator function yields a lazy sequence. Press Run.
function* countUp(start, end) {
  let n = start;
  while (n <= end) {
    yield n;
    n++;
  }
}

console.log([...countUp(1, 3)]);
const it = countUp(5, 5);
console.log(it.next().value);
console.log(it.next().done);

Delegation with yield*

A generator can also delegate to another generator with yield*. This is useful when a complex iterable is built from simpler ones. The outer generator yields every item from the inner generator before continuing. The rest of the language does not need to know there are two generators involved; it sees a single, flat sequence. You can use this to split a large data pipeline into small, testable stages.

Because generators are iterators, you can use them anywhere the protocol is expected: in for...of loops, with the spread operator, or by passing them to Array.from. The function* keyword is doing nothing more than building the { next() } object for you, but the result behaves identically to a hand-written one. That is why generator-based code composes so cleanly with the rest of the language.

yield* flattens an inner generator into the outer sequence.
function* inner() { yield 'a'; yield 'b'; }
function* outer() { yield 'start'; yield* inner(); yield 'end'; }
console.log([...outer()]);
flowchart LR
  F["for...of loop"] --> S["obj[Symbol.iterator]()"]
  S --> N["iterator.next()"]
  N --> V["value assigned<br/>to loop variable"]
  V --> N
  N -.->|"done: true"| E["Loop ends"]
  style F fill:#e0900b,color:#fff
  style S fill:#3776ab,color:#fff
  style E fill:#1e7f3d,color:#fff
for...of under the hood: it calls Symbol.iterator, then next(), until done is true.
Exercise

What does this print? Calling next() drives the generator forward one step at a time.

function* gen() { yield 1; yield 2; }
const g = gen();
console.log(g.next().value);
console.log(g.next().done);
Exercise

Write a generator function range(start, end) that yields every integer from start up to and including end.

function* range(start, end) {
  // yield each integer from start to end inclusive
}
Exercise

This function is meant to be a generator that yields the numbers 1, 2, and 3. But it uses a normal function and return, so only the first value comes back and the function exits. Fix it so all three numbers are yielded.

function count123() {
  return 1;
  return 2;
  return 3;
}
Exercise

What does this print? Remember that after the last yield, one more next() returns done: true with no value.

function* g() { yield 'a'; }
const it = g();
console.log(it.next().value);
console.log(it.next().done);
Exercise

Write createCountdown(n) that returns an object which is iterable using the iterator protocol (not a generator). When looped over with for...of, it should count down from n to 1. For example, [...createCountdown(3)] produces [3, 2, 1].

function createCountdown(n) {
  // return an iterable object with [Symbol.iterator]
}

Recap

  • The iterator protocol requires [Symbol.iterator]() to return an object with a next() method.
  • next() must return { value, done }; the loop ends when done is true.
  • Generator functions (function*) automatically implement the protocol.
  • yield pauses execution and produces a value; the generator resumes on the next next() call.
  • yield* delegates to another iterable, flattening it into the current sequence.
  • Calling a generator function returns an iterator object, not a value — drive it with a loop or spread.
  • Any object that speaks the protocol works with for...of, spread, Array.from, and destructuring.

Next you will meet Symbols — unique keys that let you attach hidden properties to objects without the risk of name collisions.

Checkpoint quiz

What does a generator function return when first called?

Which object shape must an iterator's next() method return?

Go deeper — technical resources