Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Awaited<T> to core library #21613

Closed
wants to merge 3 commits into from
Closed

Add Awaited<T> to core library #21613

wants to merge 3 commits into from

Conversation

rbuckton
Copy link
Member

@rbuckton rbuckton commented Feb 4, 2018

This adds Awaited<T> as a mechanism to unwrap a type to its "awaited" type:

declare type Awaited<T> =
    T extends { then(onfulfilled: (value: infer U) => any): any; } ? U :
    T extends { then(...args: any[]): any; } ? never :
    T;

Unlike the await expression, Awaited<T> does not recursively unwrap T as we do not support circular references in type aliases. It turns out recursive unwrap of T is mostly unnecessary if we modify the definition of Promise and PromiseLike to return a Promise<Awaited<TResult>>:

interface Promise<T> {
    then<TResult1 = T, TResult2 = never>(
        onfulfilled?: ((value: Awaited<T>) => TResult1) | null, 
        onrejected?: ((reason: any) => TResult2) | null
    ): Promise<Awaited<TResult1 | TResult2>>;
}

This effectively flattens Promise<Promise<number>> as well as other nested definitions of Promise:

type T1 = Awaited<number>; // number
type T2 = Awaited<Promise<number>>; // number
type T3 = Awaited<Promise<Promise<number>>>; // number

The Awaited<T> type is a conditional type that provides the following conditions:

  • If T has a then() method with a callback, infer the callback's first argument as U and use that as the result type.
  • If T has a then() method without a callback, this is a non-promise "thenable" that can never be resolved, so the result type is never.
  • Otherwise, the result type is T.

The benefit of this approach is more effective type inference from the onfulfilled callback passed to the then method of a Promise:

const p: Promise<number> = ...;
const x = p.then(value => {
  if (value > 1) return Promise.resolve("a");
  return false;
});
// `x` has the type `Promise<"a" | false>`

@rbuckton
Copy link
Member Author

rbuckton commented Feb 4, 2018

This only solves the "Incorrect return types or errors with complex Promise chains" issue in #17077.

This does not solve the "Incorrect eager resolution of the "awaited type" for a type variable" issue.

@ajafff
Copy link
Contributor

ajafff commented Feb 4, 2018

There's a difference to await if the on fulfilled callback has no parameter. await unwraps the promise to void while Awaited resolves to never AFAICT

src/lib/es5.d.ts Outdated

/** Gets the type resulting from awaiting `T`. This does **not** recursively unwrap nested promises. */
declare type Awaited<T> =
T extends Awaitable<Awaitable<infer U>> ? U :
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we figure that two levels deep is enough for most uses, and if anyone complains, we can always add more later?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess if we currently don't do better than two levels, this will do for now.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are using the Promise definition from our libs then you won't need anything deeper. Once this is shipping we can modify the definitions in DT to use it as well. Per my examples above: type T3 = Awaited<Promise<Promise<number>>>; // number for any level of nesting using either Promise or PromiseLike.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually only need:

declare type Awaited<T> =
    T extends Awaitable<infer U> ? U :
    T extends { then(...args: any[]): any; } ? never :
    T;

I seemed to have forgotten to stage an additional change to es5.d.ts.

src/lib/es5.d.ts Outdated
/** An object type that can be definitely be awaited. Do not inherit from this type. */
declare type Awaitable<T> = { then(onfulfilled: (value: T) => any): any; };

/** An object type that may not be awaitable. Do not inherit from this type. */
Copy link
Member

@weswigham weswigham Feb 4, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this type isn't supposed to be extended/used, why not just inline the object type inside Awaited, below, since it's only used one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've considered that, but this is more readable when getting Quick Info on Awaited<T>. I don't even need Awaitable<T> if I inline it in the 2-3 places its used but that again reduces readability.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed NonAwaitable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to also remove Awaitable<T> and inline the definition into Awaited<T>. The two other places where it was referenced were fine as PromiseLike<T>.

Copy link
Member

@weswigham weswigham left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It definitely seems better than the union with PromiseLike most positions took before from before, and we can definitely improve it in the future if we can.

We should audit how this affects definitelytyped, though.

@Igorbek
Copy link
Contributor

Igorbek commented Feb 4, 2018

Nice! This is what @tycho01 suggested by leveraging #6606 but now with conditional types.

src/lib/es5.d.ts Outdated
@@ -1273,7 +1273,20 @@ declare type PropertyDecorator = (target: Object, propertyKey: string | symbol)
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;

