Skip to content

Conversation

@rbuckton
Copy link
Contributor

@rbuckton rbuckton commented Jan 4, 2020

It has been over two years since we decided not to move forward with #17077 due to the advent of conditional types, however in that time we have still been unable to devise a reliable mechanism for defining a recursive conditional type that can properly handle the type-side of ECMAScript's await and native Promise unwrapping behavior.

Solutions like Awaited<T> were untenable given that the only way to make the conditional type "recursive" was to introduce complex type like the following:

T extends PromiseLike<infer U> ? { 0: Awaited<U> }[U extends any ? 0 : never ] : ...

However, there are a number of problems with this approach:

  • It is not obvious what { 0: ... }[U extends any ? 0 : never] is doing in this alias.
  • The index access type approach is a bit of a "hack" that we probably should not include in our core libs.
  • A self-recursive "promise" type can easily exhaust the maximum recursion limit, resulting in errors in the type alias itself, rather than in the offending code that references it.
  • The quick-info for such a type is non-trivial.

As a result, I would like to reintroduce the awaited type operator from #17077 to meet our needs for a mechanism to recursively unwrap a Promise-like type for methods like Promise.all, Promise.race, Promise.allSettled, Promise.prototype.then, and Promise.prototype.catch. The awaited type operator has the following semantics:

  • If the type operand is non-generic, the awaited operator is resolved immediately:
    • awaited number is number
    • awaited Promise<number> is number
  • If the type operand is generic, the awaited operator is deferred until instantiated with a non-generic:
    • awaited Promise<T> is awaited T
    • awaited Promise<Obj[K]> is awaited Obj[K]
  • An awaited S is assignable to an awaited T if S is assignable to T:
    • CAVEAT: This isn't precisely sound, as a non-thenable T could be subtyped with an S that has a then(). However, this is not likely to be a common practice and is no less permissive than the pseudo-recursive conditional type approach.
  • An awaited S is related to T if awaited C is related to T, where C is the constraint of S:
    function f<T extends number>(x: awaited T) {
      const y: number = x; // ok, `awaited number` is assignable to `number`
    }

This will help us to merge PRs such as #33707 without needing to introduce a non-recursive or pseudo-recursive Awaited<T> type.

Fixes: #27711, #31722
Closes: #36435, #36368.

@rbuckton
Copy link
Contributor Author

rbuckton commented Jan 4, 2020

cc: @weswigham, @DanielRosenwasser, @ahejlsberg, @RyanCavanaugh (since suggested reviewers weren't working)

@dragomirtitian
Copy link
Contributor

Wouldn't a more general operator, something like unwrap be of broader use ? I know of a couple of people trying to unwrap Array for example. Something like T unwrap G<infer> (with infer getting only one use). With the inheritance rules being tied to the variance of G.

@falsandtru
Copy link
Contributor

Looks like the position of type argument declarations is not the best for inserting modifiers. So if we use unwrap, it would be as follows:

function f<T extends number>(x: unwrapped PrimiseLike<T>) {
}

@dragomirtitian
Copy link
Contributor

@falsandtru I think there are complications in the syntax. For example what if you need to unwrap a type with more than one type parameter, you need to be able to specify the position of the argument that you are unwrapping by.

@jablko
Copy link
Contributor

jablko commented Jan 5, 2020

If nested promises are allowed, then shouldn't the following be allowed?

    const expected: Promise<string> = undefined as Promise<Promise<string>>;
          ~~~~~~~~
!!! error TS2322: Type 'Promise<Promise<string>>' is not assignable to type 'Promise<string>'.

What do you think of #35284 instead?

@rbuckton
Copy link
Contributor Author

rbuckton commented Jan 6, 2020

Wouldn't a more general operator, something like unwrap be of broader use ? I know of a couple of people trying to unwrap Array for example. Something like T unwrap G<infer> (with infer getting only one use). With the inheritance rules being tied to the variance of G.

Its not that simple, for several reasons:

  • Type Aliases cannot reference themselves recursively (without using the indexed access hack).
  • An unwrap operator could easily result in infinite recursion, and would therefore need to have a max recursion limit.
  • Its not easy to indicate how to handle bad actors like self-recursive or mutually-recursive interfaces.

All of these behaviors are already handled by await in the compiler today specifically for handling Promise-like types, and the awaited type operator is formalization of this behavior in the type system. This would not preclude us from introducing a separate mechanism for unrolling a recursive type in the future.

@rbuckton
Copy link
Contributor Author

rbuckton commented Jan 6, 2020

If nested promises are allowed, then shouldn't the following be allowed?

    const expected: Promise<string> = undefined as Promise<Promise<string>>;
          ~~~~~~~~
!!! error TS2322: Type 'Promise<Promise<string>>' is not assignable to type 'Promise<string>'.

What do you think of #35284 instead?

We already investigated adding an Awaited<T> to the core library and abandoned that approach. The problem being that we cannot currently model the recursive unwrap of the await operator with a type alias. The only way that could be remotely feasible is if we had a mechanism to forbid Promise<Promise<T>>, but such a mechanism also does not yet exist.

@jablko
Copy link
Contributor

jablko commented Jan 6, 2020

Is it necessary for Awaited<T> to be recursive, if the only way to get a nested promise is to explicitly write it as such, which is out of scope?

Why make it recursive if Promise<Promise<T>> will someday be forbidden?

@treybrisbane
Copy link

I can't help but feel as though we're jumping too quickly to a syntactic solution here...

From what I understand of this issue, we could hypothetically solve this problem without new syntax if we had a "canonized" story around recursive conditional types. Is that a reasonable assessment?

Anecdotally, I've seen the topic of recursive types come up a lot, and I know there are a good few libraries available that implement various forms of the hack @rbuckton mentioned. It definitely feels to me as though there's need for a more general feature (or set of features) in the recursive type space.

Has there been any exploration/ideation into that space that would address this specific issue as part of a more general solution? :)

