JavaScript Iterators and Generators

Introduction to Iterators

Iterators are a powerful JavaScript feature that provide a standard way to produce a sequence of values. They are objects that implement the iterator protocol by having a next() method that returns an object with two properties:

  • value: The current value in the sequence
  • done: A boolean indicating whether the sequence has been exhausted

The Iterator Protocol

An object is an iterator when it implements the next() method with the following semantics:


// Basic iterator implementation
const myIterator = {
  data: [1, 2, 3, 4, 5],
  currentIndex: 0,
  
  // The next() method returns an object with two properties
  next() {
    if (this.currentIndex < this.data.length) {
      return {
        value: this.data[this.currentIndex++], // Return current value and increment index
        done: false                            // Sequence not finished
      };
    } else {
      return { done: true };                   // Sequence is finished
    }
  }
};

// Using the iterator
console.log(myIterator.next()); // { value: 1, done: false }
console.log(myIterator.next()); // { value: 2, done: false }
console.log(myIterator.next()); // { value: 3, done: false }
console.log(myIterator.next()); // { value: 4, done: false }
console.log(myIterator.next()); // { value: 5, done: false }
console.log(myIterator.next()); // { done: true }
                        
Key Insight: Iterators provide a way to access elements in a collection one at a time, without exposing the underlying representation of the collection.

Iterable Objects

An iterable is an object that implements the iterable protocol by having a method with the key Symbol.iterator that returns an iterator.

Built-in Iterables

Many built-in types in JavaScript are iterable:

  • Arrays
  • Strings
  • Maps
  • Sets
  • DOM collections (NodeList, HTMLCollection)
  • arguments object

Creating Custom Iterables


// Custom iterable object
const myIterable = {
  data: [10, 20, 30, 40, 50],
  
  // Symbol.iterator method returns an iterator
  [Symbol.iterator]() {
    let index = 0;
    const data = this.data;
    
    return {
      next() {
        if (index < data.length) {
          return {
            value: data[index++],
            done: false
          };
        } else {
          return { done: true };
        }
      }
    };
  }
};

// Using the iterable with for...of loop
for (const item of myIterable) {
  console.log(item); // 10, 20, 30, 40, 50
}

// Using the spread operator
const values = [...myIterable]; // [10, 20, 30, 40, 50]

// Using destructuring
const [first, second, ...rest] = myIterable; // first=10, second=20, rest=[30,40,50]
                        

Iteration Protocols in Action

The following example demonstrates a practical use case for custom iterables - a range function similar to Python's:


// Range function that returns an iterable
function range(start, end, step = 1) {
  return {
    [Symbol.iterator]() {
      let current = start;
      return {
        next() {
          if ((step > 0 && current <= end) || (step < 0 && current >= end)) {
            const value = current;
            current += step;
            return { value, done: false };
          } else {
            return { done: true };
          }
        }
      };
    }
  };
}

// Using the range iterable
for (const num of range(1, 10, 2)) {
  console.log(num); // 1, 3, 5, 7, 9
}

// Count down
for (const num of range(10, 1, -2)) {
  console.log(num); // 10, 8, 6, 4, 2
}
                        
Pro Tip: Iterables and iterators are the foundation for many modern JavaScript features like for...of loops, spread syntax, destructuring, and more.

Introduction to Generators

Generators are a special type of function that can be paused and resumed, making them perfect for creating iterators with less boilerplate code. They are defined using a function with an asterisk (function*) and use the yield keyword to pause execution and return values.

Basic Generator Syntax


// Simple generator function
function* simpleGenerator() {
  yield 1;
  yield 2;
  yield 3;
}

// Using the generator
const generator = simpleGenerator();

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

// Generators are iterables
for (const value of simpleGenerator()) {
  console.log(value); // 1, 2, 3
}
                        

Generator Features

Generators have several powerful features that make them unique:

  • Lazy Evaluation: Values are computed on-demand, not all at once
  • Memory Efficiency: Great for working with large or infinite sequences
  • Two-way Communication: Can pass values back into the generator with next(value)
  • Control Flow: Makes complex asynchronous code more readable

Implementing the Range Example with Generators


                             0) {
    while (current <= end) {
      yield current;
      current += step;
    }
  } else {
    while (current >= end) {
      yield current;
      current += step;
    }
  }
}

// Using the range generator
for (const num of rangeGenerator(1, 10, 2)) {
  console.log(num); // 1, 3, 5, 7, 9
}]]>
                        
Key Insight: Generators provide a clean, elegant way to create iterators without having to implement the full iterator protocol manually.

Advanced Generator Techniques

Two-way Communication

Generators allow two-way communication: not only can they yield values to the caller, but they can also receive values from the caller using next(value).


function* twoWayCommunication() {
  // First yield doesn't receive any value
  const a = yield 'First question?';
  console.log('Received:', a);
  
  const b = yield 'Second question?';
  console.log('Received:', b);
  
  return 'All done!';
}

const gen = twoWayCommunication();

// First next() call starts the generator
console.log(gen.next());           // { value: 'First question?', done: false }

// Second next() call sends 'Answer 1' to the generator
console.log(gen.next('Answer 1')); // Logs "Received: Answer 1"
                                   // Returns { value: 'Second question?', done: false }