declare type PromiseConstructorLike = new <T>(executor: (resolve: (value?: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void) => PromiseLike<T>;
/** An object type that can be definitely be awaited. Do not inherit from this type. */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"be definitely be"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

src/lib/es5.d.ts Outdated

/** Gets the type resulting from awaiting `T`. This does **not** recursively unwrap nested promises. */
declare type Awaited<T> =
T extends Awaitable<Awaitable<infer U>> ? U :
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess if we currently don't do better than two levels, this will do for now.

@rbuckton
Copy link
Member Author

rbuckton commented Feb 4, 2018

@ajafff I'll see if there's anything worth changing here. Despite the naming Awaited<T> isn't the same as an await expression as its currently only used in the definition of Promise (and PromiseLike).

@rbuckton
Copy link
Member Author

rbuckton commented Feb 5, 2018

There's a difference to await if the on fulfilled callback has no parameter. await unwraps the promise to void while Awaited resolves to never AFAICT

@ajafff actually, if the onfulfilled callback doesn't take a parameter then we report an error:

Sorry, this was incorrect. If the then() method doesn't take a callback we report an error. If the onfulfilled callback doesn't take a parameter then the resulting type is never:

async function foo() {
  let y1: { then(): any; };
  let x1 = await y1; // Type of 'await' operand must either be a valid promise or must not contain a callable 'then' member.

  let y2: { then(onfulfilled: () => any): any; };
  let x2 = await y2; // never
}

The best approximation of an error we can provide here is to resolve the type to never, which is what we are doing.

@sandersn
Copy link
Member

sandersn commented Feb 5, 2018

Can you run this on DefinitelyTyped before checking in? Any change to inference is likely to disturb things there and it would be nice to know how big the break is.

@mhegazy
Copy link
Contributor

mhegazy commented Feb 5, 2018

Please also run the RWC tests before submitting and ensure we have not introduced any regressions there either.

@mhegazy
Copy link
Contributor

mhegazy commented Feb 5, 2018

and user tests.

@rbuckton
Copy link
Member Author

rbuckton commented Feb 5, 2018

@mhegazy, I plan to run this against RWC and DefinitelyTyped today. If all goes well, should we discuss this in the design meeting before taking the change?

@mhegazy
Copy link
Contributor

mhegazy commented Feb 5, 2018

If all goes well, should we discuss this in the design meeting before taking the change?

sure. We should discuss the while types as well to enable recursive unwrapping. Just had a chat with @ahejlsberg about a few minutes ago.

@rbuckton
Copy link
Member Author

rbuckton commented Feb 6, 2018

@mhegazy There are a few failing RWC tests after this that may actually be more correct. Most of them seem to be related to assignability, i.e.: Type 'Awaited<T> is not assignable to type 'T'. I will dig into these more tomorrow.

@rbuckton
Copy link
Member Author

rbuckton commented Feb 6, 2018

An example of one of the RWC errors is this:

Type 'Promise<Awaited<T>>' is not assignable to type 'Promise<T>'.
  Type 'Awaited<T>' is not assignable to type 'T'.
    Type 'U | (T extends { then(...args: any[]): any; } ? never : T)' is not assignable to type 'T'.
      Type 'U' is not assignable to type 'T'.

I'd expect Promise<Awaited<T>> to be synonymous with Promise<T>, except that we see that both are type references to Promise and Awaited<T> and T are not comparable. If we had attempted a structural comparison of Promise we still would fail when instantiating the onfulfilled callback to then as we would end up comparing Awaited<Awaited<T>> to Awaited<T>, which still aren't comparable.

@ahejlsberg, any suggestions? Is there something I can change in my definition to address this issue, or is there a change we can make in the checker that makes sense? Is this something that while types could address?

@KiaraGrouwstra
Copy link
Contributor

I got it to recursively unwrap under the present definition of Promise as follows:

type Awaited<T> = {
  '0': T;
  '1': Awaited<T extends { then(onfulfilled: (value: infer U) => any): any; } ? U : 'wat'>;
}[T extends { then(onfulfilled: (value: any) => any): any; } ? '1' : '0'];

let one: 1;
one = null! as Awaited<1>>; // ok
one = null! as Awaited<Promise<1>>>; // ok
one = null! as Awaited<Promise<Promise<1>>>>; // ok

I'll admit it's clunky and silly -- I just used an object for the conditional to enable type recursion. But it works on master already, fwiw.

@weswigham
Copy link
Member

@tycho01 @ahejlsberg on master I see this example:

type Awaited<T> = {
  '0': T;
  '1': Awaited<T extends { then(onfulfilled: (value: infer U) => any): any; } ? U : never>;
}[T extends { then(onfulfilled: (value: any) => any): any; } ? '1' : '0'];


function foo<T>(x: T) {
  let one: T;
  let n1: Awaited<T>;
  let n2: Awaited<Awaited<T>>;
  let n3: Awaited<Awaited<Awaited<T>>>;
  one = n1;
  one = n2;
  one = n3;
}

spin forever. Looks like an infinite loop in instnatiateType, during computeBaseConstraint.

@rbuckton
Copy link
Member Author

@weswigham, I think that is the same issue as in #21611

@KiaraGrouwstra
Copy link
Contributor

Sorry, just remembered I'd yet to test for the original core issue, 1|Promise<1>. I've yet to make that work.
Also, I mistakenly said master, while I was on 2.8.0-dev.20180210.

@KiaraGrouwstra
Copy link
Contributor

I played around a bit more to get the union cases to work properly.

Conditional types already distributed over union types properly, so this came down to finding a way to make recursive types based on this, i.e. somehow wrapping the recursion so it'd allow it, using some useless no-op wrapper delaying execution to trick it.

I found a silly approach that appears to work: { '1': ... }[T extends number ? '1' : '1']. Here T is the generic we use to delay unwrapping, while number is some arbitrary condition -- the point is this wrapper is there during the recursion check, and vanishes once T is plugged in.

Using that to unwrap promises or to flatten nested array types:

// unwrap a Promise to obtain its return value
export type Awaited<T> = {
  '1': T extends { then(onfulfilled: (value: infer U) => any): any; } ? Awaited<U> : T;
}[T extends number ? '1' : '1'];

// Awaited<Promise<1 | Promise<2>>> // -> 1 | 2

// flatten a structure of nested tuples/arrays into a flat array type
export type Flatten<T> = {
  '1': Flatten<T extends Array<infer U> ? U : T>;
}[T extends number ? '1' : '1'];

// Flatten<Array<1 | Array<2>>> // -> 1 | 2

@ericanderson
Copy link
Contributor

@tycho01 you rock my friend

@KiaraGrouwstra
Copy link
Contributor

Sorry, so far I'd tested through the compiler API, but through tsc I ran into a recursion related issue on that Flatten, now posted at #22018.

@mhegazy
Copy link
Contributor

mhegazy commented Apr 24, 2018

Does not look like this is something we can proceed on at the time being.closing the PR, but leaving the branch for further investigations.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

9 participants