typescriptparametersdiscriminated-union

How can I include two separate unions in a Typescript object function argument and allow properties with values that overlap both?


playground link

type Props = (
  | {
    endpoint: 'constant-a' | 'constant-b';
    property: object
  }
  | {
    endpoint?: 'constant-c';
    property?: object
  }
) & ({
  isMulti: true
  property2: object
} | {
  isMulti?: false
  property2?: object
});


function breaks(props: Props) {
    return null;
}

const endpoint: 'constant-a' | 'constant-b' | 'constant-c' = '' as any;

// this breaks, but shouldn't. Since property and property2 are provided,
// any of the possible values of endpoint should be fine.
breaks({ endpoint, property: {}, property2: {} });

// this is fine.
breaks({ endpoint: 'constant-c' });

// this is _expected_ to break since property is missing.
breaks({ endpoint: 'constant-a' });

The message is:

Argument of type '{ endpoint: "constant-a" | "constant-b" | "constant-c"; }' is not assignable to parameter of type 'Props'.
  Type '{ endpoint: "constant-a" | "constant-b" | "constant-c"; }' is not assignable to type '{ endpoint?: "constant-c" | undefined; } & { isMulti?: false | undefined; }'.
    Type '{ endpoint: "constant-a" | "constant-b" | "constant-c"; }' is not assignable to type '{ endpoint?: "constant-c" | undefined; }'.
      Types of property 'endpoint' are incompatible.
        Type '"constant-a" | "constant-b" | "constant-c"' is not assignable to type '"constant-c" | undefined'.
          Type '"constant-a"' is not assignable to type '"constant-c"'.(2345)
(property) endpoint: "constant-a" | "constant-b" | "constant-c"

How can we support the behavior of the second and third calls to breaks() (where the second should succeed and the third should fail), while also supporting the overlap in the first call to breaks() (which should not fail).


Solution

  • TypeScript cannot, in general, relate source objects with union-typed properties to arbitrary target unions of objects with non-union properties. For example, every value of type {x: A | B, y: C | D, z: E | F} should be assignable to the union {x: A, y: C, z: E} | {x: B, y: C, z: E} | {x: A, y: D, z: E} | {x: B, y: D, z: E} | {x: A, y: C, z: F} | {x: B, y: C, z: F} | {x: A, y: D, z: F} | {x: B, y: D, z: F}, but it's a lot of work for the type checker to verify that. If I made a mistake on one of those union members, or left one out, then it wouldn't be true anymore.

    TypeScript 3.5 introduced some support for this type of checking, as implemented in microsoft/TypeScript#30779, but it's fragile and limited in scope.

    It looks like you've hit one such limitation, as described in microsoft/TypeScript#58432, where the discriminant property of a discriminated union is optional.


    According to a comment from @RyanCavanaugh, "there's almost always a better way to write the target type". So let's see if we can do that with your example.

    Your original type is

    type Props =
      (
        { endpoint: 'constant-a' | 'constant-b'; property: object } |
        { endpoint?: 'constant-c'; property?: object }
      ) & (
        { isMulti: true; property2: object } |
        { isMulti?: false; property2?: object }
      );
    

    which is equivalent to a union of four members (distributing the intersection over the union). And you're assigning a value of type {endpoint: 'constant-a' | 'constant-b' | 'constant-c'; property: object; property2: object} to it, which is assignable to none of the four union members of Props. Since we expect to be able to make such an assignment, we should try to see if we can take one of those union members and make it accept the assignment without also accepting unwanted assignments.

    I'd suggest something like

    type Props =
      (
        { endpoint: 'constant-a' | 'constant-b' | 'constant-c'; property: object } |
        { endpoint?: 'constant-c'; property?: object }
      ) & (
        { isMulti: boolean; property2: object } |
        { isMulti?: false; property2?: object }
      );
    

    where I've widened some of union members to overlap other ones. I don't think this allows anything new: adding 'constant-c' as a possible endpoint to 'constant-a' | 'constant-b' only accepts things already accepted by the existing endpoint?: 'constant-c' member; and adding false as a possible isMulti to true only accepts things already accepted by the existing isMulti?: false member.

    Now it works as you intend:

    const endpoint: 'constant-a' | 'constant-b' | 'constant-c' = '' as any;
    breaks({ endpoint, property: {}, property2: {} }); // okay
    breaks({ endpoint: 'constant-c' }); // okay
    breaks({ endpoint: 'constant-a' }); // error
    

    So that answers the question as asked.


    There might be some more general situation where you can't just rewrite the union without breaking a constraint, but if you plan to make a single assignment of a single source object with independent union-typed properties, then you can always find a way to make it work.

    At the very least you should be able to add a union member corresponding to your assignment. That is, in general: if you have a value of type X and you're sure it should be assignable to type Y = A | B | C | D, but TypeScript doesn't think so, then you should be able to write type Y = A | B | C | D | X without changing things. After all, X is already supposed to be assignable to Y, so by adding X you're just being redundant, not permissive.

    The only way it wouldn't work is if you either did conditional assignments, or a single assignment of dependent/correlated union-typed properties, where TypeScript can't keep track of the precise type of your object. (Like, if you wrote const v = Math.random() < 0.5 ? "a" : 1; const obj = {x:v, y:v}, then TypeScript would see this as {x: string | number; y: string | number} and not {x: string, y: string} | {x: number, y: number}.) But that doesn't seem to be what you're doing in this question, so it's probably out of scope here.

    Playground link to code