Search code examples
typescriptgenericsstructural-typing

TypeScript does not expand generic and complains about type arguments being not comparable


I'm working with a data structure that exists in two variants: a complete version for the output and an incomplete version where some properties are nullable during intermediate processing. Most functions expect the complete version of the type, some can work with both. Since it's a complex tree-like data structure with a lot of discriminated unions, instead of defining the type for the incomplete variant as a union, I wanted to use a generic parameter, the basic idea being

interface Data<Mode = 'DONE'>  {
    someProperty: Mode extends 'BUILD' ? string | null : string;
}

type FullData = Data<'DONE'>; // or just `Data`, using the default
type TempData = Data<'BUILD'>;

Now some functions will want to check whether the data is actually complete, and can then call another function that expects the complete data:

function example(doneVal: Data<'DONE'>) {}

function caller(buildVal: Data<'BUILD'>) {
  if (buildVal.someProperty != null) {
    example(buildVal);
  }
}

However, this produces the error message

Argument of type 'Data<"BUILD">' is not assignable to parameter of type 'Data<"DONE">'.
Type '"BUILD"' is not assignable to type '"DONE"'. (ts2345)

Ok, I get it, TypeScript cannot infer from the someProperty != null check that buildVal is of the correct type, but even when I use a type assertion

example(buildVal as Data<'DONE'>);

it still complains

Conversion of type 'Data<"BUILD">' to type 'Data<"DONE">' may be a mistake because neither type sufficiently overlaps with the other.
Type '"BUILD"' is not comparable to type '"DONE"'

What gives? It seems TypeScript is not actually comparing the types structurally, but rather by name?!

If I were to spell out the types in both variants instead of using a single generic type, it works just fine.

I can work around this by using a type guard (which also means I don't have to explicitly assert the type in the call), but I'd still like to understand what is happening. So far, my experimentation shows that the error message changes when I use 'BUILD' extends Mode instead of Mode extends 'BUILD' in the type condition (I never know which one to use), and that the default parameter type does not matter (Data<Mode> or Data<Mode extends 'DONE' | 'BUILD'>).

Edit: after some further experimentation, I found that using type TempData = Data<'BUILD'> | Data<'DONE'> or Data<'BUILD' | 'DONE'> lets me do the cast, but I don't like the verbosity of that. And I'd still like to understand why my original attempt doesn't work :-)


Solution

  • At its core TypeScript's type system is structural, where two types are considered compatible based on their shapes or structures. This is in opposition to nominal, where types are considered compatible based on their names or declarations.

    So while a nominally typed language can quickly and easily decide if two types are the compatible by just looking at their declarations, a structurally typed language might have to do a lot of work to make a decision. A nominal language can say "oh, A and B are obviously different because they came from different places", while a structural language has to break A and B apart into their members, and into the members of their members, and so on, until it finds some differing structure. We can call that a full structural comparison of two types, and while it is sometimes necessary to do that, TypeScript would be almost unusable if that were the only way to compare types.

    Luckily, it's not. There are shortcuts, some of which involve nominal types. Yes, sometimes TypeScript performs nominal type checks.


    One obvious shortcut (that you might not even consider a shortcut) is to say that A is identical to A. You don't have to do a full structural comparison on them, because they are nominally identical. They're the same type before you even look at structure. So nominal equivalence implies structural equivalence.

    Another shortcut involves generic types. If you are comparing F<X> to F<Y> for the same F, then you might be able to just compare X to Y to get the same result. This depends on the generic type F<T> having a known variance. (See Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript for more information about variance.)

    TypeScript lets you manually assign variance markers to generic types via in and out annotations, but even if you don't do that, the compiler will infer such markers internally, depending on the ways the type parameter appears in the structure. (Manually assigned variance markers are just a convenience for performance, relieving the compiler from having to infer them.)

    So, if F<T> is known to be covariant in T, then F<X> extends F<Y> if and only if X extends Y. So there's no point in fully instantiating F<X> and F<Y> and comparing them structurally. The compiler can just reduce the problem to comparing X and Y. Similar things can be said if F<T> is known to be contravariant, or bivariant, or invariant. These speed up type checking for generics immensely.

    Of course sometimes the structure of a generic type is complicated in such a way that the compiler cannot be sure about the variance marker, and either decides that it is unreliable (meaning it will do a followup structural check but only if the comparison fails) or unmeasurable (meaning it will always do a structural check).

    If the compiler always assigned accurate variance markers, then you would rarely notice the difference between a structural check and a nominal check (other than compiler performance), because they'd always give the same results.

    Unfortunately this is not the case.


    The problem is that sometimes the compiler assigns an inaccurate variance marker to a generic type. There are several open issues in GitHub where this is the underlying problem. See microsoft/TypeScript#31251 and microsoft/TypeScript#44945. Apparently, the compiler often misassigns variance markers when the type parameter appears inside conditional types, such as Mode extends 'BUILD' ? string | null : string inside type Data<Mode ⋯> = ⋯.

    And so in your code, the compiler ends up comparing Data<"BUILD"> and Data<"DONE"> by just comparing "BUILD" and "DONE". These literal types are unrelated to each other, so the compiler concludes that Data<"BUILD"> and Data<"DONE"> are also unrelated to each other.

    Oops.


    Presumably if microsoft/TypeScript#44945 is ever fixed, your code would start working as written.

    While it would theoretically always be correct to fix it by just marking things as unmeasurable, doing so isn't always feasible. It tends to make performance worse, as mentioned above. Also, it tends to destabilize a lot of existing code, so even if it does things "correctly", it could do so in a way that has a lot of far-reaching changes that need to be addressed. (For example, it could change the order of operations somewhere and now the compiler sees a circularity issue where there wasn't one before). There was a proposed fix for the issue here at microsoft/TypeScript#43387 which would mark all generic conditional types as unmeasurable, but it was never merged, apparently because of unexpected breakages elsewhere.

    Oh well. Maybe some other more suitable fix will happen someday.