TypeScript - Poor man's async await using generators

This post is part of an ongoing series on TypeScript. In it, I try to explain in a detailed way different situations I’ve faced while building relatively big React applications using TypeScript, and moving them from JavaScript. Here’s a list of other posts I’ve written about TypeScript:


Other articles on the web explain this idea[1] [2]. This is my take on it using TypeScript. I find it interesting because it lets me explore how generator functions are typed. Also, some libraries have implemented their versions of this before async await existed. The two I could find are bluebird’s coroutine and co.

The goal of this post is:

Why, though? Because it’s fun. Please use a polyfill in your projects!

By the end, we should have something that looks like this:

asynq(function* () {
  const a = yield Promise.resolve('a')
  const b = yield Promise.resolve('b')
  const c = yield 'c'

  return [a, b, c]
}).then((array) => {
  expect(array).toEqual(['a', 'b', 'c'])
})

Instead of async and await, the stars of the show are going to be our own function asynq and a JavaScript keyword yield, specific to generator functions. If generator functions and iterators are new concepts to you, I suggest you read some documentation[3] [4] first.

If you’d like to see the code before you read the full post, here it is on GitHub.

High-level overview #

Our function asynq will need to do two things:

  1. Initialize the generator that it gets passed.
  2. Iterate over the initialized generator in such a way that our a, b and c variables receive the value from the resolved promise, just like it would happen if we could use await.

TypeScript types generator functions simply as () => Generator, so this is what we’re using for the single argument.

function asynq(func: () => Generator) {
  const iterator = func()

  // Iterate over `iterator` here.
}

Now the question is how to iterate.

Iterating over the generator #

A first impulse could be to use a for..of loop because it’s made for iterators.

function asynq(func: () => Generator) {
  const iterable = func()

  for (const value of iterable) {
    console.log(value) // Can't call `next` on this.
  }
}

This loop calls iterable.next() with no arguments automatically for us. This is not what we want! We want to pass our own values to next. We need fine-grained control over what we pass to each next call, and a for..of loop would be making that decision for us by passing undefined to next until the generator is done.

Why do we need fine-grained control over what we pass to each next call? I’ll steal the example from this StackOverflow answer:

const [variable] = yield [expression]

Regardless of what [expression] evaluates to, yield will assign to [variable] the value passed to next. This means whatever we pass to next will be what the user of asynq receives in their variables.

To do this, we can use recursion. We want to call our recursive helper until the generator is done. I think awwait is a good name for it since awaiting is more or less what it does.

function asynq(func: () => Generator) {
  const iterable = func()

  function awwait(result: IteratorResult<unknown>) {
    if (result.done) {
      // This is our base case.
    }

    // We still need to figure out the recursive call.
  }

  return awwait(iterable.next())
}

Our awwait helper needs to serve three purposes:

From IteratorResults to Promises #

Generators have two methods that are of interest two us because they map very well to promises:

Since we now know awwait needs to map from IteratorResult to Promise, we can easily figure out the base case: the generator is done and we can return a resolved promise.

function awwait(result: IteratorResult<unknown>) {
  if (result.done) {
    return Promise.resolve(result.value)
  }
}

Our recursive call must now implement the mapping between IteratorResult and Promise we talked about earlier:

function asynq(func: () => Generator) {
  const iterable = func()

  function awwait(result: IteratorResult<unknown>): Promise<unknown> {
    if (result.done) {
      return Promise.resolve(result.value)
    }

    return (
      result.value
        // Things worked out fine, we can call `awwait`.
        .then((value) => awwait(iterable.next(value)))
        // Oops. Calling `throw` will cause an exception,
        // so there's no need to continue the recursion.
        .catch((error) => iterable.throw(error))
    )
  }

  return awwait(iterable.next())
}

With this, the example from the beginning of this post almost works. Here’s how far we’ve gotten:

asynq(function* () {
  const a = yield Promise.resolve('a')
  const b = yield Promise.resolve('b')

  return [a, b]
}).then((array) => {
  expect(array).toEqual(['a', 'b'])
})

Error handling is supported to thanks to our catch-to-throw mapping:

await asynq(function* () {
  try {
    yield Promise.resolve('a')
    yield Promise.reject('b')
  } catch (error) {
    assert.deepStrictEqual(error, 'b')
  }
})

Some improvements #

This implementation already works for “asynq” functions that yield promises. It’s cool, but we can do better.

Supporting non-Promise values #

asynq won’t work if a user tries to yield a value that’s not a promise. We’ll add an early return to our awwait helper to solve this:

if (!(result.value instanceof Promise)) {
  return awwait(iterable.next(result.value))
}

Note that this is a naïve way to check whether something is a promise since it will only work for native promises and not for shims, but that is beside the point.

Supporting type inference #

Type inference isn’t working at all. In const a = yield Promise.resolve('a'), a should be string, but it’s any. What’s wrong?

Ultimately, what’s happening is that there are some limitations on the TypeScript language that we need to sidestep somehow. Support for generator functions is one of the longest-running topics for TypeScript.

So, how can we sidestep this limitation? We need to use type assertion. The Generator<T, TReturn, TNext> built-in type takes three type arguments:

We know that our variables const a and const b receive the value passed to next from asynq, so this is the type argument that’s of interest to us.

asynq(function* (): Generator<Promise<string>, string[], string> {
  const a = yield Promise.resolve('a') // a is now `string`
  const b = yield Promise.resolve('b') // b is now `string`

  return [a, b]
})

This introduces some difficulties. If a were a number, then we would have to pass string | number as our type argument, and thus both a and b would be string | number. There’s also the elephant in the room: we’re asserting these types, so we could pass whatever we wanted and the checker wouldn’t complain.

I’m looking forward to being able to update this post with a solution that’s fully type-safe. The discussion in the relevant issue sounds very promising.


Related documentation and links:

  1. Async-Await ≈ Generators + Promises
  2. Implementing Async And Await With Generators
  3. MDN web docs for generator functions
  4. MDN web docs for iterators
  5. How does Generator.next() processes its parameter?
  6. Progress on type inference for generator functions
  7. The code in this post on GitHub