// Third next() call sends 'Answer 2' to the generator
console.log(gen.next('Answer 2')); // Logs "Received: Answer 2"
                                   // Returns { value: 'All done!', done: true }
                        

Error Handling in Generators

Generators support error handling with try/catch blocks and the throw() method:


function* errorHandlingGenerator() {
  try {
    yield 1;
    yield 2;
    yield 3;
  } catch (error) {
    console.log('Caught error:', error);
    yield 'Error recovered';
  }
  yield 4;
}

const gen = errorHandlingGenerator();

console.log(gen.next());  // { value: 1, done: false }
console.log(gen.next());  // { value: 2, done: false }

// Throw an error into the generator
console.log(gen.throw('Something went wrong!'));  
// Logs "Caught error: Something went wrong!"
// Returns { value: 'Error recovered', done: false }

console.log(gen.next());  // { value: 4, done: false }
console.log(gen.next());  // { value: undefined, done: true }
                        

Generator Delegation with yield*

You can delegate to other generators using the yield* expression:


function* generateNumbers() {
  yield 1;
  yield 2;
}

function* generateLetters() {
  yield 'a';
  yield 'b';
}

function* generateAll() {
  yield* generateNumbers();
  yield* generateLetters();
  yield* [3, 4]; // Can also delegate to any iterable
}

for (const value of generateAll()) {
  console.log(value); // 1, 2, 'a', 'b', 3, 4
}
                        

Practical Applications

Infinite Sequences

Generators are perfect for creating infinite sequences because they compute values on-demand:


// Infinite Fibonacci sequence generator
function* fibonacci() {
  let [prev, curr] = [0, 1];
  while (true) {
    yield curr;
    [prev, curr] = [curr, prev + curr];
  }
}

// Take first 10 Fibonacci numbers
const fib = fibonacci();
for (let i = 0; i < 10; i++) {
  console.log(fib.next().value); // 1, 1, 2, 3, 5, 8, 13, 21, 34, 55
}
                        

Data Processing Pipelines

Generators can be used to create data processing pipelines:


// Generator pipeline for processing data
function* numbers() {
  yield* [1, 2, 3, 4, 5];
}

function* double(iterable) {
  for (const value of iterable) {
    yield value * 2;
  }
}

function* filter(predicate, iterable) {
  for (const value of iterable) {
    if (predicate(value)) {
      yield value;
    }
  }
}

// Create a pipeline: numbers -> double -> filter even numbers
const pipeline = filter(
  x => x % 2 === 0,
  double(numbers())
);

// Process the data
for (const value of pipeline) {
  console.log(value); // 4, 8
}
                        

Async Operations with Generators

Before async/await, generators were used for handling asynchronous operations:


// Simple generator-based async runner
function runGenerator(generatorFunc) {
  const generator = generatorFunc();
  
  function handle(result) {
    if (result.done) return Promise.resolve(result.value);
    
    return Promise.resolve(result.value)
      .then(res => handle(generator.next(res)))
      .catch(err => handle(generator.throw(err)));
  }
  
  return handle(generator.next());
}

// Using the runner with a generator function
runGenerator(function* () {
  try {
    // Simulating async operations
    const users = yield fetch('https://jsonplaceholder.typicode.com/users');
    const userData = yield users.json();
    console.log('Users:', userData);
    
    const posts = yield fetch('https://jsonplaceholder.typicode.com/posts');
    const postData = yield posts.json();
    console.log('Posts:', postData);
    
    return 'All data loaded';
  } catch (error) {
    console.error('Error:', error);
  }
}).then(result => console.log(result));
                        
Note: While generators can be used for async operations, modern JavaScript typically uses async/await for this purpose. The example above is for educational purposes to show how generators can control flow.

Performance Considerations

Memory Efficiency

One of the biggest advantages of generators is memory efficiency when working with large data sets:


// Memory-intensive approach (creates a large array in memory)
function getLargeArray(size) {
  const result = [];
  for (let i = 0; i < size; i++) {
    result.push(i);
  }
  return result;
}

// Memory-efficient approach (generates values on demand)
function* getLargeGenerator(size) {
  for (let i = 0; i < size; i++) {
    yield i;
  }
}

// Using a large array (stores all values in memory at once)
// const largeArray = getLargeArray(1000000); // Uses ~8MB of memory
// largeArray.forEach(num => {
//   if (num % 1000000 === 0) console.log(num);
// });

// Using a generator (computes values on demand)
// const largeGen = getLargeGenerator(1000000); // Minimal memory usage
// for (const num of largeGen) {
//   if (num % 1000000 === 0) console.log(num);
// }
                        

Generator Overhead

While generators are memory-efficient, they do have some performance overhead:

  • Each yield involves suspending and resuming execution
  • For simple operations on small data sets, regular functions might be faster
  • The benefits of generators increase with larger data sets or more complex operations
Best Practice: Use generators when you need to work with large sequences, infinite sequences, or when you want to simplify complex iteration logic. For small, performance-critical operations, traditional approaches might be more appropriate.

Browser Support and Compatibility

Iterators and generators have excellent support in modern browsers:

  • Chrome: Full support since version 39
  • Firefox: Full support since version 26
  • Safari: Full support since version 10
  • Edge: Full support since version 13

For older browsers, you can use transpilers like Babel to convert generators to ES5-compatible code.