JavaScript Iterators: Symbol.iterator Protocol

JavaScript's iteration protocol is the backbone of modern language features like `for...of` loops, the spread operator, and array destructuring. At its core, an iterator is simply an object that...

Key Insights

  • The Symbol.iterator protocol is the foundation for for...of loops, spread operators, and destructuring—understanding it unlocks powerful iteration patterns in JavaScript
  • Implementing custom iterators gives you fine-grained control over iteration behavior, enabling lazy evaluation, infinite sequences, and stateful traversal that generators can’t always provide
  • Iterator exhaustion is a common pitfall: once consumed, an iterator won’t reset automatically, requiring careful design decisions about reusability versus memory efficiency

Introduction to Iterators

JavaScript’s iteration protocol is the backbone of modern language features like for...of loops, the spread operator, and array destructuring. At its core, an iterator is simply an object that knows how to access items from a collection one at a time, while keeping track of its current position.

Every time you loop through an array or spread a string into individual characters, you’re using iterators—even if you don’t realize it. Understanding the Symbol.iterator protocol transforms these “magic” language features into predictable, controllable mechanisms you can leverage in your own code.

const numbers = [1, 2, 3, 4, 5];

// This familiar syntax uses iterators under the hood
for (const num of numbers) {
  console.log(num);
}

// So does the spread operator
const copy = [...numbers];

// And destructuring
const [first, second, ...rest] = numbers;

The iteration protocol defines a standard way for objects to define or customize their iteration behavior. This standardization is what makes all these features work seamlessly across different data types.

Understanding Symbol.iterator

The Symbol.iterator is a well-known symbol that specifies the default iterator for an object. When JavaScript encounters a for...of loop or spread operator, it looks for this symbol on the object and calls it to get an iterator.

The iterator protocol has a simple contract: the Symbol.iterator method must return an iterator object, which must have a next() method. This next() method returns an object with two properties:

  • value: the current item in the iteration
  • done: a boolean indicating whether the iteration is complete

Let’s peek under the hood of how built-in iterables work:

const str = "hello";

// Get the iterator function
const iteratorFunction = str[Symbol.iterator];

// Call it to get an iterator object
const iterator = iteratorFunction.call(str);

// Manually iterate
console.log(iterator.next()); // { value: 'h', done: false }
console.log(iterator.next()); // { value: 'e', done: false }
console.log(iterator.next()); // { value: 'l', done: false }
console.log(iterator.next()); // { value: 'l', done: false }
console.log(iterator.next()); // { value: 'o', done: false }
console.log(iterator.next()); // { value: undefined, done: true }

This manual approach reveals exactly what for...of does automatically: it calls next() repeatedly until done is true, yielding each value along the way.

Creating Custom Iterators

The real power of understanding iterators comes from implementing them on your own objects. Let’s build a Range object that iterates through numbers from a start to an end value:

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

  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;

    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
}

const range = new Range(1, 5);

for (const num of range) {
  console.log(num); // 1, 2, 3, 4, 5
}

// Works with spread operator too
const numbers = [...new Range(10, 15)];
console.log(numbers); // [10, 11, 12, 13, 14, 15]

The Symbol.iterator method creates and returns a new iterator object each time it’s called. The iterator maintains its own state (the current variable) through closure, tracking progress through the sequence independently.

Practical Iterator Patterns

Iterators shine in scenarios where you need lazy evaluation or want to represent potentially infinite sequences. Unlike arrays, iterators don’t require all values to exist in memory simultaneously.

Here’s a Fibonacci sequence generator that can theoretically produce infinite values:

class Fibonacci {
  constructor(limit = Infinity) {
    this.limit = limit;
  }

  [Symbol.iterator]() {
    let prev = 0;
    let curr = 1;
    let count = 0;
    const limit = this.limit;

    return {
      next() {
        if (count++ >= limit) {
          return { done: true };
        }

        const value = prev;
        [prev, curr] = [curr, prev + curr];
        
        return { value, done: false };
      }
    };
  }
}

// Get first 10 Fibonacci numbers
const fib = new Fibonacci(10);
console.log([...fib]); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

For real-world applications, consider a paginated data iterator that fetches data on demand:

class PaginatedAPI {
  constructor(endpoint, pageSize = 20) {
    this.endpoint = endpoint;
    this.pageSize = pageSize;
  }

  [Symbol.iterator]() {
    let page = 1;
    let buffer = [];
    let done = false;
    const endpoint = this.endpoint;
    const pageSize = this.pageSize;

    return {
      async next() {
        // Return buffered items first
        if (buffer.length > 0) {
          return { value: buffer.shift(), done: false };
        }

        // If we've exhausted all pages, we're done
        if (done) {
          return { done: true };
        }

        // Fetch next page
        try {
          const response = await fetch(
            `${endpoint}?page=${page}&limit=${pageSize}`
          );
          const data = await response.json();
          
          if (data.items.length === 0) {
            done = true;
            return { done: true };
          }

          buffer = data.items;
          page++;
          
          return { value: buffer.shift(), done: false };
        } catch (error) {
          done = true;
          throw error;
        }
      }
    };
  }
}

// Usage with for-await-of
const api = new PaginatedAPI('/api/users');
for await (const user of api) {
  console.log(user);
  // Processes users one at a time, fetching pages as needed
}

This pattern is memory-efficient and allows you to work with large datasets without loading everything upfront.

Iterators vs Generators

While manual iterators give you complete control, generator functions provide a more concise syntax for many use cases. Generators automatically implement the iterator protocol and handle state management for you.

Here’s our Range iterator rewritten as a generator:

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

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

// Behavior is identical to the manual implementation
const range = new Range(1, 5);
console.log([...range]); // [1, 2, 3, 4, 5]

The generator version is more readable and less error-prone. However, manual iterators are preferable when:

  • You need async iteration with complex state management
  • You want explicit control over the iterator lifecycle
  • You’re optimizing for performance in hot code paths
  • You need to maintain compatibility with environments that don’t support generators

For most cases, reach for generators first. Use manual iterators when you need the extra control or have specific performance requirements.

Common Pitfalls and Best Practices

The most common mistake with iterators is assuming they’re reusable by default. Once an iterator is exhausted, it stays exhausted:

class SingleUseRange {
  constructor(start, end) {
    this.start = start;
    this.end = end;
    this.current = start;
  }

  [Symbol.iterator]() {
    return this; // Returns itself as the iterator
  }

  next() {
    if (this.current <= this.end) {
      return { value: this.current++, done: false };
    }
    return { done: true };
  }
}

const range = new SingleUseRange(1, 3);

console.log([...range]); // [1, 2, 3]
console.log([...range]); // [] - exhausted!

The fix is to create a new iterator object each time Symbol.iterator is called, as we did in earlier examples:

class ReusableRange {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;

    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        }
        return { done: true };
      }
    };
  }
}

const range = new ReusableRange(1, 3);

console.log([...range]); // [1, 2, 3]
console.log([...range]); // [1, 2, 3] - works every time

Always handle errors gracefully in iterators, especially when dealing with I/O or external resources. Set the done flag to true when errors occur to prevent infinite loops:

[Symbol.iterator]() {
  return {
    next() {
      try {
        // Your iteration logic
      } catch (error) {
        console.error('Iterator error:', error);
        return { done: true }; // Terminate on error
      }
    }
  };
}

The Symbol.iterator protocol is a powerful primitive that enables elegant, memory-efficient solutions to complex iteration problems. Master it, and you’ll write more expressive, performant JavaScript code.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.