Search code examples
typescripttypescript-generics

TypeScript fails to infer type correctly when type alias used


In the code below I have two instances of a Record<string, string[]>: one that declares itself directly as the Record, and one that uses a type alias referring to the exact same Record type. When passing these into a function, along with an empty array (which I expect TypeScript to infer as being an empty string array), TypeScript compiles the first, but not the second. So it looks like the type alias is causing it to have a problem. Is there a way around this? Is this a bug in TypeScript that should be reported?

function foo<T>(record: Record<string, T>, entity: T) {
}

type StringArrayRecord = Record<string, string[]>;

function test() {
    const working: Record<string, string[]> = {};
    foo(working, []); // Compiles

    const broken: StringArrayRecord = {};
    foo(broken, []); // Does not compile
}

The error is:

Argument of type 'StringArrayRecord' is not assignable to parameter of type 'Record<string, never[]>'.
  'string' index signatures are incompatible.
    Type 'string[]' is not assignable to type 'never[]'.
      Type 'string' is not assignable to type 'never'.

If I try a different type instead of string[] as the second type parameter in the record, I get the same problem as soon as there is any ambiguity in the type (e.g. if it's a union type), e.g.

type StringOrNumberRecord = Record<string, string | number>;

function test() {
    const working: Record<string, string | number> = {};
    foo(working, "string"); // Compiles

    const broken: StringOrNumberRecord = {};
    foo(broken, "string"); // Does not compile
}

(Am getting this both with TypeScript 4.8 and with 5.)

(One possible solution is to explicitly tell the compiler the type of the second parameter when calling foo, e.g. foo(broken, "string" as string | number);. Another is specify the generic type parameter when calling foo, e.g. foo<string | number>(broken, "string"). But both of these seem like they should not be necessary.)


Solution

  • This is apparently working as intended, according to microsoft/TypeScript#54000 although it's definitely strange when a type alias has such a large effect on inference.

    The reason for the difference is that when TypeScript compares two types, it often has to recursively match up pieces of each one before deciding if they are the same, or different, or if one can be used to infer pieces of the other.

    But if they both types are written in terms of an identical generic type with known variance in its type parameter (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript ), then the compiler can take a shortcut by just comparing the type arguments. For example, if the compiler is comparing F<string> to F<unknown> for the following F<T> type

    type F<T> = {
      prop: T;
      otherProp: string;
      anotherProp: number;
      // ... lots more stuff, none of which depend on T
    }
    

    which it has already analyzed as being covariant in its type parameter, then it can immediately say that F<string> is a subtype of F<unknown> because string is a subtype of unknown, without needing to actually evaluate F<string> or F<unknown> and compare them directly.

    This shortcut is, to a first approximation, just a faster way of getting the same result it would have gotten the long slow way.


    On the other hand, if the compiler is comparing F<string> to G for the following G type

    type G = F<unknown>
    

    then it would need to start evaluating the types before it could compare them. If it does so by plugging string into F and then evaluating G, it would be too late to notice that they are both in terms of F and therefore it needs to start comparing all the properties of both.

    This will eventually determine that F<string> is a subtype of G. But it does so through a different path.

    (And you can substitute Record<string, T> for F<T> and Record<string, string[]> for G if you want here.)


    Even though these have basically the same result, there are observable differences, for better or worse. And one is that it can affect generic type argument inference.

    When inferring Record<string, T> from a value of type Record<string, string[]> at the same time as inferring T from a value of type never[] (which is the default type of an empty array), the compiler can immediately use the variance marker on Record's second type parameter to see that as the same as inferring T from string[] and T from never[]. These two inference candidates are given equal priority and the compiler decides that string[] is the right choice because never[] is assignable to string[].

    On the other hand when inferring Record<string, T> from a value of type StringOrNumberRecord at the same time as inferring T from a value of type never[], the compiler cannot take the shortcut of variance markings. It needs to evaluate StringOrNumberRecord to do that, and therefore the inference candidate of never[] has a higher priority because it's more direct. And then when it does finally evaluate StringOrNumberRecord it fails because it's not assignable to Record<string, never[]>.

    It's an unfortunate inconsistency in behavior, but neither way is "incorrect" per se. And according to the TS team dev lead, the more consistent fix would be to lower the variance marker priority and have both calls fail, which probably wouldn't make anyone happier. So it is what it is, at least for now.