Search code examples
typescripttypingdiscriminated-unionmapped-types

TypeScript: Inference Issue with Mapped Types and Discriminated Unions


I know that much has already been said about typing discriminated unions, but I could not find a solution for my particular case yet. Say I have the following code:

type A = {label: 'a', options: {x: number}; text: string}; // label is intended to act as tag
type B = {label: 'b', options: {y: string}; text: string};
type C = A | B;
type D<T extends C> = {
  label: T['label'];
  displayOptions: T['options'];
  complexValue: T extends B ? T['options']['y'] : never;
};
function f<U extends D<C>>(u: U) {
  if (u.label === 'a') {
    u.displayOptions // is inferred as {x: number} | {y: string} instead of just {x: number}
  }
}

At the commented place, I would expect the type of u.displayOptions to be inferred as {x: number} because label should act as a "tag" as suggested here for a similar problem. But this doesn't work; the type is still {x: number} | {y: string}.

I suspect that is because in the definition of D, I only indirectly use T['label'] and T['options'], since it does work if I would use a property type: T in D instead, and then if (t.type.label === 'a'). However, it seems I cannot do that for the following reasons:

  1. I do not want to use all properties of T (or C) in D (such as text).
  2. Such properties that I do want to use may be renamed (like displayOptions instead of options).
  3. I want to use additional properties that depend on T, like complexValue.

Is there any (preferrably simple) solution that can achieve all of this?


Solution

  • With generics, there is no safe way for the compiler to narrow the type, which is described in ms/TS#33014 since we don't know the passed type exactly. Example:

    function f<T extends 'a' | 'b'>(x: T) {
      if (x === 'a') {
        x; //  T extends "a" | "b"
      }
    }
    

    In reality, generics are not what you need in your use case. What you actually need are distributive conditional types. Unions are distributed when they are checked against some condition (extends something) and to make sure that every member of the union passes the check we need to find some condition that will be always true. For instance T extends any or even T extends T. By distributing, we will re-create the union again with modified/added fields. To remove the unnecessary fields we will use the built-in Omit utility type. Since we are adjusting the members of the union separately, we will also adjust the way an extra field is added:

    type D<T extends C = C> = T extends T
      ? Omit<T, 'options' | 'text'> & {
          displayOptions: T['options'];
        } & (T extends B ? { complexValue: T['options']['y'] } : {})
      : never;
    

    Testing:

    // (Omit<A, "options" | "text"> & {
    //   displayOptions: {
    //       x: number;
    //   };
    // }) | (Omit<B, "options" | "text"> & {
    //   displayOptions: {
    //       y: string;
    //   };
    // } & {
    //   complexValue: string;
    // })
    type Result = D;
    

    Even though the type looks correct it is really hard to read. To fix it we can use Prettify utility type defined in the type-samurai package:

    type Prettify<T> = T extends infer R
      ? {
          [K in keyof R]: R[K];
        }
      : never;
    

    Basically, it creates a copy of the passed type and by using mapped types remaps it, which forces the compiler to remove the unwanted intersection and aliases:

    // {
    //   label: 'a';
    //   displayOptions: {
    //     x: number;
    //   };
    // }
    // | {
    //   label: 'b';
    //   displayOptions: {
    //     y: string;
    //   };
    //   complexValue: string;
    // };
    type Result = Prettify<D>;
    

    Now, you can accept a parameter of type Result and everything should work as expected. Final testing:

    function f(u: Result) {
      if (u.label === 'a') {
        u.displayOptions; // {x: number}
      }
    }
    

    playground