JavaScript Generators


JavaScript generators are a powerful feature that allows you to handle lazy-loaded sequences of data and manage asynchronous operations more effectively. They offer an elegant way to pause and resume the execution of functions, making it easier to handle repetitive tasks, manage data pipelines, and implement complex algorithms.

What Are JavaScript Generators?

A generator is a special type of function in JavaScript that can be paused and resumed. It allows you to generate a sequence of values over time, instead of computing them all at once. This is particularly useful for handling large datasets, working with async operations, or creating infinite sequences.

How Generators Work

A generator function is defined with the function* syntax (notice the asterisk *), and it uses the yield keyword to return values one at a time. Every time the generator function is called, it returns an iterator, which you can use to retrieve the values.

The key difference between regular functions and generator functions is that generators don't run to completion immediately. Instead, they pause when they reach the yield keyword and resume when the iterator’s next() method is called.

Example of a Simple Generator Function

function* greet() {
  yield "Hello";
  yield "World";
  yield "!";
}

const generator = greet();

console.log(generator.next());  // { value: 'Hello', done: false }
console.log(generator.next());  // { value: 'World', done: false }
console.log(generator.next());  // { value: '!', done: false }
console.log(generator.next());  // { value: undefined, done: true }

Here:

  • The greet() function is a generator function.
  • Each time next() is called, it resumes execution until it reaches the next yield statement.
  • When the generator has no more values to yield, done: true is returned.

Syntax of Generator Functions

1. Generator Function Declaration

To define a generator function, use the function* syntax.

function* myGenerator() {
  // code
}

2. Yielding Values with yield

Inside the generator function, use yield to pause the execution and return a value.

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

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

3. Using next() to Resume Execution

To resume the execution of the generator function and get the next yielded value, use the next() method.

const generator = numbers();

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 }

4. Passing Values to Generators

You can also pass values into a generator function using next(). This allows you to send data back to the generator, which can be useful for more complex workflows.

Example: Passing Values to the Generator

function* processData() {
  const data1 = yield "Step 1";
  console.log("Received:", data1);
  const data2 = yield "Step 2";
  console.log("Received:", data2);
}

const generator = processData();
console.log(generator.next());  // { value: 'Step 1', done: false }
console.log(generator.next("Data for Step 1"));  // Received: Data for Step 1
                                              // { value: 'Step 2', done: false }
console.log(generator.next("Data for Step 2"));  // Received: Data for Step 2
                                              // { value: undefined, done: true }

Here:

  • We pass data into the generator using next("Data for Step 1"), which is received by the generator's yield statement.

Practical Uses of JavaScript Generators

1. Lazy Evaluation

Generators are ideal for lazy evaluation, where values are generated on demand instead of being computed all at once. This is especially useful when dealing with large datasets or infinite sequences.

Example: Generating Fibonacci Numbers

function* fibonacci() {
  let [a, b] = [0, 1];
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const gen = fibonacci();

console.log(gen.next().value);  // 0
console.log(gen.next().value);  // 1
console.log(gen.next().value);  // 1
console.log(gen.next().value);  // 2
console.log(gen.next().value);  // 3

In this example:

  • The Fibonacci sequence is generated lazily, meaning that values are only calculated when needed.

2. Implementing Infinite Sequences

Generators are particularly useful for creating infinite sequences. They can run indefinitely, producing values one at a time when requested.

Example: Infinite Sequence of Numbers

function* infiniteNumbers() {
  let i = 0;
  while (true) {
    yield i++;
  }
}

const gen = infiniteNumbers();

console.log(gen.next().value);  // 0
console.log(gen.next().value);  // 1
console.log(gen.next().value);  // 2

Here:

  • The generator produces an infinite sequence of numbers starting from 0.

3. Asynchronous Programming with Generators

Generators can be useful in managing asynchronous operations, especially when combined with Promise and async/await. They allow you to pause execution at yield and resume after a promise is resolved, which can simplify working with async code.

Example: Using Generators for Asynchronous Operations

function* fetchData() {
  const data1 = yield fetch('https://jsonplaceholder.typicode.com/posts/1').then(res => res.json());
  console.log("Data 1:", data1);
  const data2 = yield fetch('https://jsonplaceholder.typicode.com/posts/2').then(res => res.json());
  console.log("Data 2:", data2);
}

function run(generator) {
  const iterator = generator();

  function handle(result) {
    if (result.done) return;
    result.value.then(data => handle(iterator.next(data)));
  }

  handle(iterator.next());
}

run(fetchData);

Here:

  • The generator yields promises, which are resolved before the next yield statement is executed.
  • The run() function handles the asynchronous flow of the generator.

4. Stateful Iteration

Generators can maintain their internal state between yields. This is useful when you need to keep track of certain data while iterating through a sequence.

Example: Stateful Generator

function* counter() {
  let count = 0;
  while (count < 3) {
    yield count++;
  }
}

const counterGen = counter();
console.log(counterGen.next().value);  // 0
console.log(counterGen.next().value);  // 1
console.log(counterGen.next().value);  // 2
console.log(counterGen.next().value);  // undefined

In this case:

  • The generator maintains a count variable that keeps track of the current state between yield statements.

Advanced Generator Features

1. return and throw in Generators

  • You can use the return statement to return a final value from a generator and exit it early.
  • You can use throw to throw an error inside a generator.

Example: Using return in Generators

function* simpleGenerator() {
  yield 1;
  yield 2;
  return 3;  // End the generator early and return this value
}

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

Example: Using throw in Generators

function* throwExample() {
  try {
    yield 1;
    throw new Error("Something went wrong");
  } catch (e) {
    console.log(e.message);  // "Something went wrong"
  }
}

const gen = throwExample();
console.log(gen.next());  // { value: 1, done: false }
console.log(gen.throw(new Error("Something went wrong")));  // Catch the error and log it