TypeScript and Generators

Starting in TypeScript 3.6, tsc’s previously “whatever brah” mentality surrounding generators was finally replaced with accurate types.

This is nice, because it gets us some extra guarantees at compile time, in particular when dealing with some asynchronous code. But the types of generators can be tricky to grok at first, and in order to understand those types, we must first conceptualize what a generator is.

Firing Whenever You Quit

A generator is a function. Good start, right?

Normally in Ecmaville, we expect that when calling a function, it will execute in its entirety, and return zero or one values at the end. If you want another value, you have to call the function again, resulting in another complete execution of the function.

By contrast, a generator function can exit mid-execution by “yielding” a value. Viewed from the caller, this is similar to a return. The difference is that instead of a naked value (like 42 or "Hello world!"), the caller will be given a generator object that contains a couple of properties of interest:

  1. A function next() which can be used to re-invoke the function
  2. A value property which can be used to access the yielded value.

Assuming we leverage next() to re-invoke, our generator function can then yield yet another value, even of a different type, and repeat this over and over again until it finally returns as normal.

Generators are functions that can be exited and later re-entered. Their context (variable bindings) will be saved across re-entrances.

Quoth MDN

To summarize what we know about these freaky little things:

  1. Generator functions can be called like any normal function
  2. They can yield a value, exiting their execution while saving the context
  3. They can then be re-entered at the same position and either yield another value, or else return.

Use-cases for generators are outside the scope of this post, and as this article points out, can be hard to come by. For what its worth, I ended up digging into them because of Fluture’s go notation.

Here at Doki Doki Driven, however, what we really care about are types. And Pop Tarts. But mostly types. So let us dive into the murky pond of generator types and see what mysterious plant life adheres to us while we’re down there.

Basic function type

Let’s begin with the type of something simple as a baseline. We’ll define a function f that takes a string an argument, and returns the result of appending the string “!” to the original string

function f (str: string) {
    return str + "!"
}

According to TS, our type here is:

function f(str: string): string

This makes sense, as we have a single string argument, and our return type is also a string.

Now let’s do some generatin’:

function* g(str: string) {
    yield str.length > 0
    return str + "!"
}

Here, we’re using yield to first give back a value indicating whether or not the string has a length > 0 (is not empty string). After that, we return the same str + "!" concatenation that we did in f. Let’s see what that did to our type:

function g(str: string): Generator<boolean, string, unknown>

There’s a lot more going on now, isn’t there? We can see the Generator generic, which takes three type parameters. The first one is clearly the boolean value from our initial yield statement, so we can assume the first type parameter represents the type of yielded values:

Generator<TYielded, ??, ??>

Next, we’ve got a string, so that’s likely the type of our returned value:

Generator<TYielded, TReturn, ??>

The third value is coming back unknown, so we have to mess with this function some more to figure out what that represents.

Re-entry Permission

Earlier we referenced the MDN description of generators as functions that can be exited (via yield) and then re-entered. And while our functions above have a single string parameter, we did not consider the possibility of passing in another parameter when we re-enter the function.

Let’s say we want to add some extra oomph to our returned string, and specify the number of exclamation points we’ll concatenate. And, for god knows what reason, we’d like to do this after the initial yield statement. Well, we’d end up with a function like this:

function* h(str: string) {
    const bang = "!"
    const numBangs: number = yield str.length > 0
    return str + bang.repeat(numBangs)
}

This syntax is a little bonkers the first time you see it, because the const numBangs assignment is actually doing two mostly unrelated things:

  1. It’s yielding a boolean value that indicates whether or not str has a length
  2. It’s setting up a new entry point for the function, so it can be re-entered. This re-entry point takes a single argument of type number and assigns that value to the variable numBangs.

Now our function type looks like this:

function h(str: string): Generator<boolean, string, number>

Ha! The unknown is gone and replaced by a number. This must be the type of the re-entry arguments:

Generator<TYielded, TReturn, TArgsForReEntry>

Indeed, if we confirm TypeScript’s documentation (which we could have just done from the jump, rather than all this excessive manual labor), it defines the Generator generic as:

Generator<T = unknown, TReturn = any, TNext = unknown>

Where It All Falls Apart

Somewhere up above this I’m pretty sure I mentioned that generators can yield more than once, thus resulting in more than one yielded value. If our first type variable T in the Generator generic is supposed to be the type of the yielded value, what happens to it if we yield more than once?

function* r (str: string) {
    const bang = "!"
    const numBangs: number = yield str.length > 0
    yield bang
    return str + bang.repeat(numBangs)
}

Now we’re yielding a boolean and a string literal, and our type reflects this by way of a union type:

function r(str: string): Generator<boolean | "!", string, number>

You’re probably assuming, like I was, that you could do the same thing for the TNext by also accepting a different type for the re-entry argument. Here, we’ll have one yield that wants a number, and a second that wants a boolean:

function* s (str: string) {
    const bang = "!"
    const numBangs: number = yield str.length > 0
    const someBool: boolean = yield bang
    return str + bang.repeat(numBangs)
}

You would be, again much like myself, very wrong about being able to do this. While we might expect a type like the below:

function s(str: string): Generator<boolean | "!", string, number | boolean>

We end up with this instead:

function s(str: string): Generator<boolean | "!", string, never>

Never?! You are probably aware that never is TypeScript’s bottom type, in the sense that it represents a value that can never occur. So what gives? Well, if we have a look at the PR that added improved typing for generators, we get an explanation:

We will infer the next type as an intersection of the contextual types of each yield expression.

rbuckton@github

Here, TypeScript is attempting to take the intersection of a number and a boolean. Thinking of the possible values for those types, we can see that there are no numbers which are booleans, and no booleans which are numbers, so the intersection is empty. This is what gives us the never.

If we need to get around this, currently our only option is to provide an explicit signature and open up the type with an any:

function* s(str: string): Generator<boolean | "tomato", number, any> {
  ...
}

As a side note, while it is beyond my understanding, there is some discussion about what would be required to provide better typing support for this in the same PR.

Closing

While we may speculate that there will be improved support for these types in a later version of TypeScript, for the time being I hope the above has given you some solid insight into what the type checker is doing with generators. If anything remains unclear, drop me a comment anytime.

The code in this post was most recently tested against TypeScript v3.9.2

Leave a comment

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: