Skip to content

Generator: infer the type of yield expression based on yielded value #32523

Closed
@Retsam

Description

@Retsam

Search Terms

generator, iterator, yield, type inference, redux-saga, coroutine, co, ember-concurrency

Suggestion

There are a number of JS patterns that require a generator to be able to infer the type of a yield statement based on the value that was yielded from the generator in order to be type-safe.

Since #2983 was something of a "catch-all" issue for generators, now that it has closed (🙌) there isn't a specific issue that tracks this potential improvement, as far as I can tell. (A number of previous issues on this topic were closed in favor of that issue, e.g. #26959, #10148)

Use Cases

Use Case 1 - coroutines

A coroutine is essentially async/await implemented via generators, rather than through special language syntax. [1] There exist a number of implementations, such as Bluebird.coroutine, the co library, and similar concepts such as ember-concurrency

In all cases, it's pretty much syntactically identical to async/await, except using function* instead of async function and yield instead of await:

type User = {name: string};
declare const getUserId: () => Promise<string>;
declare const getUser: (id: string) => Promise<User>;

const getUsername = coroutine(function*() {
    // Since a `Promise<string>` was yielded, type of `id` should be string.
    const id = yield getUserId(); 
    // Since a `Promise<User>` was yielded, type of `user` should be `User`
    const user = yield getUser(id);
    return user.name;
});

Currently, there really isn't a better approach than explicit type annotations on every yield statement, which is completely unverified by the type-checker:

const getUsername = coroutine(function*() {
    const id: string = yield getUserId(); 
    const user: User = yield getUser(id);
    return user.name;
});

The most correct we can be right now, with TS3.6 would be to express the generator type as Generator<Promise<string> | Promise<User>, string, string | User> - but even that would require every the result of every yield to be discriminated between string and User.

It's clearly not possible to know what the type of a yield expression is just by looking at the generator function, but ideally the types for coroutine could express the relationship between the value yielded and the resulting expression type: which is something like type ResumedValueType<Yielded> = Yielded extends Promise<infer T> ? T : Yielded.

Use Case 2 - redux-saga

redux-saga is a (fairly popular) middleware for handling asynchronous effects in a redux app. Sagas are written as generator functions, which can yield specific effect objects, and the resulting expression type (and the runtime behavior) depend on the value yielded.

For example, the call effect can be used analogously to the coroutine examples above: the generator will call the passed function, which may be asynchronous, and return the resulting value:

function* fetchUser(action: {payload: {userId: string}}) {
   try {
      const user = yield call(getUser, action.payload.userId);
      yield put({type: "USER_FETCH_SUCCEEDED", user: user});
   } catch (e) {
      yield put({type: "USER_FETCH_FAILED", message: e.message});
   }
}

The relationship between the yielded value and the resulting value is more complex as there are a lot of possible effects that could be yielded, but the resulting type could still hypothetically be determined based on the value that was yielded.

This above code is likely even a bit tricker than the coroutine as the saga generators aren't generally wrapped in a function that could hypothetically be used to infer the yield relationship: but if it were possible to solve the coroutine case, a wrapping function for the purposes of TS could likely be introduced:

// SagaGenerator would be a type that somehow expressed the relationship 
// between the yielded values and the resulting yield expression types.
function saga<TIn, TReturn>(generator: SagaGenerator<TIn, TReturn>) { return generator; }

const fetchUser = saga(function*() {
   //...
});

I imagine this would be a difficult issue to tackle, but it could open up a lot of really expressive patterns with full type-safety if it can be handled. In any case, thanks for all the hard work on making TS awesome!


[1] As an aside, to the tangential question of "why would you use a coroutine instead of just using async/await?". One common reason is cancellation - Bluebird promises can be cancelled, and the cancellation can propagate backwards up the promise chain, (allowing resources to be disposed or API requests to be aborted or for polling to stop, etc), which doesn't work if there's a native async/await layer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Design LimitationConstraints of the existing architecture prevent this from being fixed

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions