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.

Activity

yortus

yortus commented on Jul 24, 2019

@yortus
Contributor

For reference, there's some discussion and ideas about this in #30790 (e.g. #30790 (comment)).

rbuckton

rbuckton commented on Jul 31, 2019

@rbuckton
Contributor

This is not currently possible to model in the type system, as is mentioned in #30790, as it requires some mechanism to define a relationship between a TYield and a TNext to be preserved between separate invocations.

treybrisbane

treybrisbane commented on Jul 31, 2019

@treybrisbane

Two additional use cases are:

  • Emulation of Haskell's do syntax for sequencing monadic actions (example)
  • Emulation of Algebraic Effects (explanation, and example)
falsandtru

falsandtru commented on Aug 6, 2019

@falsandtru
Contributor
added
Design LimitationConstraints of the existing architecture prevent this from being fixed
and removed
Needs InvestigationThis issue needs a team member to investigate its status.
on Aug 27, 2019
removed this from the TypeScript 3.7.0 milestone on Aug 27, 2019
itsMapleLeaf

itsMapleLeaf commented on Aug 29, 2019

@itsMapleLeaf
type TestAsyncGen = {
  next<T>(value: T): IteratorResult<Promise<T>, void>
}

function* test(): TestAsyncGen {
  const value = yield Promise.resolve(123)
}

I just tried this, currently it errors on Promise.resolve(123)

Type 'Promise<number>' is not assignable to type 'Promise<T>'.
  Type 'number' is not assignable to type 'T'.
    'number' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{}'.

Would it be possible to support this use case this way?

For the record, this currently works and is properly typechecked:

type TestNumberStringGen = {
  next(value: number): IteratorResult<string, number>
}

function* test(): TestNumberStringGen {
  const num = yield "123"
  return num
}
rbuckton

rbuckton commented on Aug 29, 2019

@rbuckton
Contributor

Would it be possible to support this use case this way?

No, it is not possible. Your definition of TestAsyncGen doesn't actually reflect what's happening in the generator. The definition next<T>(value: T): IteratorResult<Promise<T>, void> implies that calling next will with a value of T will produce a Promise<T>. However, what is actually happening is that you want the Promise<T> of one call to next to inform the T of a subsequent call to next:

function start(gen) {
  const result = gen.next();
  return result.done
    ? Promise.resolve(result.value)
    : Promise.resolve(result.value).then(value => resume(gen, value));
}
function resume(gen, value) {
  const result = gen.next(value);
  return result.done
    ? Promise.resolve(result.value)
    : Promise.resolve(result.value).then(value => resume(gen, value));
}

The type system has no way to handle that today.

What you would need is something like #32695 (comment), where calling a method can evolve the this type:

interface Start<R> {
  next<T>(): IteratorResult<Promise<T>, Promise<R>> & asserts this is Resume<T, R>;
}
interface Resume<T, R> {
  next<U>(value: T): IteratorResult<Promise<U>, Promise<R>> & asserts this is Resume<U, R>;
}

If such a feature were to make it into the language, then you would be able to inform the T of a subsequent call to next based on the return type of a preceding call to next.

26 remaining items

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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

        Participants

        @SamB@pqnet@spion@evelant@arcanis

        Issue actions

          Generator: infer the type of yield expression based on yielded value · Issue #32523 · microsoft/TypeScript