typescripttypes

Type signature that infers all values


This is in TypeScript 4.0.2. The type system can infer some of the values from the type signature in the example below. I'm perplexed though why it can't infer the concrete value of the last element in const b.

Can anyone explain why, and how I can write a type signature that can?

declare function identityA<T extends string[]>(p: readonly [...T]): T
declare function identityB<T extends any[]>(p: readonly [...T]): [...T]

const a = identityA(['r', 's'])  // ['r', 's']
const b = identityB([...a, 3])  // ['r', 's', number]

Solution

  • TypeScript's type inference uses a variety of heuristics to figure out when to widen literal types and when to leave them as narrow as possible. For example, when you write

    let s = ""; // string
    let n = 0; // number
    let b = true; // boolean
    

    the types of the variables s, n, and b are widened to string, number, and boolean, respectively... the assumption is that since let declarations allow you to reassign values, you probably want to do that. On the other hand, when you write

    const sC = ""; // ""
    const nC = 0; // 0
    const bC = true; // true
    

    the types of the variables sC, nC, and bC are kept narrow as "", 0, and true, respectively. The assumption is that because you can't change those values, you have no reason to allow more than those single values in the types.


    For type parameters, like the T in your identityA() and identityB() functions, the compiler does something else. This is mentioned in microsoft/TypeScript#10676:

    During type argument inference for a call expression the type inferred for a type parameter T is widened to its widened literal type if (...) T has no constraint or its constraint does not include primitive or literal types

    In

    declare function identityA<T extends string[]>(p: readonly [...T]): T
    

    the type parameter T has a constraint that includes the primitive string type, and thus T will not be widened from, say, ["r", "s"] to [string, string]. On the other hand, in

    declare function identityB<T extends any[]>(p: readonly [...T]): [...T]
    

    the type parameter T's constraint does not include any literal or primitive types, so T will be widened from, say, [3] to [number]. Or since the "r" and "s" types were already non-widened from a previous inference, T will be widened from ["r", "s", 3] to ["r", "s", number].

    It's confusing, right? Never mind that by using the variadic tuple type [...T] in your p parameter type you are giving the compiler a hint that you want to infer tuple types for T instead of just array types.


    So that's why it happens. What can you do to fix it?

    If you have control over the call site, you can use a const assertion to explicitly ask the compiler to infer the narrowest possible type for what you're passing in. This happens before T gets inferred:

    const iBAsConst = identityB([...iA, 3] as const)  // ['r', 's', 3]
    

    But you asked how to change the signature to turn identityB()'s type parameter into a literal-preferring inference thingy. The easiest way is to use a const type parameter:

    declare function identityB<const T extends any[]>(p: readonly [...T]): [...T]
    const iB = identityB([...iA, 3])  
    // const iB: ["r", "s", 3]
    

    In versions of TypeScript before 5.0, you can do this by changing the any[] to something that includes primitives. For example:

    type Narrowable = string | number | boolean | symbol | 
      object | undefined | void | null | {};
    declare function identityC<T extends Narrowable[]>(p: readonly [...T]): [...T]
    const iC = identityC(["r", "s", 3]); // ['r', 's', 3] 
    

    Just about anything can be assigned to Narrowable, like a weird any or unknown that also works as a keep-this-narrow hint.

    Playground link to code