How TypeScript guards react components

Some time ago we discussed discriminated unions as a super-feature of TypeScript which helps in keeping business rules straight in the code. Here is how we can transform this idea into React ecosystem. Let’s make sure our components are used in the right way.

Unlimited variants as a highway to disaster

Building generic components in react we have to keep a balance between developer experience and vary of options to customize.

We meet a problem when our component takes too much responsibility, which drives us into bizarre situations, when we have to handle a growing amount of use cases and variants;

It’s time for a sample. Today we consider a banner, which has different styles depending on type:

type Props = {
  type: "success" | "info" | "warning" | "error";
  label: string;
  onRetry?: () => void;
  onDismiss?: () => void;
};

const Banner = (props: Props) => {
  if (props.type === "error" && props.onRetry) {
    return (
      <div className="banner bg-error">
        {props.label}
        <button onClick={props.onRetry}>Retry</button>
      </div>
    );
  }
  if (props.type === "warning" && props.onDismiss) {
    return (
      <div className="banner bg-warning">
        {props.label} <button onClick={props.onDismiss}>x</button>
      </div>
    );
  }
  if (props.type === "success") {
    return <div className="banner bg-success">{props.label}</div>;
  }

  return <div className="banner bg-info">{props.label}</div>;
};

As you may see, it’s not very complex, but at the first glance we can see that typing is not strong. Based on Props we require only label, but other fields are not very intuitive. We cannot be sure when we should pass onRetry or onDismiss.

It increases chaos and complexity in our project. The thing we have to do is to…

…set rules!

Thanks to discriminated unions, we can set strict rules and behaviors of our components. 

Let’s start with a rough plan, how our component should behave: if type of Banner is error, then we should be able to retry if type of Banner is warning, then we should be able to dismiss if type of Banner is success or info we shouldn’t have any additional actions each Banner should have label This is how our plan looks like in the code as Props type:

type Props = {
  label: string;
} & (
  | {
      type: "success" | "info";
    }
  | {
      type: "error";
      onRetry: () => void;
    }
  | {
      type: "warning";
      onDismiss: () => void;
    }
);

We used here three variants, one for each type - which is our differentiator. We also grouped those which have the same API (success and info). In addition, intersection (&) allows us to connect one required field (label).

How will it look like in the component?

const Banner = (props: Props) => {
  switch (props.type) {
    case "error":
      return (
        <div className="banner bg-error">
          {props.label}
          <button onClick={props.onRetry}>Retry</button>
        </div>
      );
    case "warning":
      return (
        <div className="banner bg-warning">
          {props.label}
          <button onClick={props.onDismiss}>x</button>
        </div>
      );
    case "success":
      return <div className="banner bg-success">{props.label}</div>;
    case "info":
      return <div className="banner bg-info">{props.label}</div>;
  }
};

Thanks to switch statement, we could cover all variants and make our code more readable. Discriminated union also drives us to use proper methods in each case. If we use onRetry in the case of success type, then TypeScript compilator would scream that Property 'onRetry' does not exist on type '{ label: string; } & { type: "success" | "info"; }'! How cool is that!

Let’s take a look how can we work with our Button component:

/**
Type '{ type: "info"; onRetry: () => void; }' is not assignable to type 'IntrinsicAttributes & Props'.
Property 'onRetry' does not exist on type 'IntrinsicAttributes & { label: string; } & { type: "success" | "info"; }'
**/
const Sample = () => (
  <Banner label="Info text" type="info" onRetry={() => {}} />
);

We tried to add onRetry to type info, which is an invalid property. TypeScript knows that and doesn’t let us do this.

On the other hand, if we forget about some obligatory props, then TS will gently inform us that something went wrong:

/**
Type '{ type: "error"; label: string; }' is not assignable to type 'IntrinsicAttributes & Props'.
Property 'onRetry' is missing in type '{ type: "error"; label: string; }' but required in type '{ type: "error"; onRetry: () => void; }'
**/
const Sample = () => <Banner label="Error text" type="error" />;

Unions to the rescue… again!

Thanks to that simple trick, you can seal your code and create your design system with confidence, that everyone knows, how to use it. Even if not, TypeScript will see to it on behalf of us.