Understanding the JavaScript Iterator Protocol

This article provides a comprehensive overview of the JavaScript iterator protocol, explaining how JavaScript standardizes the process of looping over data collections. You will learn the difference between the iterable and iterator protocols, how the next() method manages iteration state, and how to implement custom iterators from scratch using standard JavaScript syntax and generator functions.


What Is the Iterator Protocol?

In JavaScript, the iterator protocol defines a standard way to produce a sequence of values, either finite or infinite.

An object is an iterator when it implements a next() method. The next() method takes zero or one argument and must return an object with two specific properties:

// Example shape of an iterator's result object
{ value: 42, done: false }
{ value: undefined, done: true }

The Iterable Protocol

While the iterator protocol defines how values are retrieved sequentially, the iterable protocol defines how an object makes itself iterable.

An object is iterable if it defines a method with the key [Symbol.iterator]. This method must return an object that adheres to the iterator protocol (an iterator).

Built-in iterables in JavaScript include: - Array - String - Map - Set - TypedArray

Built-in language constructs that consume iterables include the for...of loop, the spread operator (...), Array.from(), and destructuring assignment.


How to Implement a Custom Iterator

To make a custom object both iterable and compatible with JavaScript iteration constructs, implement both the iterable and iterator protocols.

Example: Custom Range Iterator

Below is an implementation of a Range object that steps through numbers from a start value to an end value:

class Range {
  constructor(start, end, step = 1) {
    this.start = start;
    this.end = end;
    this.step = step;
  }

  // The Iterable Protocol: define [Symbol.iterator]
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;
    const step = this.step;

    // The Iterator Protocol: return an object with a next() method
    return {
      next() {
        if (current <= end) {
          const result = { value: current, done: false };
          current += step;
          return result;
        }
        return { value: undefined, done: true };
      }
    };
  }
}

// Consuming the custom iterable with for...of
const numbers = new Range(1, 10, 2);

for (const num of numbers) {
  console.log(num); // Outputs: 1, 3, 5, 7, 9
}

// Consuming using the spread operator
console.log([...numbers]); // Outputs: [1, 3, 5, 7, 9]

Manual Consumption with next()

You can also consume an iterator manually without using for...of loops:

const range = new Range(1, 3);
const iterator = range[Symbol.iterator]();

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

Simplifying Implementation with Generators

Writing custom iterator objects manually requires keeping track of internal state using closures or properties. Generator functions (function*) automatically implement the iterator and iterable protocols, drastically simplifying the code.

class GeneratorRange {
  constructor(start, end, step = 1) {
    this.start = start;
    this.end = end;
    this.step = step;
  }

  *[Symbol.iterator]() {
    for (let current = this.start; current <= this.end; current += this.step) {
      yield current;
    }
  }
}

const genNumbers = new GeneratorRange(5, 15, 5);

for (const val of genNumbers) {
  console.log(val); // Outputs: 5, 10, 15
}

When using yield, the JavaScript engine automatically constructs the { value, done } objects and manages the execution state of the function.