typescriptdiscriminated-union

How to respect discriminated union without redundant type guard?


If I have a function that can take a discriminated union as an argument, how can I assure the type system that those types will be properly discriminated without a redundant type guard? Here's an example:

type DiscriminatedUnion = { arg1: "hello", arg2: "world" } | { arg1: "olleh", arg2: "dlrow" }
function takesEither({ arg1, arg2 }: DiscriminatedUnion) {
    takesEither({ arg1, arg2 }) // <-- error
    if (arg1 === "hello") {
        takesEither({ arg1, arg2 }) // <-- no error
    } else {
        takesEither({ arg1, arg2 }) // <-- no error
    }
}

Is this possible with TypeScript or is this an optimization that can't be bypassed without a type guard or a ts-ignore/ts-expect-error?

For reference, this is the error from above:

Diagnostics:
1. Argument of type '{ arg1: "hello" | "olleh"; arg2: "world" | "dlrow"; }' is not assignable to parameter of type 'DiscriminatedUnion'.
     Type '{ arg1: "hello" | "olleh"; arg2: "world" | "dlrow"; }' is not assignable to type '{ arg1: "olleh"; arg2: "dlrow"; }'.
       Types of property 'arg1' are incompatible.
         Type '"hello" | "olleh"' is not assignable to type '"olleh"'.
           Type '"hello"' is not assignable to type '"olleh"'. [2345]

Solution

  • TypeScript doesn't directly support correlated unions as described in microsoft/TypeScript#30581. If you have a value of a union type and refer to it multiple times, TypeScript does not follow the identity of that value; instead, it just looks at the type, and so it treats the multiple references as multiple values. Your code is equivalent to

    function takesEither(du: DiscriminatedUnion) { }
    
    declare const du: DiscriminatedUnion;
    takesEither({ arg1: du.arg1, arg2: du.arg2 }); // error!
    

    But TypeScript cannot tell the difference between that and

    declare const du1: DiscriminatedUnion;
    declare const du2: DiscriminatedUnion;
    takesEither({ arg1: du1.arg1, arg2: du2.arg2 }); // error
    

    which is a legitimate error, since du1 and du2 might not be of the same union member.

    If you want to write code that works this way, you need to refactor.


    By far the easiest approach here is simply not to destructure your union in the first place. You already have a value of the discriminated union type as a function parameter; just reuse it:

    function takesEither(du: DiscriminatedUnion) {
        takesEither(du); // okay
    }
    

    If you really need to destructure, then you'd need to refactor you code to use generics instead of unions. The recommended technique is described in detail in microsoft/TypeScript#47109. It involves writing your operations in terms of a "base" interface, mapped types over that interface, and generic indexes into those types. For your example it could look like this:

    interface DU {
        hello: "world",
        olleh: "dlrow"
    }
    type DiscriminatedUnion<K extends keyof DU = keyof DU> =
        { [P in K]: { arg1: P, arg2: DU[P] } }[K];
    
    function takesEither<K extends keyof DU>({ arg1, arg2 }: DiscriminatedUnion<K>) {
        takesEither({ arg1, arg2 });
    }
    

    The DU type is the base interface, and DiscriminatedUnion is now a generic type which maps over DU and indexes into that mapped type with a generic key. It has a default type argument of keyof DU and therefore DiscriminatedUnion without a type argument is the same as your original union. You can verify this:

    type Check = DiscriminatedUnion;
    /* type Check = {
        arg1: "hello";
        arg2: "world";
    } | {
        arg1: "olleh";
        arg2: "dlrow";
    } */
    

    And takesEither() is now generic and accepts a DiscriminatedUnion<K>. That means TypeScript sees the arg1 value as type K and the arg2 value as type DU[K], and together TypeScript accepts that those are of type DiscriminatedUnion<K>. Note that takesEither() still only lets you call it with valid inputs:

    takesEither({ arg1: "hello", arg2: "world" }); // okay
    takesEither({ arg1: "olleh", arg2: "dlrow" }); // okay
    takesEither({ arg1: "hello", arg2: "dlrow" }); // error!
    

    This refactoring to generics is probably overkill for the example here, especially if there's no use case for destructuring an object just to repackage it again.

    Playground link to code