Repository navigation
Suggestion for improving generator and async function type checking #6686
Description
This is a suggestion to improve both the consistency and the type-safety of return type checking for
generator functions (GFs) and async functions (AFs).
NB: this issue refers to tsc behaviour for target >=ES6 with typescript@next as of 2016-01-28 (ie since commit a6af98e)
Current Behaviour
Consider the following examples of current behaviour for checking return type annotations:
// Current behaviour - generator functions
class MyIterator implements Iterator<any> {next}
function* gf1(): any {} // OK
function* gf2(): MyIterator {} // OK (but really is an error)
// Current behaviour - async functions
class MyPromise extends Promise<any> {}
async function af1(): any {} // ERROR (but is really not an error)
async function af2(): MyPromise {} // ERRORProblems with Current Behaviour
Firstly, type checking is not consistent across the two kinds of functions. In the examples the GF checking is too loose and the AF checking is too strict. The inconsistency is due to the different approach to checking the return type. The two approaches may be summarised like this:
- Generator functions: accept any return type annotation that is assignable from
IterableIterator<T>. - Async functions: reject all return type annotations other than references to the global
Promise<T>. This is a recent change, the rationale
for it can be followed from here.
Secondly, the type checker only gets 2 out of 4 of the above checks right (gf1 and af2). Explanation:
- GOOD:
gf1's return type annotation is not super helpful but is 100% consistent with the type system. No sense erroring here, so the implementation is good. - BAD:
gf2's return type annotation passes type checking because it passes the assignability check.
Howevergf2definitely does not return an instance ofMyIterator. All generator functions
return a generator object, so at runtimegf2() instanceof MyIteratorisfalse. A compile error would have been helpful. - BAD:
af1's return type annotation is just likegf1: not super helpful but 100% consistent with the type system. The compiler errors here even though nothing is wrong (reason for the error is here). - GOOD:
af2's return type annotation fails type checking because it's notPromise<T>. The return type definitely won't be an instance of any class other thanPromise, so the implementation is good.
Suggested Improvement
Since GFs and AFs always return instances of known instrinsic types, we can rule out any type annotation that asserts they will return an instance of some other class.
Both generator and async functions could therefore be checked with the same two steps:
- Is Assignable: Ensure the return type of the GF/AF is assignable to the annotated return type. This is the basic check for all kinds of function return types. If not assignable, type checking fails. Otherwise, continue to step 2.
- Not a Class Type: Ensure the return type annotation is not a class type (except
Promise<T>which is allowed for AFs). For example if the return type annotation isFoo, ensure it does not refer toclass Foo {...}or another class-like value.
These rules have the following effects:
- GF and AF type checking are mutually consistent.
- This fixes
gf2by ruling out class types likeMyIteratorin addition to checking assignability. GF type checking is made safer in general by catching a class of errors that currently slip through. - This fixes
af1, because it's no longer necessary to rule out all annotations other thanPromise<T>, but just those that are assignment-compatible class types likeMyPromise. This approach will catch the breaking change from 1.7 as a compile error (as desired for reason here), but allow harmless (and correct) things likeanyandPromiseLike<T>.
Working Implementation
This is a small code change. I implemented this in a branch as best I could (but I may have made errors since I'm still getting my head around the codebase). The diff can be seen here.
With this version of tsc the above code works as follows:
// Suggested behaviour - generator functions
class MyIterator implements Iterator<any> {next}
function* gf1(): any {} // OK
function* gf2(): MyIterator {} // ERROR: A generator cannot have a return type annotation of a class type.
// Suggested behaviour - async functions
class MyPromise extends Promise<any> {}
async function af1(): any {} // OK
async function af2(): MyPromise {} // ERROR: An async function cannot have a return type annotation of a class type other than Promise<T>.
Activity
yortus commentedon Jan 28, 2016
TL;DR: Generator functions and async functions always return instances of intrinsic types, so they can never return instances of other class types. This observation could be used to improve consistency and correctness of return type checking for both generator functions (currently over-loose) and async functions (currently over-strict).
mhegazy commentedon Jan 28, 2016
I asked this question before, and i do not think any thing has changed since #6068
How is the compiler expected to emit this:
if as
Promise, then either type it asPromiseor write it asfunction af1(): MyPromise<someType>yortus commentedon Jan 28, 2016
Mohamed Hegazy (@mhegazy) things have changed since #6068. For ES6+, the emit always uses the global
Promise, and the checker reserves the identifierPromise. The return type annotation is no longer used for emit, so now is conceptually no different to that of a generator function.yortus commentedon Jan 28, 2016
Mohamed Hegazy (@mhegazy) I just checked the emit for ES6, so now regardless of the return type annotation the emit is:
and the
__awaiterhelper now starts with:So basically for ES6,
Pis always passed asundefinedand then set toPromise, regardless of how the async function was annotated.mhegazy commentedon Jan 28, 2016
so what is the scenario we are trying to do accomplish here? you can not have a function marked as
asyncthat returnsMyPromise. what value is there to annotate it asanyand keepasync.yortus commentedon Jan 28, 2016
The value is in consistency in the type system. Annotations would ideally work consistently right across the language. I can do all of the following:
Note that none of these return type annotations is an exact match to what is actually returned, they are simply assignment-compatible. So now I have my nice consistent mental model, and I try out the following expecting it to work in the same way as the examples above:
...but it in fact raises a compiler error. The inconsistency is confusing (#5911 raised this very example). And as of #6631, there's no longer a need to keep this inconsistency.
jods4 commentedon Jan 29, 2016
Troy Gerwien (@yortus) suggested I copy my comment from #6631:
The fact that the return type must be strictly equal to Promise might become a hindrance in scenarios involving interfaces and/or methods overriding.
You can work around it by adding a layer of indirection (like all CS problems 😉) but it doesn't feel nice.
Here's one example: in frameworks such as Aurelia, many methods can return a value or a Promise for a value. Not mandating a Promise is both convenient when implementing trivial methods (like
canSave() { return true; }) and good for perf.Say I have a base class that exposes such a function and is meant to be overriden. Assume that the base default implementation is async.
The natural way is this:
but this wouldn't work :(
mhegazy commentedon Feb 20, 2016
jods (@jods4)
asyncis not applicable in this context. marking a function asasyncmeans that the compiler will wrap it in a new Promise call all the time. if the function is not guaranteed to return a Promise it should not be marked as async, it should rather returnT | Promise<T>.This goes back to the issue that an
asyncfunction will be rewritten by the compiler, there is no way to change that unless it is not marked asasync. if it is marked asasync, the only possible type that can come out of it is thePromiseobject, any thing else we allow here will be a lie.The only proposal i see here is making a generator function returning something other than the generator interface an error to match the Promise implementation.
yortus commentedon Feb 20, 2016
Mohamed Hegazy (@mhegazy) jods (@jods4)'s example is valid both in terms of its type assertions and its runtime behaviour.
isValidin the base class is guaranteed to return a promise so is correctly marked asasync. It is annotated withboolean | Promise<boolean>so that derived classes can override it with either a sync or an async implementation, without TypeScript complaining about incompatibility.The following workaround adds pointless boilerplate that is only neded to satisfy TypeScript (pointless since jods (@jods4)'s version works fine at runtime as is currently is written, just removing the annotations) (actually it's much worse than jods (@jods4)'s original because it creates a new closure instance on every call):
Another workaround just to satisfy the compiler is this (which is also worse than jods (@jods4)'s original because it instantiates a new implementation of
isValidfor every class instance, rather than adding the method once to the prototype):yortus commentedon Feb 20, 2016
Then please look differently :)
Using annotations the way jods (@jods4) does is a great example of the Liskov substitution principle. This surely is part of any good programmer's toolkit.
I'd much rather allow LSP to work on async functions, than deliberately stopping it from working on generator functions (where it currently works just fine).
It is categorically not a lie to describe a
Promiseinstance as a union type that includesPromise. It's useful for applying LSP as mentioned above. More generally, for some valuetwhose real type isT, it's correct to sayvar t: T|UwhateverUis.That's guaranteed to return a
number. It's certainly not a lie to annotate the return type asstring|number.These are also not lies and may actually be useful annotations for keeping interfaces separate from implementations:
The compiler allows these useful 'abstracting' annotations basically everywhere except on async function return types.
nwalters512 commentedon Jul 18, 2018
I hate to raise this issue from the dead, but I just ran into this issue while trying to write types for the
child-process-promiselibrary. The functions return a subclass ofPromisethat have an additionalchildProcessmember so that you can, for instance, listen to data on thechildProcess.stdoutstream. However, declaring the type:I'm unable to return a
ChildProcessPromsiefrom an async function:This produces an error:
It's impossible for the return type to be anything other than a
Promise, but I know that that promise will always have that additional property, and I want my types to reflect that. Is there an alternative here, or will I just have to cast the return value asanywhen I want to use it?yortus commentedon Jul 19, 2018
Nathan Sarang-Walters (@nwalters512) for ES6+ targets, async functions will never return anything other than the built-in
Promisetype. You can check it by debugging your own sample code above. If you set a breakpoint on thereturn promise;line and inspect the value, it has thechildProcessproperty as you describe. However if you add code to call this async function and then inspect the result type, it is a standardPromiseand does not have thechildProcessproperty.This is the expected behaviour of async functions. They work fine with promise-like values in their body, but their return value is always a built-in Promise.
nwalters512 commentedon Jul 19, 2018
Ah! I had this idea that returning a promise would bypass the usual wrapping of the return value in a promise, not sure where that came from. Thanks so much. Making the return type
Promise<ChildProcessPromise>was able to fix the type error.yortus commentedon Jul 19, 2018
The return value will not be a
Promise<ChildProcessPromise>despite passing the type checker. A promise never resolves to another promise, by design. ThePromiseinstance returned by a call tolswill adopt the state of the specialpromiseinstance that was created inside the function. So it will resolve or reject when that inner promise resolves or rejects. But anything else special about that inner promise is lost.