C.W.K.
Stream
Lesson 05 of 07 · published

Discriminated Unions — When Props Are Mutually Exclusive

~14 min · typescript, discriminated-union, narrowing

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Half of all bad React APIs come from optional props that quietly imply 'sometimes you need these together, never those.' Discriminated unions make those rules explicit and let the compiler enforce them.

The shape

A discriminated union is a union of object types where every variant shares a single literal field (the discriminant) with a different value per variant. TypeScript uses the discriminant to narrow the union — once you check the discriminant in code, the compiler knows which variant you're in.

The classic Button-or-Link problem

You want one component that renders a styled button when you pass onClick, and a styled anchor when you pass href. With optional props you'd get { onClick?: () => void; href?: string } — which compiles even when both are passed (ambiguous) or neither (useless). The discriminated-union version makes the contract exclusive: pick exactly one variant.

Narrowing inside the component

Once you check the discriminant (if (props.variant === 'link')), TypeScript narrows the type — inside that block, href is required, onClick doesn't exist. No type assertions, no !. The compiler walks the union for you.

Where this scales

Beyond Button/Link, discriminated unions are everywhere:

  • Fetch result: { status: 'loading' } | { status: 'error'; error: Error } | { status: 'success'; data: T }.
  • Form state: { phase: 'editing'; draft: ... } | { phase: 'submitting' } | { phase: 'saved'; saved: ... }.
  • Cinder's protocol envelope: { type: 'preview.captured'; payload: ... } | { type: 'generation.candidate_ready'; payload: ... } — exactly the shape packages/protocol in cwkCinder will use.
Make impossible states impossible. A type that allows { loading: true, error: 'oops' } is a type that will eventually produce that bug. Discriminated unions are how you reach into the type system and remove the bug before it's written.

Code

Button-or-Link — the wrong way and the right way·tsx
// WRONG — optional props let callers pass both, neither, or contradictory pairs.
type BadProps = {
  onClick?: () => void;
  href?: string;
  children: React.ReactNode;
};
// <Bad /> — no behavior at all, but compiles.
// <Bad onClick={f} href="/x" /> — both, ambiguous, also compiles.

// RIGHT — discriminated union forces one and only one variant.
type ButtonProps =
  | { variant: "button"; onClick: () => void; children: React.ReactNode }
  | { variant: "link"; href: string; children: React.ReactNode };

export function Button(props: ButtonProps) {
  const base = "inline-flex px-4 py-2 rounded-lg font-medium";
  if (props.variant === "link") {
    // Narrowed: TS knows href is here, onClick isn't.
    return <a href={props.href} className={base}>{props.children}</a>;
  }
  return (
    <button onClick={props.onClick} className={base}>
      {props.children}
    </button>
  );
}

// <Button variant="link" href="/about">About</Button>          ✓
// <Button variant="button" onClick={save}>Save</Button>        ✓
// <Button variant="button">Save</Button>                       ✗ missing onClick
// <Button variant="link" onClick={save}>Save</Button>          ✗ link doesn't take onClick
Fetch state — the audit-gap-grade type that prevents impossible states·tsx
type FetchResult<T> =
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "success"; data: T };

function renderResult<T>(
  result: FetchResult<T>,
  renderData: (data: T) => React.ReactNode
): React.ReactNode {
  switch (result.status) {
    case "loading": return <Spinner />;
    case "error":   return <p className="text-red-500">{result.error.message}</p>;
    case "success": return renderData(result.data);
  }
}

// No '!' assertions. No 'if (loading && error)' contradictions. The compiler
// guarantees every branch is reachable and the others are not.

External links

Exercise

Model a Toast component with three variants — success, warning, error — using a discriminated union. The success variant requires message: string. The warning variant requires message: string and onDismiss: () => void. The error variant requires message: string, error: Error, and onRetry: () => void. Render each variant with appropriate Tailwind colors. Try omitting a required prop for one variant and verify the compiler flags it.
Hint
type ToastProps = { kind: 'success'; ... } | { kind: 'warning'; ... } | { kind: 'error'; ... }. Then if (props.kind === 'error') narrows. The compiler will refuse calls that miss the variant-specific props.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.