Discriminated Unions: Making Impossible States Impossible

The most expensive bugs in React UIs are often not logic errors in isolation—they are illegal combinations of props and state that the runtime happily accepts. A toast that accepts both onRetry and dismissAfter when only one makes sense. A form field that allows type: "select" without options. A modal that can be open while title is missing. Optional props are seductive because they look flexible; in practice they create a combinatorial mess that only tests and production incidents discover.

Discriminated unions (also called tagged unions) let you model variants explicitly. Each variant carries a literal tag—often variant, type, status, or kind—and TypeScript narrows the rest of the object when you branch on that tag. In component terms, you are turning an implicit state machine into an explicit one the compiler can verify.

From optional soup to tagged variants

Consider a notification surface that behaves differently per kind of message. A naive approach piles optional callbacks and flags onto one flat type:

type NotificationPropsBad = {
  variant: "success" | "error" | "warning";
  message: string;
  onRetry?: () => void;
  dismissAfter?: number;
  action?: { label: string; onClick: () => void };
};

Nothing stops a caller from passing variant: "success" with onRetry, or variant: "error" without it—yet the UI might assume onRetry exists. Runtime checks and defensive coding paper over the gap. A discriminated union removes the gap:

type NotificationProps =
  | {
      variant: "success";
      message: string;
      action?: { label: string; onClick: () => void };
    }
  | {
      variant: "error";
      message: string;
      onRetry: () => void;
    }
  | {
      variant: "warning";
      message: string;
      dismissAfter?: number;
    };

Now requiredness is conditional on the tag. Errors must supply onRetry. Success cannot accidentally require it. The “why” is immediate: you have documented the product rules in types, not comments.

Inside the component, a switch on props.variant gives you full narrowing without assertions:

function Notification(props: NotificationProps) {
  switch (props.variant) {
    case "success":
      return (
        <div>
          {props.message}
          {props.action ? (
            <button type="button" onClick={props.action.onClick}>
              {props.action.label}
            </button>
          ) : null}
        </div>
      )
    case "error":
      return (
        <div>
          {props.message}
          <button type="button" onClick={props.onRetry}>
            Retry
          </button>
        </div>
      )
    case "warning":
      return <div>{props.message}</div>
  }
}

If you add a fourth variant later and forget a return branch, noImplicitReturns and exhaustiveness checking (via a never helper) can catch it at compile time:

function assertNever(x: never): never {
  throw new Error(`Unexpected variant: ${x}`);
}

function renderBody(props: NotificationProps) {
  switch (props.variant) {
    case "success":
      return props.message;
    case "error":
      return props.message;
    case "warning":
      return props.message;
    default:
      return assertNever(props);
  }
}

Shared base props with intersections

Real components repeat cross-cutting concerns: label, disabled, error, required. You do not need to duplicate those fields on every variant. Extract a base object type and intersect it with each variant-specific shape:

type BaseFieldProps = {
  label: string;
  error?: string;
  disabled?: boolean;
  required?: boolean;
};

type TextFieldProps = BaseFieldProps & {
  type: "text";
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  maxLength?: number;
};

type SelectFieldProps = BaseFieldProps & {
  type: "select";
  value: string;
  onChange: (value: string) => void;
  options: Array<{ value: string; label: string }>;
};

type DateFieldProps = BaseFieldProps & {
  type: "date";
  value: Date | null;
  onChange: (value: Date | null) => void;
  minDate?: Date;
  maxDate?: Date;
};

type FieldProps = TextFieldProps | SelectFieldProps | DateFieldProps;

The discriminant is type. A select field must include options; a text field cannot accept them without widening the union incorrectly. That is exactly the sort of constraint product designers verbalize (“when it’s a select, we need options”) but that flat optional props fail to enforce.

Rendering stays straightforward:

function Field(props: FieldProps) {
  const { label, error, disabled, required, ...rest } = props

  switch (rest.type) {
    case "text":
      return (
        <label>
          {label}
          <input
            type="text"
            value={rest.value}
            disabled={disabled}
            maxLength={rest.maxLength}
            placeholder={rest.placeholder}
            onChange={(e) => rest.onChange(e.target.value)}
          />
          {error}
        </label>
      )
    case "select":
      return (
        <label>
          {label}
          <select
            value={rest.value}
            disabled={disabled}
            onChange={(e) => rest.onChange(e.target.value)}
          >
            {rest.options.map((opt) => (
              <option key={opt.value} value={opt.value}>
                {opt.label}
              </option>
            ))}
          </select>
          {error}
        </label>
      )
    case "date":
      return (
        <label>
          {label}
          <input
            type="date"
            disabled={disabled}
            onChange={(e) =>
              rest.onChange(e.target.value ? new Date(e.target.value) : null)
            }
          />
          {error}
        </label>
      )
  }
}

Impossible states in local state

Unions are not only for props. The same idea applies to useReducer state or data returned from hooks. A fetch hook that models idle | loading | success | error as one object with every field optional forces you to ask “which fields exist now?” at every read. A discriminated union answers that question structurally:

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function useUser(id: string): AsyncState<{ name: string }> {
  // ...
  return { status: "success", data: { name: "Ada" } };
}

In UI code, state.status === "success" guarantees state.data without optional chaining gymnastics. That pattern is the TypeScript-side expression of making invalid states unrepresentable—a principle that predates React but fits it perfectly.

Practical tradeoffs

Discriminated unions shine when variants have different required data or different behaviors. They are less helpful when variants differ only cosmetically (shared shape, different literals for styling); in those cases a simple variant enum plus one props object may be enough.

Also watch ergonomics at call sites: very wide unions can make JSX noisy. Mitigations include small wrapper components (<SuccessNotification /> that fixes variant) or factory functions that construct the union for you—while keeping the public prop type strict.

Choosing the discriminant name

The tag should be stable and obvious at call sites. Names like variant, type, status, and mode read well in JSX and in reducers. If type is easily confused with an HTML attribute in a given component, prefer fieldKind or inputMode so reviewers do not misread the contract.

Avoid optional discriminants: if the tag can be missing, TypeScript cannot narrow and you return to optional chaining everywhere. When a sensible default exists, encode it in the type (wrapper components or overloads) so “omitted at JSX” still means “well-typed at the boundary.”

Unions and useReducer

Discriminated unions align with useReducer actions. Prefer a union of actions over a loose type: string and payload?: unknown:

type Action =
  | { type: "increment"; by: number }
  | { type: "reset" }
  | { type: "set"; value: number };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "increment":
      return state + action.by;
    case "reset":
      return 0;
    case "set":
      return action.value;
    default:
      return assertNever(action);
  }
}

The same assertNever exhaustiveness check you use in UI applies to reducers when product adds a new action.

When unions meet external APIs

Network payloads are untagged until you parse them. Validate at the boundary (schema, runtime checks), then map into a discriminated union components consume. The union describes trusted in-app state; pretending fetch already returns it removes the guarantee unions exist to provide.

Used deliberately, unions turn your component API into a contract: callers get autocomplete that matches the scenario, reviewers see illegal combinations rejected by the compiler, and refactors fail fast when a variant’s requirements change. That is the senior-level payoff—not clever types for their own sake, but fewer impossible states shipped to users.