typescriptgenericsconditional-typesindexed

Typescript 5.0.3 - Why these types with incompatible generic parameters do not generate an error?


------------- Motivation and context (Not essential to understand the question)----------------

In my typescript app, I have objects with references to other objects. Sometimes these references are populated, sometimes not. Example:

    type Entity = {
        ref1: string | Obj,
        ref2: string | Obj
    }

I wanted to introduce a generic parameter T that allows me to specify which of the props are populated. If I don't pass T, all references need to be strings. If I pass T = {ref1: Obj}, then ref1 needs to be populated, but ref2 must not. And if I pass T = {ref1:Obj, ref2:Obj}, both references need to be populated. The most flexible way to write this turned out to be something like:

type Entity<T extends Partial<{ref1: string|Obj, ref2:string|Obj}> = {}> = {
    ref1: T["ref1"] extends string|Obj ? T["ref1"] : string,
    ref2: T["ref2"] extends string|Obj ? T["ref2"] : string,
}

---------- The question / bug ------------------------

All was working well until I realized a place in my code where typescript should have thrown an error, but didn't. I simplified the above type to investigate better, since I know union types with conditional types can cause unexpected behavior. I came up with this weird result in typescript playground:

--- Minimal reproducible example (Playground link)---

type Entity<T extends {ref1?:unknown}> = {
    ref1: T["ref1"] extends string ? T["ref1"]: number;
}

type A = Entity<{ref1: string}> extends Entity<{}> ? true : false // correctly true
type B = Entity<{ref1: number}> extends Entity<{}> ? true : false // Incorrectly true 
type C = Entity<{ref1: number}> extends Entity<{ref1: undefined}> ? true : false // result: correctly returns false
type D = Entity<{ref1: string}> extends Entity<{ref1: undefined}> ? true : false // result: Incorrectly returns false

But that makes no sense: In type B, Entity<{ref1: number}> simplifies to a type of {ref1: number} whereas Entity<{}> simplifies to a type of {ref1: string} which are incompatible.

When I am more explicit, in type C, typescript understands correctly.

Is there something I don't understand about typescript that explains that behaviour, or is it a typescript bug?


