Javascript Generators and Coroutines

A Primer on Generators and Coroutines

June 26, 2024

Welcome! If you’re here, you must be curious about the world of generators in JavaScript and possibly about coroutines as well.

This post provides a quick primer on generator features available in JavaScript and demonstrates how to use these features to implement coroutines.

Generators

The most basic generator looks like a function.

function* myFirstGenerator() {
  return 42;
}

This function returns an iterator when invoked. That iterator has a single value, which is extracted by calling next.

Invoking the generator function always returns a Generator instance, which is a subtype of Iterator.

An iterator is a special collection container that has a mechanism for requesting the next item and determining when all items have been exhausted.

const fnEval = fnLookAlike();
const {value, done} = fnEval.next();

By invoking next on the generator, we get a value with two properties: done, a boolean indicating if we’ve reached a return statement or exhausted all yield statements, and value, which contains the last value yielded or returned by the generator.

Generators are useful for lazily creating values. We can have a generator with an infinite loop to generate an infinite sequence, allowing consumers to take as many or as few values as they’d like.

function* infiniteRandomNumbers() {
  while(true) yield 42;
}

// Take as many values as we want from the generator.
const LIMIT = 42 * 42;
const sink = [];
const source = infiniteRandomNumbers();
while (sink.length < LIMIT) {
   const { value } = souce.next();
   sink.push(value);
}

Generators are excellent for generating items, but they require a bit of extra ceremony that makes loops and other abstractions preferable in most cases.

Generators shine when the value is expensive to compute, we prefer laziness, or there is an unknown upper bound.

One unique feature of generators is the ability to pass in values at every step of the iterator. The yield operator passes a value back to the consumer and defers control until next is invoked again. When ready to resume, next can accept an optional parameter, similar to a return value from a function call.

function* pingPong() {
  while(true) {
    const pong = yield 'ping ->';
    console.log(pong); // logs: '<- pong'
  }
}

const game = pingPong();
let current = game.next() 
for (let i = 0; i < 10; i++) {
  console.log(current.value); // logs: "ping ->"
  current = game.next('<- pong') 
}

This provides a powerful tool to vary the next value our generator can potentially generate, creating a two-way communication channel with our generator.

Generators can also defer control to another coroutine with the yield* operator, similar to how functions can call other functions.

function* inner(index: number) {
  yield `Inner${index} - A`;  
  yield `Inner${index} - B`;  
}

function outer() {
  yield 'Start';
  yield* inner(1);
  yield* inner(2);
  yield 'End';
}

This allows us to capture values from any descendant generators within the same stream of values.

const evaluate = outer();
let current = evaluate.next();
while (!current.done) {
  console.log(current.value);
  current = evaluate.next();
}

This type of evaluation allows us to inject values into our stream. Conceptually, we can have a generator that defers to other generators based on its input parameters.

The yield* operator also allows us to capture the return value of the nested generator we invoke. Return values are captured by the parent generator, while yielded values are surfaced to the consumer.

By pairing these features with a utility function to run our generators to completion, we can implement coroutines.

Here’s an example of a utility function:

function coroutine(generator: Generator) {
  const evaluation = generator();
  let current = evaluation.next();
  while (!current.done) {
     current = evaluation.next();
  }
  return current.value; // Final return
}

// Use coroutine utility function:
const final = coroutine(function*() {
  yield 1;
  return 2;
})

This utility function runs the generator to completion and returns the final value.

The coroutine function in this post is simple, but along with other features like yield* and the ability to pass back values when calling next, we can build something that injects dependencies based on the current yielded value. For example, if we yield 'GetMagicNumber', we could inject a magic number into any generator that uses our utility function.

const Secrets = {
  GetMagicNumber: 42,
  GetOtherMagicNumber: 2
};

function coroutine(generator: any) {
  const evaluation = generator();
  let current = evaluation.next();
  let inject = undefined;
  while (!current.done) {
     inject = Secrets[current.value] || 0;
     current = evaluation.next(inject);
  }
  return current.value;
}


const final = coroutine(function*() {  
  // Randomly yield `GetMagicNumber.`
  const magicNumber = yield 'GetMagicNumber';
  const otherMagicNumber = yield 'GetOtherMagicNumber';
  return magicNumber * otherMagicNumber;  // Secrets.GetMagicNumber * Secrets.GetOtherMagicNumber
});

We can pair this idea with a map of dependencies to implement a service locator or dependency injection-like mechanism.

In this example, yielded values act as signals to a coroutine runtime, and the return value of the generator is passed through as the return value once all yield-able values have been exhausted by the utility function.

Conclusion

I hope that this post has served to provide a perspective to help understand features of generators and how they can be pieced together to form coroutines.

Resources