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.
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"]