Before we begin, I'm going to assume that you are fairly familiar with generators. The Exploring JS book has a wonderful chapter that I would highly recommend reading if you need to brush up on how generators are implemented in JavaScript. Additionally, I'm not going to make much commentary on what kinds of problems generators are useful for. Personally, I find that the use cases where generators are helpful are pretty small, but they do exist! But without further ado, let's jump in!
Basics of Generator Typing
As of TypeScript 3.6, the Generator
type now has the signature as follows:
interface Generator<T, TReturn, TNext>
This value, or generator object, is what's returned by a given generator function.
In practice, what that means is that T
is the type of any yielded values by the generator, TReturn
is what the generator returns, and TNext
is what the generator will receive from the yield statement. For example:
// Has an inferred type of (): Generator<string, boolean, number>
function* generator() {
const a: number = yield 'string';
console.log(a);
return false;
}
Using generators
Let's create a function that we can pass our generator into, and do something with it. I highly recommend opening up a TypeScript playground. I'll provide a link to the full example a little later when we're ready to run everything.
function handleGenerator(
generator: () => Generator<string, boolean, number>
) {
const generatorObject = generator();
// Note that the first time `next` is called, its parameters are ignored
let request = generatorObject.next(1);
console.log('request is ', request);
request = generatorObject.next(2);
console.log('request is now', request);
}
Here we define a function which takes as a parameter a generator function which returns a generator object. This nuance is important. A generator function returns an instantiation of a new generator object. This means we can provide it additional parameters to supplement its internal state, and we can reuse it. More on that later.
Once we obtain generatorObject
, in order to use it we have to understand the Iterator protocol. In short, using a generator object requires iterating through its execution instruction. Each call of next
will run the generator until it yields
- providing us with an IteratorResult
.
IteratorResult for Generator
An IteratorResult
is an object that has two keys - value and done.
value
will always have the type of both T
and TReturn
, from the generator's type. In this case, it's string | boolean
.
done
is a boolean that indicates whether or not the generator is exhausted.
Question: What happens when you call next
on an exhausted generator?
Execution results
Now that we have everything in place, notice that we have a few console.log
statements in there. What do you think will get logged? What will be the value of a
in our generator
function? Make a prediction, and add the following to your playground, or you can use this link.
Full code:
function* generator() {
const a: number = yield 'string';
console.log(a);
return false;
}
function handleGenerator(
generator: () => Generator<string, boolean, number>
) {
const generatorObject = generator();
// Note that the first time `next` is called, its parameters are ignored
let result = generatorObject.next(1);
console.log(result);
result = generatorObject.next(2);
console.log(result);
}
handleGenerator(generator);
Were the results what you expected?
Huh?
Perhaps unexpectedly, the value of a
is not 1
, but rather 2
. What happens to the first value we pass into next? Well, to be honest, it doesn't go anywhere because it has no where to go to. When we first invoke our generator
function, we haven't executed any of the code inside of it yet. It's not until our first call to next
, where we're passing in 1
, that the generator starts running. The path of execution looks as follows:
handleGenerator
callsgenerator
, which returnsgeneratorObject
handleGenerator
callsnext(1)
which starts and delegates to thegeneratorObject
generatorObject
begins running, andyields
back with a value of"string"
handleGenerator
logs the response and delegates back by callingnext(2)
generatorObject
receives the value of2
, logs it, and returnsfalse
handleGenerator
logs the response
Because the input of generator
is being provided when we invoke it, and we're not receiving any parameters from outside functions until we call yield
, the first call to next
for generator objects will always have its parameters ignored.
Limitations of TypeScript
Let's say that I have a generator acting as a coroutine that is responsible for receiving an action
, and depending on that action will provide a specific response
, where the types differ depending on the action requested.
enum Action {
PickNumber = 'pickNumber',
PickLetter = 'pickLetter',
}
function handleGenerator(
generator: () => Generator<
Action,
Action,
string | number
>
) {
const generatorObject = generator();
let result = generatorObject.next();
while (!result.done) {
if (result.value === Action.PickNumber) {
result = generatorObject.next(1);
} else if (result.value === Action.PickLetter) {
result = generatorObject.next('c');
}
}
}
While this is a very poor example, this same pattern is used in a lot of applications, such as redux-saga
, coroutine
, and more. I've personally found it useful to build out custom user-driven workflows that permit task pausing and re-delegation. However, typing it becomes a bit of a challenge with TypeScript.
With this construction, we can know that if our generator yields
an Action
of PickNumber
, we should get a number
. If it's PickString
, the TNext
value ought to be string
. Is there a way to build a generator such that TypeScript can correctly infer the yielded
value based on the Action
provided?
Naive approach
function* firstGenerator(): Generator<
Action,
void,
string | number
> {
const num = yield Action.PickNumber;
// ^ string | number
const str = yield Action.PickLetter;
// ^ string | number
}
Unfortunately, TypeScript can't provide it with this approach. According to the open issue on GitHub, in order for TypeScript to support this, it would need to support Higher Kinded Types - a level of abstraction above a type. While TypeScript may support them in the future, we'll have to find another way.
Generator delegation
Thankfully, some brilliant folks have found a very useful workaround - using generator delegation. Instead of using yield
directly, we can add a middleware generator that we delegate (yield*
to) to that can provide us with the correct type.
First, we're going to need some types that will allow us to generically infer to the return type based on the action we're providing.
enum Action {
PickNumber,
PickLetter,
}
type ActionType = {
[Action.PickLetter]: string;
[Action.PickNumber]: number;
};
type InferredPick<T extends Action> = Generator<
Action,
ActionType[T],
string | number
>;
Here I've added two types - ActionType
which maps a given Action
to its return type, and InferredPick
which changes the TReturn
type to the corresponding type based on the Action
provided in the generic parameters. Next, we'll need our middleware.
function* pick<T extends Action>(
action: T
): InferredPick<T> {
const result = yield action;
return result as ActionType[T];
}
Here, we're defining a type variable T
that extends the Action
enum, and we use that to provide the type for the action
parameter we're submitting. We return our new generic InferredPick
type, which hopefully should allow us to type things correctly. Note: the as ActionType[T]
cast is required to allow us to defer the type resolution until its used.
Alright, let's give it a try!
function* delegatingGenerator(): Generator<
Action,
void,
string | number
> {
const num = yield* pick(Action.PickNumber);
// ^ number
const str = yield* pick(Action.PickLetter);
// ^ string
}
It works! You can view a full example for yourself.
Summary
We've covered quite a bit in this article, including:
- The nuances between a generator function and a generator object
- How to type generators for most use cases
- How to (ab)use generator delegation to provide a higher level of type inference for coroutines.
Big thanks to the TypeScript community over at Discord for helping me with a lot of these problems. I've found them to be incredibly helpful and responsive, and lead me to a lot of the solutions that I've shared here.