Search code examples
typescriptgenericstuplestypescript-generics

What’s the meaning of `T extends readonly unknown[] | []` in TypeScript’s function signature of `Promise.all`?


I noticed an interesting (possibly hacking) part in TypeScript’s lib.d.ts:

interface PromiseConstructor {
  /* ... */

  /**
   * Creates a Promise that is resolved with an array of results when all of the provided Promises
   * resolve, or rejected when any Promise is rejected.
   * @param values An array of Promises.
   * @returns A new Promise.
   */
  all<T extends readonly unknown[] | []>(values: T): Promise<{ -readonly [P in keyof T]: Awaited<T[P]>; }>;

  /* ... */
}

Everything seems normal except for the part where T extends readonly unknown[] is written as T extends readonly unknown[] | []. This looks strange because [] extends readonly unknown[], so readonly unknown[] should already cover []. I later realized that this allows TS to infer the tuple type:

const f = <T extends readonly unknown[] | []>(x: T): T => x;

const test_0 = f([42, "foo"]);
//    ^?: [number, string]

Without the | [], it doesn’t work:

const f = <T extends readonly unknown[]>(x: T): T => x;

const test_0 = f([42, "foo"]);
//    ^?: (number | string)[]

The relevant GitHub commit I found is this one.

The mechanism behind this seems quite magical, and I’m curious if anyone can explain the specifics of how it works.


Solution

  • TypeScript uses various heuristic rules to determine how to infer an appropriate type for a value, since lots of types are compatible with any given value. For example, the value [42, "foo"] is assignable to the types: (number | string)[]; readonly unknown[]; (42 | "foo")[]; [42, string, ...Date[]]; unknown, and many others. How does TypeScript choose?

    One of the ways it decides is to look at the context in which the value appears; that context might "expect" a certain range of types which affects inference. Without context, [42, "foo"] is inferred as (string | number)[], which is a good choice in a wide range of use cases.

    But if [42, "foo"] appears in a context that expects tuple types then a tuple type like [string, number] is likely to be inferred (see this comment on microsoft/TypeScript#27179).

    A constrained type parameter like T extends U causes U to be figured into the context for inferring T. So if T extends readonly unknown[], then there's no tuple type in the context and [42, "foo"] is inferred as (string | number)[]. But if T extends readonly unknown[] | [] then there is now a tuple type explicitly mentioned in the domain of T (although yes, the union of [] and readonly unknown[] doesn't contain any more values than just readonly unknown[]), and thus [number, string] is inferred.


    Note that variadic tuple types also serve as a tuple context, so you could also write

    const f = <T extends unknown[]>(x: readonly [...T]) => x;    
    const test_0 = f([42, "foo"]);
    //    ^?: const test_0: readonly [number, string]
    

    Presumably if the TypeScript typings for Promise.all were written today, it would use variadic tuples instead of empty tuple types. Or maybe it would even use a const type parameter to explicitly ask for even narrower types:

    const f = <const T extends readonly unknown[]>(x: T) => x;
    
    const test_0 = f([42, "foo"]);
    //    ^?: const test_0: readonly [42, "foo"]
    

    Playground link to code