Open arcanis opened 3 years ago
Why is this better than using : Generator<..., ..., T>
? That can be enforced at call sites too:
Your motivating example could be rewritten to type run
as something like run: (f: (props) => Generator<...>) => void
and the type would be inferred inside the anonymous function*
as well.
@nmain in your demo it's not possible to receive a type dependent on the yielded type. This can be done when using yield delegates (yield *
) but not with yield
alone which is used to create "effects" system (like redux-saga, koa etc)
I see, yes, that is useful. Unfortunately your proposal needs a call thunk to do that. For instance, if my generator runner communicates back a string when yielded a number, and a number when yielded a string, there's no clean way to express that:
function* foo() {
// not so pretty
var a = yield (5 as number & YieldOverride<string>);
var b = yield ("foo" as string & YieldOverride<number>);
}
Exactly, which defers the responsibility of providing the types to the user (at every callsite!), which is what this proposal attempts to avoids. The YieldOverride primitive would be interpreted by TypeScript as "when it goes through a yield expression, this value must resolve to T, regardless what the generator thinks".
The following would thus work as expected: TypeScript playground.
That is right but also - maybe not a super common use case. Thunks are usually pretty common for those kinds of patterns.
However, this is a valid criticism and maybe an alternative approach could be figured out. For instance, we could "dispatch" to the injected type using conditional types like this:
type Cond<T> = T extends string ? number : T extends number ? string : never;
function* foo(): YieldOverride<Cond> {
var a: string = yield 5;
var b: number = yield "foo";
}
@Andarist I think this is right direction. However I'd suggest to keep all 3 params of generator (T, TReturn and TNext). The only addition needed here is to allow TNext
to be a resolver type for T
:
type InferResolve<Yielded> =
Yielded extends CallEffect ?
CallEffectResolve<Yielded> :
Yielded extends SelectEffect ?
SelectEffectResolve<Yielded> :
never;
type ReduxSaga = Generator<
CallEffect | SelectEffect,
void,
InferResolve // ts complains that should be parametrized.
>;
function* betterFoo(): ReduxSaga {
const value = (yield sampleCallEffect); // should be "string" one day
}
Imho if Generator
generic third param could be (take yielded value and infer resolved type), that would be enough.
One use case for this is Mobx's flow, which uses generator functions in which yield foo
has a runtime type equal to await foo
.
@andrewbranch I've implemented this feature (here), do you mind if I open a PR?
Current usage:
type SelectEffect = {type: `select`};
declare function select<T>(): SelectEffect & YieldType<T>;
// Just some test utilities
type AssertEqual<T, Expected> = [T, Expected] extends [Expected, T] ? true : false;
const assertEqual = <U>() => <V>(val: V, expected: AssertEqual<U, V>) => {};
function* foo() {
const x: SelectEffect = select<number>();
assertEqual<SelectEffect>()(select<number>(), true);
assertEqual<number>()(yield select<number>(), true);
}
(to be clear, suggestions in this thread have their own merits, however I have no idea how I would implement them; the YieldType
approach is simple enough, works for most real-life cases, and has the benefit to have a precedent in the form of ThisType
)
I think since we haven’t decided on a direction to go for this issue, it’s unlikely that your PR will get much attention from the team, which might mean it gets closed after a while. This issue is still the best place to discuss the problem and try to promote it from “Awaiting More Feedback” to something more committed. In my view, having a prototype implementation doesn’t help its chances, since it doesn’t fall into the category of “it’s a good idea but we don’t know how or don’t have time to implement it.” But generally, it’s not something you need to ask permission for as long as you’re aware that it may not go anywhere. We tend to advise against implementing uncommitted things just to avoid wasted work, but that’s not relevant here since you’ve already done it 😁
Makes sense! What would you say is the current action item, however? Your team hasn't weighted yet, so it's difficult to know what's the type of feedback you're looking for. Is it a case of getting the idea upvoted? Or is there a concrete problem that should be addressed in the proposal?
At one point I jotted down this progression:
I think in this case, the problem clarity is good, the popularity is lacking, the applicability is not yet proven (does anyone outside redux-saga and koa care about this?), the level of pain the problem causes could be elaborated a bit (so all the yield expressions in redux-saga are typed as any
, but how often do people need to use their results?), and the replaceability of the proposed solution is unclear to me, but I see other ideas being discussed in this thread so I think the design space seems very open still. Maybe @rbuckton can help give some specific guidance, because any shortcomings of Generator typings is something I guarantee he’s thought about a lot.
so all the yield expressions in redux-saga are typed as any, but how often do people need to use their results?
Most of the time - most of the yielded expressions return something meaningful to the "caller".
Another example of library using generators as co-routine is https://github.com/tj/co. It has 11.5k stars, so it's a pretty popular library and has a stable API since a lot of time.
Their is lot of other library attempting to do the same but with less adoption. But we have to take in account that while Typescript doesn't allow to type this kind of library, their popularity will be by design limited. A Typescript project will probably not choose a library which insert any
every where in your code.
so all the yield expressions in redux-saga are typed as any, but how often do people need to use their results?
Both library heavily uses yield expressions results. You can think of it basically as a plugable async/await
system which allows to await
something else than a Promise
.
As much as I'd love a solution to TypeScript's generator typing problems, I don't feel like this is the direction we should go.
The main problem I see with this proposal is that it makes the yielded value responsible for defining a yield
expression's type. In practice though, it's the thing that's "running" a generator (e.g. co) that's ultimately responsible for defining a yield
expression's type (based on the yielded value), because it's the thing that provides a yield
expression's result (when calling next
).
IMO, the best way to tackle this is via the approach mentioned by @rbuckton here, or via the alternative he mentioned here. Both of these approaches express the type of a yield
expression as some (type-level) function of the type of a yielded value, which correctly models the runtime behaviour.
The main problem I see with this proposal is that it makes the yielded value responsible for defining a
yield
expression's type. In practice though, it's the thing that's "running" a generator (e.g. co) that's ultimately responsible for defining ayield
expression's type (based on the yielded value), because it's the thing that provides ayield
expression's result (when callingnext
).
Yes, it would be sounder if the generators were in charge of defining the returned value, there's no argument over that. However, TypeScript's (non-)goals explicitly allow for this kind of "incorrectness":
Non-goals
- Apply a sound or "provably correct" type system. Instead, strike a balance between correctness and productivity.
In this case, the solution you mention is blocked by this other issue https://github.com/microsoft/TypeScript/issues/1213 opened since 2014. While it perhaps will be addressed someday, there's no indication that it'll be anytime soon, or even that it won't suffer from a drawback preventing it from being used in this particular case. In the meantime, generators suffer from subpar developer experience that hinder their adoption and negatively affect our codebases.
By contrast, letting yielded expressions annotate the value that will be sent back is, admittedly, somewhat backward, but it can be easily implemented with few changes to the codebase (and thus doesn't significantly increase the maintenance cost for the TS team), and solves most of the practical use cases seen in popular tools.
It can also be noted that both approaches aren't incompatible: should high order type parameters become a thing, Type Override being a thing won't prevent generators to improve (at worst they'd just become slightly less relevant).
@andrewbranch I've updated the OP with more information and context, does that help?
Something to note is that this issue becomes more important with TS 4.3, because Saga-based projects now yield a bunch of previously non-reported errors, making the migration quite painful (literally hundreds of places that need to be addressed, and the best way for that is to type the returned generator as any, which makes the fix even worse than the problem ...):
expression implicitly results in an 'any' type because its containing generator lacks a return-type annotation
@arcanis can you give a small code example of something using redux-saga that gained a new implicit any error in 4.3?
Sure, here's an example: playground. Seems like it started throwing in 4.2 rather than 4.3, but the point still stands.
This is just one little piece, of course - upgrading required us to add hundreds of ts-ignore
statements because of this one pattern. The warning itself isn't the problem though, we'd just prefer if we were able to properly type this segment of the codebase in the first place.
@arcanis SagaIterator is slightly better than typing with any
for saga generators. It still doesn't address the core issue here, but I'm sure you could come up with a codemod to at least "fix" the errors without using an escape hatch like any
or ts-ignore
.
Ping @andrewbranch? Did the example help?
Would love to see this gain some traction since all of the use-cases mentioned in the description are really powerful. This is why concepts like structured concurrency are going mainstream in languages like Java and Swift.
In the case of Effection (mentioned in the description) it's one of our biggest points of confusion that trips up new users, and now with the dominant mind-share of TypeScript within the JS ecosystem, I'd argue one of the biggest barriers to adoption. In fact, the reason I came upon this issue in the first place, was because I'm typing up a guide on using Effection with TypeScript.
The main problem I see with this proposal is that it makes the yielded value responsible for defining a yield expression's type. In practice though, it's the thing that's "running" a generator (e.g. co) that's ultimately responsible for defining a yield expression's type (based on the yielded value), because it's the thing that provides a yield expression's result (when calling next).
This is the point exactly. Despite being responsible for providing the value, the exported types that come with "the thing running the generator" currently may have absolutely no say in what the type of that value will be.
However, if you look at all the use-cases above, they pertain to frameworks whose entire raison d'être is to place runtime constraints on how generators will compose. For example, in Effection, the code driving the generator will never ever ever call next()
with anything but the return value of the invoked generator, the type of which is strictly known. The same is true of both ember-concurrency
and redux-saga
, and if it weren't, it would be a critical bug that would garner the full attention of those project's developers.
We take it as an article of faith that a browser, or the "thing driving the async function" will yield the value produced by a promise and so we consider an await
expressions type-safe, but we are still putting our trust in that the runtime. Generators by contrast, must always have this "thing running them" that is ever-present, but completely invisible to the type system, and so I'd argue that any type-system that doesn't account for it, or doesn't allow it to become visible is unsound in practice if not in theory.
And in truth, we've seen this happen on our own projects where a manual type annotation becomes "stale" and program that successfully type checks is actually incorrect. On the other hand, allowing the runtime to give the compiler hints about type-inference would actually produce more correct code, more often.
In short, JavaScript has an extension mechanism to the manner in which expressions produce values, but TypeScript does not have a concomitant extension mechanism for expressing the types of those values.
@arcanis I'm assuming YieldType
would be assignable from any type? I ask because I'm trying to imagine how to write the operation type in Effection so that it still places the right constraints on the actual generators (and not just the yielded values). In other words:
T
, then the return type of the generator must be T
export type Operation<T> = YieldType<T> & Iterator<Operation<any>, T>;
function* yes(): Operation<boolean> {
let value = yield (function*() { return true; })();
return value;
}
function* no(): Operation<boolean> {
// @ts-expect-error
return "false";
}
function* alsoNo(): Operation<boolean> {
// @ts-expect-error
yield 5;
return false;
}
@rbuckton would be the best person to give some thoughts on this, though I think he may still be on vacation this week.
Any thoughts? If there's anything I can do personally to move this (or some alternative) forward, I would very much like to. I just don't know which direction to push.
@andrewbranch if I open it as a PR, would it be possible to pack it to make it usable in a playground? This way people would be able to play with the proposal.
@arcanis this PR is packed like this so i assume that it would be possible
Yep, feel free to ping me on the PR asking for that 👍
Fantastic @arcanis! I gave it a try on a paired down version of Effection types, and it works marvelously. There was one case I did have a question about. In Effection, Promise
is a valid operation type, and so you can yield to it, but I couldn't find a way to say "in the context of this generator, if you yield a Promise<T>
, treat it as though it were Promise<T> & YieldType<T>
.
Obviously don't want to monkey patch the global promise type, but since the type of the generator is recursive, and can only yield other operations. e.g. Iterator<Operation<unknown>, T, unknown>
it seems like it should be something that is possible to be inferred?
It's an inherent trait of this design - it's the yielded value/type that decided what gets "injected" back to the generator. So, as you have said, it's somewhat impossible to overload existing types with a custom/unwrapped/whatever yielded type. So to make it work you kinda have to convert the promise using a wrapper function, like here (I'm pretty sure that you know that, I'm just posting this as a reference for others).
Note that with your Operation
type I've expected an error here but it doesn't raise one 🤔
It's an inherent trait of this design - it's the yielded value/type that decided what gets "injected" back to the generator
@Andarist Right, but in this case because the TYield
type of my operation is itself Operation
, so in order for the generator function to type check, you cannot yield Promise<T>
because that is not an operation, however Promise<T> & YieldType<T>
is an operation. So I was wondering if that could then be used to infer the yield type.
It's a bit circular, but what I'm wondering is why it can't figure out from the type of the generator that the yielded value is actually Promise<T> & YieldType<T>
(which can therefore be used to infer the left hand side of the expression.)
Note that with your Operation type I've expected an error here but it doesn't raise one 🤔
Hmm... that is definitely odd.
I support this as a worth-solving problem. Yield-generator itself in JavaScript can be used to emulate powerful control flow structures (as many examples described above). Lacking type support will limit the user experience in this case.
I have a use case https://github.com/Jack-Works-forks/rfcs/blob/cancel-async-effects/text/0000-cancel-async-effects.md#basic-example I proposed an auto-cancellation effect API but it is lacking support from the type system.
useEffect(function* (_signal) {
setLoading(true)
// React automatically stops if the deps array outdated.
setText(yield getData(props.id))
}, [props.id])
In the case above, yield T
will be always T
by the semantic of useEffect
I proposed. But there is no way to modal it in the type system.
I believe YieldType<T>
is not enough, it would be better if the type system can behave this way:
type UnboxPromise<T> = T extends PromiseLike<infer Q> ? Q : never
declare function co<T, TR>(g: () => Generator<T, TR, UnboxPromise<T>>): Promise<TR>
// contextual typing for the generator function
co(function* () {
// T = Promise<number>, UnboxPromise<T> results in number
const a = yield Promise.resolve(1)
// a: number
// T = Promise<string>, UnboxPromise<T> results in string
const b = yield Promise.resolve("")
// b: string
return Promise.resolve(true)
}) //return: Promise<boolean>
This requires the type system to specially handle generator function to provide an ad-hoc multiple type evaluation.
@rbuckton would be the best person to give some thoughts on this, though I think he may still be on vacation this week.
Ping @rbuckton, do you have any thoughts on this?
In response to this concern:
Given the discussion there, I personally feel that this is not the right approach—this is essentially just a type assertion, but piggy-backing on non-type-assertion syntax in such a way that nobody will recognize that it’s just as unsafe. The next step is not finding someone to champion this PR; we are still on the very first step: exploring possible solutions that could be driven to consensus. https://github.com/microsoft/TypeScript/pull/47548#issuecomment-1167579213
I am not sure but I think I found one way of implementing a type-safe method for annotating and calling generators:
// This does not contradict the type system, hopefully?
interface MonadicGenerator<YieldCount extends number, YieldList extends unknown[], Return extends any, NextArgsList extends unknown[][]> {
next<Index extends YieldCount>(...args: NextArgsList[Index]): IteratorReturnResult<Return>;
next<Index extends number = number>(...args: NextArgsList[Index]): IteratorYieldResult<YieldList[Index]>;
}
const genFunc = function* () {
const a = yield new Just(5);
const b = yield new Just("6");
return new Just(a + parseInt(b, 10));
} as () => MonadicGenerator<2, [Maybe<number>, Maybe<string>], Maybe<number>, [[undefined], [number], [string]]>
function naiveDoNotation<T0, T1, R> (
genFunc: () => MonadicGenerator<2, [Maybe<T0>, Maybe<T1>], Maybe<R>, [[undefined], [T0], [T1]]>
) {
const gen = genFunc();
const { value: ma } = gen.next<0>(undefined);
return ma.bind((a) => {
const { value: mb } = gen.next<1>(a);
return mb.bind((b) => {
const { value: result } = gen.next<2>(b);
return result;
})
})
}
Link to full playground: https://github.com/microsoft/TypeScript/issues/36967#issuecomment-1241492260 Edit: Improved the code in https://github.com/microsoft/TypeScript/issues/32523#issuecomment-1242874821
I was not aware of this issue here when I was writing this code so this demo doesn't use YieldType but maybe this idea can be incorporated to this proposal somehow?
For what it's worth, we're migrating Effection away from using bare yield statements because it turns out that you can get what you want without any modifications to TypeScript by always consuming the return value of a generator which is strongly typed.
The motivating example becomes:
run(function* () {
const counter = yield* select(state => state.counter);
const firstName = yield* select(state => state.firstName);
});
The secret is to make the generators themselves composable as delimited continuations with shift()/reset()
. The technique is demonstrated here https://github.com/thefrontside/continuation
With these as primitives, you can build any higher set of operations that fully consume the generator and therefore use TReturn
for all type inference.
Following this blogpost: https://www.matechs.com/blog/abusing-typescript-generators I have implemented a generic do notation that makes use of fp-ts implementation of HKT and type classes. This is how it can be used:
it('works for option', () => {
const result = Do(O.Monad)(function* (_) {
const x = yield* _(O.some(2))
const y = yield* _(O.some('asd'))
return y + x.toString()
})
expect(result).toEqual(O.some('asd2'))
})
it('works for IO', () => {
const someSideEffect = jest.fn()
const result = Do(IO.Monad)(function* (_) {
const x = yield* _(IO.of(2))
yield* _(() => someSideEffect())
return 'asd' + x.toString()
})
expect(result()).toEqual('asd2')
expect(someSideEffect).toHaveBeenCalled()
})
it('works for Task', async () => {
const someSideEffect = jest.fn().mockReturnValue(Promise.resolve(undefined))
const result = Do(T.Monad)(function* (_) {
const x = yield* _(T.of(123))
yield* _(someSideEffect)
const y = yield* _(T.of(10))
return x + y
})
expect(await result()).toEqual(133)
expect(someSideEffect).toHaveBeenCalled()
})
Here is full code: https://gist.github.com/wnadurski/51a3e8cf06c8fe0cc3dd50b87fd90365
Note that with your
Operation
type I've expected an error here but it doesn't raise one thinking
@Andarist This is just a limitation of typescript where if the return type of a generator has a conditional non iterator type it can't check the yield sites. (playground) it has nothing to do with this proposal and is present in the latest version of typescript.
In the case above, yield T will be always T by the semantic of useEffect I proposed. But there is no way to modal it in the type system.
I believe YieldType
is not enough, it would be better if the type system can behave this way:
@Jack-Works you are basically describing #36967, in fact with parameter inference you could probably specify the expected yield
parameter in your library function and have it automatically inferred for the generator function passed directly in the same way that inference works here
I couldn't find a way to say "in the context of this generator, if you yield a
Promise<T>
, treat it as though it werePromise<T> & YieldType<T>
.
@arcanis I don't see how this actually fixes anything unless you are specifically using a library that maintains complete control over the type at every single yield site, if my library just uses Promise
or even strings or similar objects at the yield site then you still end up needing some way to inject the YieldType
into the type within the generator.
Like pretend I had a library that the yielded values were just strings to denote labels in a database that is lazily loaded as needed and the list of valid labels is statically known, I don't think this proposal actually benefits this.
interface A { a: number }
interface B { b: string }
function* current(){
// doesn't actually ensure the "A"->A, "B"->B logic
const x: A = yield "A" as const
const y: B = yield "B" as const
assertType<A>(x); assertType<B>(y);
}
function* using43632(){
// unclear whether this is actually any better...
const x = yield ("A" as "A" & YieldType<A>);
const y = yield ("B" as "B" & YieldType<B>);
assertType<A>(x); assertType<B>(y);
}
This is obviously a toy example but this is exactly analogous to the case of yielding Promise
s which is very much a desired usecase.
I feel like an approach that decouples the library yield contract from the type of yielded value is a better approach such as #36967 (playground showing equivalent code in both cases)
Note that in your PR the TSend
generic is currently evaluating to unknown
which makes it really easy to write an unsound runner code. in the playground above current
has a TSend value of A&B
since that is the only type that'd satisfy all yield positions so a runner would need to either satisfy all of them or use type assertions, this is basically what I get at in https://github.com/microsoft/TypeScript/issues/36855#issuecomment-589081258
Suggestion
🔍 Search Terms
yield, any, generator, saga
✅ Viability Checklist
My suggestion meets these guidelines:
⭐ Suggestion
A new
YieldType
builtin generic type would instruct TypeScript to ignore the actual generator type in ayield
expression, and to instead use whatever is used as generic parameter. The following would thus be true:📃 Motivating Example
Imagine you define an API where the user provides a generator and can interact with your application by the mean of "effects". Something akin to this:
Under the current model, TypeScript is forced to type
counter
asany
, because in theory the generator runner may return whatever value it wants to theyield
expression. It can be somewhat improved by adding an explicit type to the generator, but then allyield
expressions will return the same type - which is obviously a problem when doing multipleselect
calls, each returning a different value type:Not only does it prevent return type inference and yield value checks, but it also unnecessarily widens the type of both
counter
andfirstName
intonumber | string
. The alternative is to write the expressed values at callsite:But it then requires the user code to have an explicit requirement on the exact types, which prevents accessing various refactoring tools and generally leads to worse UX (after all that's why TS has type inference in the first place). The last option is to change the API:
By using a
select
variant supportingyield*
, library code would be able to define a return type that TS would be able to use. However, it requires all libraries to adopt a non-idiomatic runtime pattern just for the sake of TypeScript, which is especially problematic considering that it would lead to worse performances.The main point is that in all those cases, the library code already knows what's the value returned by the
yield select(...)
expression. All we need is a way to transmit this information to TypeScript.💻 Use Cases
Redux-Saga (21.5K ⭐, 6 years old)
The
redux-saga
library heavily use generators (there are various reasons why async/await isn't enough, you can see them here). As a result, its collection of effects are all typed asany
, leaving their users mostly unprotected on segments of code that would significantly benefit from types (for instance theselect
utility mentioned above can return any state slice from a larger one - typing the return asany
leaves the door open to refactoring mistakes, typos, etc).Additionally, under
noImplicitAny
, TS will put red underline on the wholeselect
call, hiding important errors that could arise within the selector itself. In some of my own application files, about a third of the lines have red squiggles because of this issue:MobX (23.8K ⭐)
https://mobx.js.org/actions.html#using-flow-instead-of-async--await-
Others
Ember Concurrency (663 ⭐)
Effection (138 ⭐)
GenSync (29 ⭐)
💻 Try it Out
I've implemented this feature (minus tests, and I'm not a TS contributor so some adjustments may be needed):
https://github.com/microsoft/TypeScript/compare/master...arcanis:mael/yield-override
Here's a playground: Before | After ✨
cc @Andarist