@rbuckton
Copy link
Contributor Author

I can't help but feel as though we're jumping too quickly to a syntactic solution here...

I would argue that two years isn't "jumping too quickly". Adding awaited T doesn't preclude us from investigating recursive conditional types. The problem with a recursive type alias is the "halting problem": Adding that level of complexity can make the type system unpredictable as it can quickly become impossible to be able to reason over whether a type can be instantiated in finite time or using finite memory. Any solution for recursive type aliases would need to be very restrictive.

@rbuckton
Copy link
Contributor Author

Is it necessary for Awaited to be recursive, if the only way to get a nested promise is to explicitly write it as such, which is out of scope?

That wasn't what was out of scope. What I was calling out was that writing Promise<Promise<number>> wouldn't be automatically replaced by Promise<number> by the compiler.

@falsandtru
Copy link
Contributor

I agree, awaited is not smart but inevitable. Since it is difficult to generalize unwrapping, we have to specify the kind of unwrapping like awaited.

@treybrisbane
Copy link

I would argue that two years isn't "jumping too quickly". Adding awaited T doesn't preclude us from investigating recursive conditional types.

This is true. 🙂
If we feel that this is the best way to address the issue, then let's do it. Just want us to be extra sure that a syntactic solution is the right one.

The problem with a recursive type alias is the "halting problem"

Oh 100%. I don't mean to trivialise recursive types, as I'm very aware of the headaches they bring. 🙂 But people are writing them already (myself included), and there are libraries full of them. If we could build out that feature space a bit, then it's possible we wouldn't need special awaited syntax in the first place (which is why I brought it up).

@jablko
Copy link
Contributor

jablko commented Jan 16, 2020

What are the cases where a non-recursive solution isn't equivalent? Why are those cases important? Do they justify adding a new kind of type?

@ExE-Boss
Copy link
Contributor

ExE-Boss commented Jan 22, 2020

@rbuckton Can’t it just be await <type>?

@jablko
Copy link
Contributor

jablko commented Jan 22, 2020

My argument is that

  • The purpose of a recursive solution is to accommodate Promise<Promise<T>>.
  • The only way to get Promise<Promise<T>> is to explicitly write it as such.
  • Writing Promise<Promise<T>> is an error. It should be written Promise<T>. They are the same thing.
    • A recursive awaited solution doesn't solve the problems with writing Promise<Promise<T>>. It remains unassignable/comparable to Promise<T>.
    • Will it ever be assignable/comparable to Promise<T>? To me, that seems even less justified than introducing a recursive awaited solution?

It's clear why #21613 was investigated and rejected, if a recursive solution was assumed. What I'm searching for and haven't found yet, is why a non-recursive solution was rejected?

@ExE-Boss
Copy link
Contributor

I’d prefer if the type operator was just await T, as it’d make sense to me that the result of an await expression would be await <type>, rather than awaited <type>.

@sandersn sandersn added the Experiment A fork with an experimental idea which might not make it into master label Feb 1, 2020
@rbuckton
Copy link
Contributor Author

@jablko: The problem of Promise<Promise<T>> isn't just the fact you can explicitly write such a type, but that it's relatively easy to accidentally end up with such a type when composing higher-order functions.

@rbuckton
Copy link
Contributor Author