Solution

  • A fundamental feature of TypeScript's type system is that it's structural and not nominal. This is in contrast to type systems like Java's where interface A {} and interface B {} are considered different types because they have different declarations. In TypeScript, when types A and B have the same structure then they are the same type, regardless of their declarations. TypeScript's type system is structural.

    Except when it's not.


    Comparing two types structurally can be expensive for the type checker. Imagine two deeply nested or even recursive types X and Y, and the compiler has to check whether X is a subtype of Y because you are trying to assign a value of type X to a variable of type Y. The compiler needs to start checking each property of Y with that of X, and each subproperty, and each subsubproperty, until it finds an incompatibility or until it reaches a point where it can stop checking (either it reaches the end of the tree structure or it finds something it's already checked). If this were the only type comparison method available to the TypeScript compiler, then it would be very slow.

    So instead the compiler sometimes takes a shortcut. When you define a generic object type, the compiler measures the type's variance (see Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript ) from its definition, and marks it as such for later use. For example in:

    interface Cov<T> { f: () => T }
    interface Contrav<T> { f: (x: T) => void; }
    interface Inv<T> { f: (x: T) => T; }
    interface Biv<T> { f(x: T): void; }
    

    the compiler marks those as covariant, contravariant, invariant, and bivariant, respectively. That makes sense with the type system:

    declare let ca: Cov<"a">
    declare let cs: Cov<string>;
    cs = ca; // okay
    ca = cs; // err
    
    declare let cna: Contrav<"a">
    declare let cns: Contrav<string>;
    cns = cna; // err
    cna = cns; // okay
    
    declare let ia: Inv<"a">
    declare let is: Inv<string>;
    is = ia; // err
    ia = is; // err
    
    declare let ba: Biv<"a">
    declare let bs: Biv<string>;
    bs = ba; // okay
    ba = bs; // okay
    

    When the compiler assigns those markers to a complicated generic type F<T>, a lot of time can be saved when comparing F<X> to F<Y>. Instead of having to plug X and Y into F and then compare the results, it can just compare X and Y and use the variance marker on F to compute the desired result. In such cases, the compiler can just treat F<T> as a black box.

    If you have another complicated generic type G<T>, though, the compiler can't use a shortcut to compare F<X> to G<Y>, since G could be completely unrelated to F. Even if it turns out that F and G's definitions are the same, the compiler wouldn't know that unless it compares those definitions, meaning the black box needs to be opened. A full structural comparison is the only option here.

    So here we have a situation where F<X> extends G<Y> results in one code path for the compiler, but F<X> extends F<Y> results in another code path. That sure looks like the compiler is comparing those types nominally, based on their declarations.

    But such a difference is unobservable, right? Because the type system is completely sound, right? If the compiler assigns a variance marker, it does so correctly, right? And subtyping is a transitive relationship, so if X extends Y and Y extends Z then X extends Z, and if F<T> is marked covariant in T then F<X> extends F<Y> and F<Y> extends F<Z> and also F<X> extends F<Z>, right?

    Right?


    No, of course not, not completely. TypeScript's type system is intentionally unsound in places where convenience has been considered more important. One such place is with optional properties and optional parameters. The compiler lets you assign a value of type {x: string} to a variable of type {x: string, y?: number}, for convenience:

    interface X { x: string }
    interface YN extends X { y?: string }
    let x: X = { x: "" }; 
    let yn: YN = x; // okay, no compiler error
    

    But this is unsafe, because really a missing property definition has a completely unknown value at runtime:

    interface YB extends X { y: boolean }
    const yb: YB = { x: "", y: true }
    x = yb; // okay, every YB is an X
    yn = x; // okay? no compiler error but... wait:
    yn.y?.toUpperCase(); // runtime error 
    

    So you have a situation where YB extends X and X extends YN are true, but YB extends YN is false.

    See microsoft/TypeScript#42479 and the issues linked within for more information.


    If you have a type function F<T> that's marked covariant in T, then what should happen? Presumably F<YB> extends F<X> and F<X> extends F<YN> are true but this can easily be violated if F is indexing into that optional property and doing something with its type. That's more or less what's happening with your code:

    type Entity<T extends { ref1?: string }> = { ref1: T["ref1"] extends string ? 0 : 1 }
    type UsingVarianceMarker = Entity<{ ref1: string }> extends Entity<{}> ? true : false
    //   ^? type UsingVarianceMarker = true
    

    The compiler thinks Entity is covariant (or maybe bivariant) and so the result is true because {ref1: string} extends {}. But indexing into the ref1 property of {} is unknown, and so Entity really shouldn't be covariant (maybe?):

    type Entity2<T extends { ref1?: string }> = { ref1: T["ref1"] extends string ? 0 : 1 }
    type UsingStructural = Entity<{ ref1: string }> extends Entity2<{}> ? true : false
    //   ^? type UsingStructural = false
    

    Maybe the variance marker is being assigned incorrectly? It's hard to say. Given the unsoundness around optional properties, there is probably no "correct" marker and the compiler just picks something that behaves well in a wide range of real-world situations. That's just how it is sometimes; see microsoft/TypeScript#43608 for example.


    So what can be done? Well you could refactor completely, but in cases where you don't like the variance marker assigned by the compiler, you can assign your own (as long as the compiler doesn't see a conflict) using the optional type parameter variance annotations in (for contravariance), out (for covariance), or in out (for invariance). So maybe:

    type Entity<in out T extends { ref1?: string }> = { ref1: T["ref1"] extends string ? 0 : 1 }
    type UsingVarianceMarker = Entity<{ ref1: string }> extends Entity<{}> ? true : false
    //   ^? type UsingVarianceMarker = false
    

    which makes Entity behave as if it were invariant in its parameter, and therefore Entity<T> extends Entity<U> will be false unless T and U are the same type. That's not necessarily what you want to do, and it's not true in general, but at least it's a lever you can pull to change this behavior.

    Playground link to code