Iterators, Generators & Iterator Helpers

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

You already use iterators every time you write for...of, spread an array, or destructure a pair of values. The surprise is that those features do not belong to arrays. They belong to a small language agreement that any object can follow.

The protocol behind for...of

An iterable is a value that can produce values one at a time, and an iterator is the object that does the producing with a next() method. Think of an iterator like a ticket window: each next() call hands you the next ticket and tells you whether the line is finished.

The iteration protocol is JavaScript's agreement for that ticket window: call a special method, get an iterator, then keep calling next() until it returns done: true.

const moods = ["fresh", "focused", "tired"];
const moodIterator = moods[Symbol.iterator]();
 
console.log(moodIterator.next());
console.log(moodIterator.next());
console.log(moodIterator.next());
console.log(moodIterator.next());

{ value: 'fresh', done: false }, then { value: 'focused', done: false }, then { value: 'tired', done: false }, then { value: undefined, done: true }.

Symbol.iterator is a symbol key, a special property name that cannot accidentally collide with normal string keys; the full Symbol story is coming in lesson 13.2. For now, read [Symbol.iterator]() as "give me your iterator."

That is what for...of hides for you:

const playlist = ["intro beat", "study mix", "victory song"];
 
for (const track of playlist) {
  console.log("Now playing:", track);
}

Now playing: intro beat, Now playing: study mix, Now playing: victory song.

Spread and destructuring use the same protocol. They ask the value for an iterator, pull values, and stop when they have enough:

const highScores = new Set(["Mina", "Rae", "Sol"]);
const [champion, runnerUp] = highScores;
 
console.log(champion);
console.log(runnerUp);
console.log([...highScores]);

Mina, Rae, then ['Mina', 'Rae', 'Sol'].

Making your own object iterable

An object becomes iterable when it has a [Symbol.iterator]() method that returns an iterator. For the basic protocol, the iterator only needs a next() method that returns objects shaped like { value, done }.

Here is a countdown object that works with for...of and spread:

const countdown = {
  start: 3,
  [Symbol.iterator]() {
    let nextNumber = this.start;
 
    return {
      next() {
        if (nextNumber === 0) {
          return { value: undefined, done: true };
        }
 
        const value = nextNumber;
        nextNumber -= 1;
        return { value, done: false };
      },
    };
  },
};
 
console.log([...countdown]);
 
for (const number of countdown) {
  console.log("Workout starts in", number);
}

[3, 2, 1], then Workout starts in 3, Workout starts in 2, Workout starts in 1.

The important detail is that [Symbol.iterator]() creates a fresh nextNumber each time. Spread gets its own countdown. The for...of loop gets another. If the same iterator object were reused forever, one loop could accidentally drain values for the next loop.

Generators pause with yield

A generator is a special function that creates an iterator for you, and yield is the keyword that sends out one value and pauses the function at that line. A generator is like a worker with a bookmark: it stops mid-task, remembers exactly where it paused, and continues from that spot on the next request.

You write a generator with function*:

function* scoreCountdown(player, start) {
  let score = start;
 
  while (score > 0) {
    yield `${player}: ${score}`;
    score -= 1;
  }
 
  return `${player}: done`;
}
 
const scores = scoreCountdown("Mina", 3);
 
console.log(scores.next());
console.log(scores.next());
console.log(scores.next());
console.log(scores.next());

{ value: 'Mina: 3', done: false }, { value: 'Mina: 2', done: false }, { value: 'Mina: 1', done: false }, then { value: 'Mina: done', done: true }.

Most of the time, you do not call next() by hand. You hand the generator result to for...of, spread, destructuring, or another iterator tool:

function* badgeNames(players) {
  for (const player of players) {
    yield player.toUpperCase();
  }
}
 
for (const badge of badgeNames(["Mina", "Rae", "Sol"])) {
  console.log("Badge:", badge);
}

Badge: MINA, Badge: RAE, Badge: SOL.

One small rule: for...of reads yielded values, not the generator's final return value. A generator's return is mostly useful when you call next() manually or build low-level iterator tools.

Try it — pull a stream of highlights

Run this once, then change take(2) to take(3). Notice that the generator does not create a giant list first. Values move through the pipeline only as far as the helper chain asks for them.

javascript — live consolereal engine
⌘/Ctrl + Enter to run

Lazy sequences and iterator helpers

A lazy sequence produces the next value only when something asks for it. It is like a snack counter that makes the next sandwich when the ticket appears, not a warehouse that makes every possible sandwich at dawn. That is the generator's real power. You can describe a long sequence, even an endless one, without building the whole thing in memory.

Iterator helpers are ES2025 methods on helper-capable iterators, such as .map(), .filter(), and .take(), that create new lazy iterators. They feel like array methods, but they work before you have an array.

Plain custom iterators only promise next(). Generator iterators and built-in iterators already have helper methods; a plain object iterator can use helpers after Iterator.from(...) wraps it:

const snackIterator = {
  current: 0,
  next() {
    this.current += 1;
 
    if (this.current > 3) {
      return { value: undefined, done: true };
    }
 
    return { value: this.current, done: false };
  },
};
 
const snacks = Iterator.from(snackIterator)
  .map((number) => `snack ${number}`)
  .toArray();
 
console.log(snacks);

['snack 1', 'snack 2', 'snack 3'].

function* priceDrops(startCents, dropCents) {
  let cents = startCents;
 
  while (cents > 0) {
    yield cents;
    cents -= dropCents;
  }
}
 
const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});
 
const saleLabels = priceDrops(1200, 175)
  .filter((cents) => cents >= 500)
  .map((cents) => usd.format(cents / 100))
  .take(4)
  .toArray();
 
console.log(saleLabels);

['$12.00', '$10.25', '$8.50', '$6.75'].

That chain reads left to right: start with price drops, keep prices at least five dollars, format them, take four, then finally turn the result into an array. Nothing before .toArray() needs to store every possible price drop.

Arrays are still the right tool when you already have a real list and want random access, length, or repeated passes. Iterators are better when values arrive over time, cost money to compute, or only make sense one at a time. That is why lesson 12.5's for await...of preview should feel familiar now: async iteration is the same "next value, then next value" idea, with waiting added between steps.

Checkpoint

Answer all three to mark this lesson complete

The next lesson pulls on the special key you saw today: Symbol.iterator. Symbols are how JavaScript lets ordinary objects plug into language features without stepping on normal property names.

+50 XP on completion