Search code examples
typescripttypescript-types

Typescript conditional type does not accept value even when all branches do


The following code:

interface Base {}

type Foo<T> =
    T extends "a" ? Base :
    T extends "b" ? Base :
    Base

function run<T>() {
    const x: Foo<T> = {}
}

run<"a">()

gives me an error

Type '{}' is not assignable to type 'Foo<T>'.

However, {} can be assigned to any of the the three branches of Foo<T>.

Moreover, if I remove one of the branches, e.g.

type Foo<T> =
    T extends "a" ? Base :
    Base

then it compiles correctly.

Moreover, if I change the initial example to

interface Base { base: string }

type Foo<T> =
    T extends "a" ? Base :
    T extends "b" ? Base :
    Base

function run<T>(x: Foo<T>) {
    x.base = "a"
}

it compiled normally, i.e. Typescript compiler understands that x.base exists for any value of T.

Why does the initial example not compile, and how can I change it so that it will compile?


Solution

  • Your Foo<T> is a distributive conditional type, where the type being checked is a generic type parameter T. That means it distributes across unions in its type argument, so Foo<X | Y | Z> is evaluated as Foo<X> | Foo<Y> | Foo<Z>.

    When distributive conditional types act on generic type arguments, like Foo<T> inside the run() function, TypeScript just defers evaluation of the type entirely. It doesn't really try to see what values might be assignable to it. It's essentially just an opaque type. So even in the pathologically trivial case that every branch of every conditional type is the same like T extends X ? A : T extends Y ? A : T extends Z ? A : ⋯ : A, TypeScript doesn't even attempt to discover that A is assignable to it. It just says "I don't know what T is, so I don't know what Foo<T> is either".


    Of course, it makes sense that TypeScript could attempt to gather all the branches into an intersection or something and allow assignments if they are assignable to the intersection (e.g., you should usually be able to safely assign a value of type A & B to a variable of type T extends X ? A : B, even if T is an unknown generic, unless T is never but let's not worry about that). Or perform some operation that attempts to verify those generic assignments.

    And indeed, they did implement this in microsoft/TypeScript#30639, which was released with TypeScript 4.3. For a brief glorious time your code worked as you intended. You can check that it fails in TypeScript 4.2 but succeeds in TypeScript 4.3.

    But unfortunately, this had an unacceptable negative impact on compiler performance as reported in microsoft/TypeScript#44851. So the extra type checking for conditional types was rolled back to stop doing it with distributive conditional types, as implemented in microsoft/TypeScript#46429 and released with TypeScript 4.5. You can check that your code succeeds in TypeScript 4.4 but fails in TypeScript 4.5 and beyond. Oh well.


    So it is essentially a design limitation of TypeScript. Even if there is no conceivable way for a type to fail to be assignable to a generic distributive conditional type, TypeScript cannot spend the compile time necessary to verify that.

    The way to fix or work around it depends on your use case. Clearly this is a trivial situation if Foo<T> evaluates to Base no matter what. You can just write type Foo<T> = Base and be done with it. For more complicated cases you might still be able to get away with writing the type in a different way, like

    interface Bar { x: number }
    interface Baz { y: string }
    
    // bad
    type Foo<T> = T extends "a" | "b" ? Bar : Baz
    function run<T>() {
        const x: Foo<T> = { x: 123, y: "abc" } // error!
    }
    
    // good
    type Foo<T> = Partial<Bar & Baz> & (T extends "a" | "b" ? Bar : Baz)
    function run<T>() {
        const x: Foo<T> = { x: 123, y: "abc" } // okay
    }
    

    where presumably you're okay with Foo<T> always being a subtype of Partial<Bar & Baz>.

    But that's not always possible. If all else fails, you might find yourself needing to just assert and move on:

    function run<T>() {
        const x = {} as Foo<T>;
    }
    

    Playground link to code