Oscar te Giffel®
← Writing

Better React Props.

Using discriminated unions to make your React props type-safe and impossible to misuse.

cover

Discriminated unions are underused in React. I see it all the time — components with optional props that shouldn’t be combined, but nothing stops you from doing it anyway. Here’s how to fix that.

Consider this component:

type Props = {
  onClick: () => void;
};

function Button(props: Props): JSX.Element {
  return <button onClick={props.onClick}>Click me</button>;
}

Simple enough. But what if we also want it to render as an anchor tag when given an href? The naive approach:

type Props = {
  onClick?: () => void;
  href?: string;
};

function Button(props: Props): JSX.Element {
  if (props.href) {
    return (
      <a href={props.href} onClick={props.onClick}>
        Click me
      </a>
    );
  }

  if (props.onClick) {
    return <button onClick={props.onClick}>Click me</button>;
  }

  return null;
}

This works but it’s not great. You have to check each prop manually, and nothing stops you from passing both onClick and href at the same time. Discriminated unions fix this:

type Props =
  | {
      type: "button";
      onClick: () => void;
    }
  | {
      type: "link";
      href: string;
    };

// Same button implementation as before
// However, when using this component and passing both props, an error will be raised
<Button type="button" onClick={() => {}} href="/home" />;

Now TypeScript enforces the constraint. You can’t pass both onClick and href. The consumer knows exactly which props are valid for each variant. Mistakes are caught at compile-time, not runtime.

Using Discriminated Unions on React Reducers

The same pattern works great for reducers:

type State =
  | { status: "pending" }
  | { status: "loading" }
  | { status: "success"; data: Book[] }
  | { status: "error"; error: any };

type Action =
  | { type: "FETCH_PENDING" }
  | { type: "FETCH_LOADING" }
  | { type: "FETCH_SUCCESS"; payload: Book[] }
  | { type: "FETCH_ERROR"; payload: any };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "FETCH_PENDING":
      return { status: "pending" };
    case "FETCH_LOADING":
      return { status: "loading" };
    case "FETCH_SUCCESS":
      return { status: "success", data: action.payload };
    case "FETCH_ERROR":
      return { status: "error", error: action.payload };
    default:
      return state;
  }
}

// Usage
const initialState: State = { status: "pending" };
const [state, dispatch] = useReducer(reducer, initialState);

// Dispatch actions
dispatch({ type: "FETCH_PENDING" });
dispatch({ type: "FETCH_LOADING" });
dispatch({ type: "FETCH_SUCCESS", payload: books });
dispatch({ type: "FETCH_ERROR", payload: error });

Each state variant only carries the data it needs. When the status is "success", data exists. When it’s "error", error exists. TypeScript narrows the type based on the discriminant, so you get autocomplete and type safety in your switch branches for free.

The pattern is simple but the guarantee of type safety is what matters. Invalid states become unrepresentable.

Useful Sources