Search code examples
reactjstypescriptreact-typescriptdiscriminated-uniontypescript-types

Using discriminating unions in react props with a default


I have a react component that has 3 variants. I have my props typed like this:

enum VariantType {
  VARIANT_1 = "variant_1",
  VARIANT_2 = "variant_2",
  VARIANT_3 = "variant_3",
}

type BaseProps = {
  a: string;
}

type Variant1Props = BaseProps & {
  variant: VariantType.VARIANT_1;
  b: never;
}

type Variant2Props = BaseProps & {
  variant: VariantType.VARIANT_2;
  b: number;
}

type Variant3Props = BaseProps & {
  variant: VariantType.VARIANT_3;
  b: boolean;
}

export const FancyComponent: FunctionComponent<Variant1Props | Variant2Props | Variant3Props> = (props) => {
  const someNumber = props.variant === VariantType.VARIANT_2 ? props.b : 123; 
   ...
}

In the assignment of someNumber TS knows that b is defined because I checked the variant. When using the component TS complains when b is present when it's VARIANT_1 and it complains when b is not present/not a number when it's VARIANT_2 (same for VARIANT_3).

What I'm looking for now is a way to make the variant option optional when using the component and it should default to VARIANT_1 and you could set it to VARIANT_2 or VARIANT_3 without loosing any of the type-safety I have now.

I tried stuff with generics and so on but couldn't get it to work.

I'm using Typescript 4.5.


Solution

  • Just make the variant property in Variant1Props optional, and it all works:

    enum VariantType {
      VARIANT_1 = "variant_1",
      VARIANT_2 = "variant_2",
      VARIANT_3 = "variant_3",
    }
    
    type BaseProps = {
      a: string;
    }
    
    type Variant1Props = BaseProps & {
      variant?: VariantType.VARIANT_1;
      b: never;
    }
    
    type Variant2Props = BaseProps & {
      variant: VariantType.VARIANT_2;
      b: number;
    }
    
    type Variant3Props = BaseProps & {
      variant: VariantType.VARIANT_3;
      b: boolean;
    }
    
    // to check that props.b actually has type never
    declare function expectTheUnexpected(wtf: never): void;
    
    export const FancyComponent = (props: Variant1Props | Variant2Props | Variant3Props) => {
      const someNumber = props.variant === VariantType.VARIANT_2 ? props.b : 123;
      if (!props.variant) expectTheUnexpected(props.b);
      // ...
    }
    

    And in the Playground.

    But realistically, you don’t want b: never—that is telling TS to expect (and require) an actual property b, but that the value of that property is never. That means that { a: 'foo' } is not a valid Variant1Props, so <FancyComponent a="foo" /> won’t work. Instead, a valid Variant1Props needs to be something like { a: 'foo', b: someVariableWithTypeNever },¹ which is a mess. Really, never is rarely a type you actually expect to work with, it’s supposed to indicate that something went wrong and we’re now in a situation that the type system says can’t happen.

    What you actually want for the definition of Variant1Props is

    type Variant1Props = BaseProps & {
      variant?: VariantType.VARIANT_1;
      b?: undefined;
    }
    

    Now b may or may not actually be present, but if you include it, it’s gotta be just undefined (which means it may as well not be present). So { a: 'foo' } works, but { a: 'foo', b: 42 } doesn’t—42 isn’t undefined. To be allowed to put b: 42, we need to put in variant: VariantType.VARIANT_2.

    1. I.e. the component is generated with <FancyComponent a="foo" b={someVariableWithTypeNever} />—I’m going to stop bringing React into this, it doesn’t affect the question at all.