JavaScript Generators: Unleashing the Power of Iterables

Discover the power of JavaScript Generators, a feature that can transform your coding skills. This guide dives into the creation, usage, and advanced applications of Generators, providing practical examples and insights. Uncover this hidden gem and elevate your JavaScript programming journey.

Aug 19, 2023 - 20:25
 37
JavaScript Generators: Unleashing the Power of Iterables
JavaScript Generators: Unleashing the Power of Iterables

JavaScript Generators might not be the most popular feature of JavaScript, but they sure pack a punch!

These special functions, which can pause and resume their execution, are like hidden gems in the JavaScript landscape.

They might not be in every developer's toolbox, but once you understand their power, you'll wonder how you ever coded without them.

Generators can make your code cleaner and easier to understand, especially when you're dealing with complex tasks or asynchronous operations. But despite their benefits, they're often overlooked and underused in the JavaScript community.

So, why not give generators a chance?

Anjana Vakil talked about the awesome power of JS Generators in her JSConf presentation. This article is all about the examples she shared.

If you don't feel like reading, I highly recommend watching her presentation instead. But if you do want to read, don't forget to check out her presentation too.

Understanding JavaScript Generators

JavaScript Generators are special functions that can pause execution and resume at any time, creating a powerful way to manage synchronous and asynchronous tasks in a more readable and maintainable way.

They were introduced in ECMAScript 2015 and have since become a fundamental part of the language.

A generator function is defined with a function keyword followed by an asterisk.

When called, it does not execute its code immediately. Instead, it returns a special type of iterator, called a generator.

Here's a simple example of a generator function:

function* simpleGenerator() {
    yield 'first yield';
    yield 'second yield';
    yield 'third yield';
}

In this example, the simpleGenerator function is a generator function, as indicated by the asterisk (*) after the function keyword.

The yield keyword is used to pause and resume the generator function. Each yield keyword represents a stage of the generator's execution.

Using Generators

To use a generator, you first have to initialize it. This is done by calling the generator function, which returns a generator object:

let gen = simpleGenerator();

The returned generator object has a next() method, which resumes the generator function until the next yield statement is reached.

The next() method returns an object with two properties: value and done.

console.log(gen.next()); // { value: 'first yield', done: false }
console.log(gen.next()); // { value: 'second yield', done: false }
console.log(gen.next()); // { value: 'third yield', done: false }
console.log(gen.next()); // { value: undefined, done: true }

The value property contains the value yielded by the yield statement, and done is a boolean indicating whether the generator function has been fully completed.

Leveraging Generators as Iterables

Generators can be used to create complex iterable objects.

For instance, consider a scenario where you need an object representing a deck of cards.

Instead of manually enumerating all 52 card values, you can create a generator function that computes all permutations of the different suits and numbers, yielding each one by one.

function* deckGenerator() {
  const suits = ['♠', '♣', '♥', '♦'];
  const ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];

  for (let s = 0; s < suits.length; s++) {
    for (let r = 0; r < ranks.length; r++) {
      yield ranks[r] + suits[s];
    }
  }
}

const deck = deckGenerator();
console.log(deck.next().value); // "2♠"
console.log(deck.next().value); // "3♠"

In this example, we're creating a generator function deckGenerator that represents a deck of cards.

The generator function uses two yield statements to produce all possible combinations of suits and ranks in a deck of cards.

When we call deck.next().value, the generator function resumes, returning the next card in the deck.

This makes it easy to iterate over the deck of cards without having to generate all possible combinations upfront.

What about if we want to show all 52 cards? ♠️

console.log([...deckGenerator()]) // Generate all possible variants

// Result: (52) ["2♠", "3♠", "4♠", "5♠", "6♠", "7♠",...]

This is one of the best examples of how using js generators simplifies devs' life!

Generators and Asynchronous Operations

With ES7, we got async versions of generators and iterators.

This allows us to handle asynchronous operations with generators. For instance, you can create an async generator function to load paginated data from an API.

async function* asyncGenerator() {
  let i = 0;

  while (i < 3) {
    await new Promise(resolve => setTimeout(resolve, 1000));
    yield i++;
  }
}

const asyncGen = asyncGenerator();
console.log(await asyncGen.next()); // { value: 0, done: false }
console.log(await asyncGen.next()); // { value: 1, done: false }

Here, we're using an async generator function asyncGenerator to simulate loading paginated data from an API.

The yield statement is used in conjunction with await to pause the generator function until the Promise resolves.

This allows us to handle asynchronous operations in a synchronous manner, making the code easier to read and understand.

When we call asyncGen.next(), it returns a Promise that resolves to the next piece of data.

Generators as State Machines

Generators can also function as state machines due to their ability to remember their state.

For example, you can create a generator function that represents a bank account.

function* bankAccount() {
  let balance = 0;

  while (true) {
    const deposit = yield;
    balance += deposit;
  }
}

const account = bankAccount();
account.next(); // initialize the generator
account.next(100); // deposit 100
account.next(50); // deposit 50

In this example, we're using a generator function bankAccount to represent a bank account.

The generator function uses a yield statement inside a while (true) loop, allowing it to run indefinitely.

Each time we call account.next(deposit), we're depositing money into the account.

The generator function remembers its state (the account balance), making it an effective way to represent a state machine.

Advanced Generator Usage

Generators can also be used to handle asynchronous tasks in a synchronous manner.

This is done by combining generators with Promises.

This combination allows us to write asynchronous code as if it were synchronous, making it much easier to understand and maintain.

function* asyncGenerator() {
    let data = yield new Promise(resolve => setTimeout(() => resolve('async data'), 1000));
    console.log(data);
}

let gen = asyncGenerator();
let promise = gen.next().value;
promise.then(data => gen.next(data));

In this example, the asyncGenerator function yields a Promise that resolves after 1 second.

The next() method is then called on the generator, which returns the Promise.

Once the Promise resolves, the next() method is called again with the resolved value, which is then logged to the console.

Conclusion

Generators are a powerful feature of JavaScript that can help us manage asynchronous operations, implement custom iterable objects, and handle complex control flows.

They can yield control and regain control, functioning as co-routines, passing information back and forth, and doing co-op multitasking.

Watch Anjana Vakil's JSConf presentation for an in-depth understanding of the Power of JS Generators. Her presentation provides comprehensive insights and explanations that will greatly enhance your knowledge in this area.

Remember, practice is the key to mastering generators, like any programming concept. So, don't hesitate to experiment with generators in your own code and explore the many possibilities they offer.