Search code examples
typescripttypescript-typingstypescript-generics

Unwanted intersection of types by transpiler


I have a type that stores a set of discriminated union types:

type M = {
    foo: {a: "foo"};
    bar: {a: "bar"};
};

Now I want to create a function that constructs and returns аn instance of the right type depending on its arguments, like:

const a1 = f1("foo"); // {a: "foo"}
const b1 = f1("bar"); // {a: "bar"}

But seemingly strait forward type assignment fails the type checking:

function f1<T extends keyof M>(a: T): M[T] {
    return {a};
    /*
    Type '{ a: keyof M; }' is not assignable to type 'M[T]'.
        Type '{ a: keyof M; }' is not assignable to type 'never'.
        The intersection '{ a: "foo"; } & { a: "bar"; }' was reduced
         to 'never' because property 'a' has conflicting types in some
         constituents.(2322)
    */
}

However, if I do it this way it works (I still need create new instances, so it doesn't help me):

const v = {
    foo: {a: "foo" as const},
    bar: {a: "bar" as const},
};

type V = typeof v;

function f2<T extends keyof V>(a: T): V[T] {
    return v[a]; // no problem!
}

const a2 = f2("foo"); // {a: "foo"}
const b2 = f2("bar"); // {a: "bar"}

Even though type V is identical to type M above.

To make it even more confusing, this fails too:

const c = {
    foo: {x: {a: "foo" as const}},
    bar: {x: {a: "bar" as const}},
};

function f<T extends keyof M>(a: T): M[T] {
    return c[a].x;
    /*
        Type '{ a: "foo"; } | { a: "bar"; }' is not assignable to type
         'M[T]'.
        Type '{ a: "foo"; }' is not assignable to type 'M[T]'.
            Type '{ a: "foo"; }' is not assignable to type 'never'.
            The intersection '{ a: "foo"; } & { a: "bar"; }' was reduced
             to 'never' because property 'a' has conflicting types in
             some constituents.(2322)
    */
}

Why would TS try to intersect all union types in first and third example, but not in the second? Am I missing something? Is there a way to make it work?

Playground link


Solution

  • The reason why the following works:

    function f2<K extends keyof V>(k: K): V[K] {
        return v[k];
    }
    

    is because you are performing the basic operation known to be compatible with an indexed access type. If you index into a value v of type V with a key k of type K, and you get the indexed access value v[k] of indexed access type V[K].

    Now, as for why your other code does not work:


    TypeScript does not analyze a block of code multiple times. So it can't look at the body of

    function f1<K extends keyof M>(a: T): M[K] {
        return {a}; 
    }
    

    and say, "What if K is "foo"? Okay, that works. Now what if K is "bar"? That works too. That exhausts the possibilities, so that means the whole function is well-typed. I approve." I would call such a type checking algorithm "distributive control-flow analysis", and if the compiler did such things automatically it would cause catastrophic compilation slowdowns, since it would end up analyzing some blocks of code hundreds or thousands of times. There's also no way to opt into such analysis; at one point I had requested this in microsoft/TypeScript#25051, but it was declined.


    TypeScript also cannot deduce patterns in types from scratch. So it can't look at the type M and notice, "Oh hey, look. The type {foo: {a: "foo"}, bar: {a: "bar"}} can be abstracted so that each property P is of the form {a: P}, meaning M[K] is basically the same as {a: K} and I can convert between them at will." When you write return {a}, the compiler knows that this is of type {a: K}, but it does not now that this is the same as M[K]. The connection is beyond the compiler's reasoning abilities.

    So the compiler can only reason like this: "The code needs to return M[K], which is either {a: "foo"} or {a: "bar"} but I don't know which one is correct. The actual return value is of type {a: K}, but I don't see any relationship between that and the desired return value. So the only thing I can be sure is a safe thing to return always is something it is both an {a: "foo"} and an {a: "bar"}, namely the intersection {a: "foo"} & {a: "bar"}. Is {a: K} assignable to that? Nope. (Indeed, nothing is assignable to that.) Too bad. This is broken and I'll report an error."

    The intersection specifically comes from the pull request in microsoft/TypeScript#30769, but that's the basic logic.


    So that's why your broken code is broken. The general inability of the compiler to track correlations between types like this is described in microsoft/TypeScript#30581, and the general approach to fix it is described in microsoft/TypeScript#47109. The fixes involve refactoring to a particular sort of generic approach, where things are represented in terms of basic mapping types, and all your actions are generic indexes into those types or into mapped types over those types.

    Your third function can be made to work merely by explicitly annotating the type of c as a mapped type over M.

    const c: { [K in keyof M]: { x: M[K] } } = {
        foo: { x: { a: "foo" } },
        bar: { x: { a: "bar" } },
    };   
    
    function f<K extends keyof M>(a: K): M[K] {
        return c[a].x; // okay
    }
    

    That's an equivalent type, but the form is different. You're not just hoping that the compiler can notice that c has a relationship with M, you're explicitly writing it out. So now c[a] is known to be of type {x: M[K]} and therefore c[a].x is of type M[K].

    Your first function is a little harder to fix in that there's no simple way to involve M such that the compiler is happy. Instead I'd just say that we will build a distributive object type (read ms/TS#47109 for that term) called N, which captures what { a } will be for a of generic type:

    type N<K extends keyof M> = { [P in K]: { a: P } }[K]
    function f1<K extends keyof M>(a: K): N<K> {
        return { a };
    }
    

    You may or may not be able to use that downstream with things that care about M.


    Playground link to code