React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

typescriptreactunionspattern-matchingexhaustivediscriminant

Discriminated Unions

Recipe

Use discriminated union types to model component props that change shape based on a variant or status field. Achieve exhaustive pattern matching so TypeScript catches missing cases at compile time.

Working Example

// Props that change shape based on a "variant" discriminant
type AlertProps =
  | { variant: "success"; message: string }
  | { variant: "error"; message: string; retryAction: () => void }
  | { variant: "loading" };
 
function Alert(props: AlertProps) {
  switch (props.variant) {
    case "success":
      return <div className="alert-success">{props.message}</div>;
    case "error":
      return (
        <div className="alert-error">
          <p>{props.message}</p>
          <button onClick={props.retryAction}>Retry</button>
        </div>
      );
    case "loading":
      return <div className="alert-loading">Loading...</div>;
  }
}
 
// Usage
<Alert variant="success" message="Saved!" />
<Alert variant="error" message="Failed" retryAction={() => refetch()} />
<Alert variant="loading" />
// Exhaustive check helper
function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}
 
function getStatusColor(props: AlertProps): string {
  switch (props.variant) {
    case "success": return "green";
    case "error": return "red";
    case "loading": return "gray";
    default: return assertNever(props);
    // If you add a new variant and forget to handle it,
    // TypeScript will error on this line.
  }
}

Deep Dive

How It Works

  • A discriminated union has a common literal field (the "discriminant") that TypeScript uses to narrow the type. In AlertProps, the discriminant is variant.
  • Inside a switch or if block that checks the discriminant, TypeScript narrows the type to the specific union member. In the "error" case, props.retryAction is available because TypeScript knows props is { variant: "error"; message: string; retryAction: () => void }.
  • The assertNever pattern catches missing cases at compile time. If you add a new variant to the union but forget to handle it in the switch, TypeScript will error because the new variant type is not assignable to never.
  • Discriminated unions are more type-safe than optional props because they make impossible states unrepresentable. You cannot pass retryAction to a "success" alert.

Variations

Async data pattern:

type AsyncData<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };
 
function UserProfile({ state }: { state: AsyncData<User> }) {
  switch (state.status) {
    case "idle":
      return null;
    case "loading":
      return <Spinner />;
    case "success":
      return <div>{state.data.name}</div>;
    case "error":
      return <div>Error: {state.error.message}</div>;
  }
}

Form field union:

type FormField =
  | { type: "text"; label: string; placeholder?: string }
  | { type: "select"; label: string; options: string[] }
  | { type: "checkbox"; label: string; checked: boolean };
 
function FormFieldComponent({ field }: { field: FormField }) {
  switch (field.type) {
    case "text":
      return <input type="text" placeholder={field.placeholder} />;
    case "select":
      return (
        <select>
          {field.options.map((opt) => <option key={opt}>{opt}</option>)}
        </select>
      );
    case "checkbox":
      return <input type="checkbox" defaultChecked={field.checked} />;
  }
}

Conditional props without a discriminant:

type ModalProps =
  | { dismissible: true; onDismiss: () => void }
  | { dismissible?: false };
 
// TypeScript enforces: if dismissible is true, onDismiss is required

TypeScript Notes

  • The discriminant field must be a literal type (string literal, number literal, or boolean literal). A string type field does not work for narrowing.
  • You can use in operator narrowing as an alternative: if ("retryAction" in props) narrows to the error variant.
  • The satisfies keyword can validate that an object matches a union without widening the type.

Gotchas

  • Destructuring props before the switch breaks narrowing. Write switch (props.variant) not const { variant } = props; switch (variant) -- the latter loses the connection between variant and the rest of props.
  • Adding a new union member without updating all switch statements is a silent bug unless you use the assertNever pattern or enable noFallthroughCasesInSwitch.
  • Discriminated unions only narrow when TypeScript can see the discriminant check. Extracting the check into a separate function requires a type predicate.
  • Using default: return null instead of assertNever will not catch missing cases at compile time.

Alternatives

ApproachProsCons
Discriminated unionsImpossible states are unrepresentableMore verbose type definitions
Optional propsSimpler type definitionsAllows invalid prop combinations
Enum discriminantNamed constants, IDE autocompleteEnums have runtime overhead, string unions preferred
Polymorphic componentsSingle component, many shapesComplex type signatures
Separate components per variantEach component is simple and focusedDuplicate shared logic

FAQs

What is a discriminated union and what is the "discriminant"?
  • A discriminated union is a union type where each member has a common literal field (the discriminant).
  • TypeScript uses the discriminant to narrow the type inside switch or if blocks.
  • Example: in { variant: "success" } | { variant: "error" }, the discriminant is variant.
Why are discriminated unions more type-safe than optional props?
  • They make impossible states unrepresentable.
  • You cannot pass retryAction to a "success" alert -- the type system prevents it.
  • Optional props allow any combination, including invalid ones.
What does the assertNever pattern do and why is it useful?
function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}
  • Placed in the default case of a switch, it catches missing cases at compile time.
  • If you add a new union member but forget to handle it, TypeScript errors because the new type is not assignable to never.
Can the discriminant field be a number or boolean, or must it be a string?
  • The discriminant must be a literal type: string literal, number literal, or boolean literal.
  • A plain string or number type does not work for narrowing.
  • String literals are the most common choice.
Gotcha: Why does destructuring props before the switch break narrowing?
  • Writing const { variant } = props; switch (variant) breaks the connection between variant and the rest of props.
  • TypeScript can no longer narrow props to the correct union member.
  • Always use switch (props.variant) to preserve narrowing.
How do you model async data loading states with a discriminated union?
type AsyncData<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };
  • Each status has exactly the fields it needs.
  • data is only accessible when status is "success".
Can you use the in operator instead of a discriminant field for narrowing?
  • Yes. if ("retryAction" in props) narrows to the union member that has retryAction.
  • This works without a dedicated discriminant field.
  • It only checks property existence, not value type.
Gotcha: What happens if you use default: return null instead of assertNever in a switch?
  • TypeScript will not catch missing cases at compile time.
  • New union members added later will silently fall through to the default case.
  • Use assertNever to get compile-time exhaustiveness checking.
How does the satisfies keyword work with discriminated unions?
  • satisfies validates that an object matches a union without widening its type.
  • The inferred type preserves the specific literal values.
  • Useful for config objects that should conform to a union shape.
How do you type conditional props without a discriminant field?
type ModalProps =
  | { dismissible: true; onDismiss: () => void }
  | { dismissible?: false };
  • TypeScript enforces that onDismiss is required only when dismissible is true.
  • When dismissible is false or omitted, onDismiss cannot be passed.
When would you choose separate components per variant over a single discriminated union component?
  • Separate components are simpler and more focused, with no switch logic.
  • A single component with a discriminated union avoids duplicating shared rendering logic.
  • Choose based on how much logic is shared across variants.