To me, await is more of an imperative operation. The word await is an action verb: it happens once and happens immediately. However, the result of an await is something that was awaited.

The word awaited is a past participle and can be used as an adjective. If the thing you awaited was generic, then we must defer the resolution of the actual type until such time as it is fully instantiated. As a result, you can carry around an awaited T type for a value long after the actual await has occurred.

@falsandtru
Copy link
Contributor

I agree. So I agreed with awaited.

@rbuckton
Copy link
Contributor Author

declare function f<T>(x: Promise<T>): Promise<T extends number ? true : false>;
const expected: Promise<true> = f(undefined as Promise<Promise<number>>);

...

Sorry: Now that Promise<Promise<number>> is assignable to Promise<T>, what is T? number or Promise<number>?

This will result in an error as we haven't changed inference here. T is still inferred as Promise<number>, so your return type in the conditional is instantiated as Promise<Promise<number> extends number ? true : false> which becomes Promise<false>.

However, if you change your example in this way:

declare function f<T>(x: Promise<T>): Promise<awaited T extends number ? true : false>;
const expected: Promise<true> = f(undefined as any as Promise<Promise<number>>);

Then you get the correct result.

@rbuckton
Copy link
Contributor Author

@DanielRosenwasser in the DT test I'm seeing a lot of this from JQuery:

../jquery/misc.d.ts(1205,15): error TS2430: Interface 'PromiseBase<TR, TJ, TN, UR, UJ, UN, VR, VJ, VN, SR, SJ, SN>' incorrectly extends interface '_Promise<TR>'.
  Types of property 'then' are incompatible.
    Type '{ <ARD = never, AJD = never, AND = never, BRD = never, BJD = never, BND = never, CRD = never, CJD = never, CND = never, RRD = never, RJD = never, RND = never, ARF = never, AJF = never, ANF = never, BRF = never, BJF = never, BNF = never, CRF = never, CJF = never, CNF = never, RRF = never, RJF = never, RNF = never, AR...' is not assignable to type '<TResult1 = TR, TResult2 = never>(onfulfilled?: ((value: TR) => TResult1 | PromiseLike<TResult1>) | null | undefined, onrejected?: ((reason: any) => TResult2 | PromiseLike<...>) | null | undefined) => _Promise<...>'.

I'm investigating whether this is something I can fix in this PR or if I will just need to make a change to JQuery.

@rbuckton
Copy link
Contributor Author

Upon investigation, I think the fix will need to be in the JQuery types, due to the fact they are extending from both PromiseLike and copy of Promise.

@jablko
Copy link
Contributor

jablko commented Mar 20, 2020

T is still inferred as Promise<number>

Would it make sense to accommodate Promise<Promise<number>> in this context and if so, what would be the differences between awaited T and

type Awaited<T> = Promise<T> extends Promise<infer U> ? U : never;

Assuming U would become awaited T?

@rbuckton
Copy link
Contributor Author

T is still inferred as Promise<number>

Would it make sense to accommodate Promise<Promise<number>> in this context [...]

Currently, no. We're not doing anything special for conditionals here, and as I mentioned the problem would be easily addressable by using awaited T in your example.

[...] and if so, what would be the differences between awaited T and

type Awaited<T> = Promise<T> extends Promise<infer U> ? U : never;

Assuming U would become awaited T?

I'm not sure what the difference would be, and I don't see how we could choose to assume the U in Promise<infer U> should be awaited T.

@rbuckton
Copy link
Contributor Author

There are some known breaks on DT that I am planning to address in parallel.

@rbuckton rbuckton merged commit e3ec7b1 into master Mar 20, 2020
sheetalkamat added a commit to microsoft/TypeScript-TmLanguage that referenced this pull request Mar 20, 2020
@ExE-Boss
Copy link
Contributor

To close multiple issues or PRs, you need to write the keyword before each of them: https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword

@rbuckton
Copy link
Contributor Author

@ExE-Boss thanks, I went ahead and closed them manually.

kdesysadmin pushed a commit to KDE/syntax-highlighting that referenced this pull request Apr 15, 2020
Summary: Add the new keyword "awaited": microsoft/TypeScript#35998

Reviewers: #framework_syntax_highlighting, dhaumann, cullmann

Reviewed By: #framework_syntax_highlighting, cullmann

Subscribers: kwrite-devel, kde-frameworks-devel

Tags: #kate, #frameworks

Differential Revision: https://phabricator.kde.org/D28814
@microsoft microsoft locked as resolved and limited conversation to collaborators Oct 21, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Author: Team Experiment A fork with an experimental idea which might not make it into master

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Promise<Promise<T>> cannot exist in JS