<< Home

Chapter 13: Iteration and Generators

Iteration is a cornerstone of JavaScript's design, allowing you to process data sequentially and perform actions on each element in a collection. From arrays and maps to custom objects, JavaScript provides a rich set of mechanisms for traversing data structures efficiently and expressively. Understanding these mechanisms is key to writing modern, elegant code.

This chapter explores how iteration works under the hood, beginning with the iterable and iterator protocols that define the language’s iteration behaviour. You will learn how constructs such as for...of and the spread operator rely on these protocols to access values one at a time. The chapter then introduces generator functions (special functions that can pause and resume their execution) along with the yield keyword and its practical applications. Finally, you will see how asynchronous generators extend these concepts for working with streams of data that arrive over time.

💡 Once you understand how iteration protocols and generators work together, you can create powerful custom sequences, data pipelines, and asynchronous workflows with concise, readable code.

Iterable and iterator protocols

Iteration in JavaScript is built on two formal agreements known as the iterable and iterator protocols. These protocols define how a data structure can be stepped through, one element at a time, in a predictable and standard way. They are not special syntax, but conventions that objects can implement so that language features such as for...of and the spread operator know how to access their contents.

An iterable is any object that has a method keyed by Symbol.iterator. When this method is called, it must return an iterator object. That iterator provides a next() method, which returns an object of the form { value, done }. Each call to next() yields the next value in the sequence until done becomes true, signalling that iteration has finished.

