Using discriminating unions in react props with a default

Viewed 188

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.

2 Answers

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.

Once you destructure your props it's too late to discriminate them together because they're no longer associated to each other. Instead, you need to discriminate the object itself, then destructure. Here are a few ways you can do that:

TS Playground

type AllVariants = Variant1Props | Variant2Props | Variant3Props;

// Example 1
export const FancyComponent1 = (props: AllVariants): ReactElement => {
  const someNumber = props.variant === VariantType.VARIANT_2 ? props.b : 123; // number
  return <div></div>;
}

// Example 2: Using conditional
export const FancyComponent2 = (props: AllVariants): ReactElement => {
  if (props.variant === VariantType.VARIANT_2) {
    const {
      a, // string
      b, // number
      variant, // VariantType.VARIANT_2
    } = props;
  }
  return <div></div>;
}


// Example 3: Extracting conditional in #2 to predicate:
// https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates

function isVariant2 (props: AllVariants): props is Variant2Props {
  return props.variant === VariantType.VARIANT_2;
}

export const FancyComponent3 = (props: AllVariants): ReactElement => {
  if (isVariant2(props)) {
    const {
      a, // string
      b, // number
      variant, // VariantType.VARIANT_2
    } = props;
  }
  return <div></div>;
}
Related