const iterable = {
  data: [10, 20, 30],
  [Symbol.iterator]() {
    let i = 0;
    return {
      next: () => {
        if (i < this.data.length) {
          return { value: this.data[i++], done: false };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
};

for (const num of iterable) {
  console.log(num);
}

This example defines a custom iterable object. Its Symbol.iterator method creates an iterator that returns numbers from the internal data array one by one. Each iteration retrieves the next value until there are no more, at which point the loop stops automatically.

💡 All built-in sequence types such as arrays, strings, maps, and sets already implement the iterable protocol. That is why they work naturally with for...of loops and the spread operator.

The distinction between iterables and iterators makes iteration flexible. A single iterable can produce multiple independent iterators, each tracking its own progress through the data. This allows separate loops to traverse the same collection without interfering with one another.

⚠️ Do not confuse for...of with for...in. The former retrieves values from iterables using these protocols, while the latter enumerates property names of objects.

for...of and spread

The for...of loop is JavaScript’s most direct way to consume iterable objects. It automatically calls the object’s Symbol.iterator method to obtain an iterator, then repeatedly calls its next() method until done becomes true. Each value returned is assigned to the loop variable in sequence.

const colors = ["red", "green", "blue"];

for (const color of colors) {
  console.log(color);
}

In this example, the array’s built-in iterator supplies each element in order. You do not need to handle Symbol.iterator or next() manually as the loop takes care of that behind the scenes. The for...of construct works with any object that implements the iterable protocol, including strings, maps, sets, and even custom iterables.

const message = "Hi!";
for (const ch of message) {
  console.log(ch);
}

Here, the loop walks through each character of the string, because strings are also iterable.

💡 The for...of loop reads values, not keys. To iterate over property names of an object, use for...in or Object.keys().

The spread operator (...) also uses the iterable protocol. When applied to an iterable, it expands the sequence into individual values. This makes it useful for copying, concatenating, or unpacking data from collections.

const nums = [1, 2, 3];
const copy = [...nums];
console.log(copy);      // [1, 2, 3]

const extended = [...nums, 4, 5];
console.log(extended);  // [1, 2, 3, 4, 5]

The spread operator can appear in array literals, function calls, and destructuring assignments. It provides a concise, readable way to work with iterables without manually looping through them.

function sum(a, b, c) {
  return a + b + c;
}

const values = [10, 20, 30];
console.log(sum(...values));  // 60
⚠️ The spread operator only works with iterable values. Attempting to spread a non-iterable object (for example, a plain object literal) causes a TypeError. Use { ...obj } for object spreading instead, which is a distinct feature.

Generator functions and yield

Generators provide a special way to write functions that can pause and resume their execution. Instead of returning a single value and finishing immediately, a generator can produce multiple values over time, each separated by a yield expression. This makes them ideal for representing sequences, streams, and lazy computations.

A generator function is declared using an asterisk (*) after the function keyword. When called, it does not run its body immediately but instead returns an iterator-like object, known as a generator object. Each time the generator’s next() method is called, the function runs until it reaches a yield statement, pauses, and returns the yielded value.

function* numbers() {
  yield 1;
  yield 2;
  yield 3;
}

const gen = numbers();
console.log(gen.next());  // { value: 1, done: false }
console.log(gen.next());  // { value: 2, done: false }
console.log(gen.next());  // { value: 3, done: false }
console.log(gen.next());  // { value: undefined, done: true }

Each call to next() resumes execution from where the generator last paused. When the function reaches its end (or a return statement), the iteration finishes with done: true.

💡 A generator automatically implements both the iterable and iterator protocols, meaning you can use it directly in a for...of loop without calling next() yourself.
for (const n of numbers()) {
  console.log(n);
}

You can also send values into a generator by passing arguments to next(). Each yield expression acts like a two-way channel: it pauses the function and returns a value outward, then resumes with whatever value is passed back in.

function* greeter() {
  const name = yield "What is your name?";
  yield `Hello, ${name}!`;
}

const g = greeter();
console.log(g.next().value);         // "What is your name?"
console.log(g.next("Robin").value);  // "Hello, Robin!"

This feature allows generators to maintain internal state across multiple calls, effectively turning them into resumable coroutines that exchange data with the caller.

⚠️ Unlike regular functions, generators cannot be constructed with new. They are designed to produce sequences, not objects.

By combining yield with control logic, you can model complex iterative processes cleanly and lazily, generating values only when needed rather than computing everything upfront.

Generator use cases

Generators are more than just an alternative way to write loops. Because they can pause, resume, and maintain internal state, they enable elegant solutions to problems that would otherwise require complex callbacks, iterators, or manual bookkeeping. Their ability to produce values lazily (only when requested) makes them efficient for handling large or infinite data sequences.

Producing sequences lazily

Generators are perfect for generating sequences one step at a time. Instead of constructing an entire array in memory, a generator yields values as needed. This is particularly useful for potentially unbounded data, such as number ranges or stream-like structures.

function* countUpTo(limit) {
  let n = 1;
  while (n <= limit) {
    yield n++;
  }
}

for (const n of countUpTo(5)) {
  console.log(n);
}

Each call to next() retrieves the next number without keeping the whole range in memory. The iteration stops automatically when the generator’s done flag becomes true.

💡 Because generators are iterable, they can feed directly into constructs like [...countUpTo(5)] or Array.from() to collect their results when needed.

Stateful iteration

Generators can also encapsulate and preserve internal state across iterations. This makes them suitable for tasks such as producing unique IDs, repeating patterns, or managing timed events.

function* idGenerator() {
  let id = 0;
  while (true) {
    yield ++id;
  }
}

const ids = idGenerator();
console.log(ids.next().value);  // 1
console.log(ids.next().value);  // 2
console.log(ids.next().value);  // 3

Unlike regular functions, which lose their local variables after returning, generators remember where they left off. Each subsequent call continues execution with the same local state intact.

Controlling asynchronous flow

Before the introduction of async/await, generators were commonly used to simplify asynchronous code. By combining them with promises and a small runner function, developers could write asynchronous logic in a synchronous-looking style.

function* fetchSequence() {
  const a = yield fetch("/data1.json").then(r => r.json());
  const b = yield fetch("/data2.json").then(r => r.json());
  return [a, b];
}

Each yield pauses the generator until the corresponding promise resolves. Although async functions now offer a cleaner approach, this technique remains useful for understanding how asynchronous control flow evolved in JavaScript.

⚠️ Generators themselves are synchronous. They do not automatically wait for promises, so you must use additional logic to resume them when an asynchronous operation completes.

Custom iteration logic

Generators can define custom iteration rules for objects, allowing you to control how they are traversed by for...of or the spread operator. By implementing [Symbol.iterator] as a generator, you can make objects iterable with minimal code.

const team = {
  members: ["Alex", "Robin", "Sam"],
  *[Symbol.iterator]() {
    for (const name of this.members) {
      yield name;
    }
  }
};

for (const person of team) {
  console.log(person);
}

In this case, the generator acts as the object’s iterator, providing a concise and readable way to expose its contents for iteration.

💡 Implementing [Symbol.iterator] as a generator function is one of the cleanest ways to make custom data structures work naturally with for...of, spread, and destructuring.

Async generators

Asynchronous generators extend the concept of regular generators to support asynchronous data streams. They allow you to yield promises and consume results as they resolve, which is ideal when working with data that arrives over time, such as network responses, file streams, or real-time events.

An asynchronous generator is declared by adding both async and * to the function keyword. It returns an asynchronous iterator, which is an object that has an async next() method returning a promise for each { value, done } pair. This allows iteration to be paused until each asynchronous step completes.

async function* fetchData() {
  const urls = ["/data1.json", "/data2.json", "/data3.json"];
  for (const url of urls) {
    const response = await fetch(url);
    const data = await response.json();
    yield data;
  }
}

To consume an asynchronous generator, use a for await...of loop. This form of loop automatically waits for each promise to resolve before moving to the next iteration.

for await (const item of fetchData()) {
  console.log(item);
}

Each yield in an async generator can represent an asynchronous operation. This design pattern simplifies working with streams of data that may not all be available at once, allowing you to process each item as soon as it arrives rather than waiting for the full set.

💡 Async generators combine the simplicity of for...of iteration with the power of asynchronous control flow. They provide a clean alternative to manual promise chaining or nested then() calls when handling sequential asynchronous tasks.

Manual consumption of async generators

You can also consume async generators manually by calling their next() method, which returns a promise that resolves to a result object. This gives fine-grained control over when each step executes.

const gen = fetchData();

(async () => {
  console.log(await gen.next());  // first item
  console.log(await gen.next());  // second item
  console.log(await gen.next());  // third item
})();

Manual iteration is useful when you need to coordinate generator progress with other asynchronous operations or manage flow explicitly.

Error handling

Because async generators operate asynchronously, errors within them are raised as rejected promises. You can handle these errors using try...catch blocks inside the generator body or around the for await...of loop that consumes it.

async function* getData() {
  try {
    yield await fetch("/ok.json").then(r => r.json());
    yield await fetch("/missing.json").then(r => r.json());
  } catch (err) {
    console.error("Fetch failed:", err);
  }
}

for await (const item of getData()) {
  console.log(item);
}
⚠️ Each await inside an async generator pauses execution until its promise resolves. Use caution when combining many awaits inside long-running loops, as this can delay results unnecessarily if operations could run concurrently.

Async generators elegantly merge iteration and asynchronous programming. They allow you to stream results progressively, responding to data as it arrives rather than waiting for entire collections to load into memory.