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 isvariant. - Inside a
switchorifblock that checks the discriminant, TypeScript narrows the type to the specific union member. In the"error"case,props.retryActionis available because TypeScript knowspropsis{ variant: "error"; message: string; retryAction: () => void }. - The
assertNeverpattern 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 tonever. - Discriminated unions are more type-safe than optional props because they make impossible states unrepresentable. You cannot pass
retryActionto 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 requiredTypeScript Notes
- The discriminant field must be a literal type (string literal, number literal, or boolean literal). A
stringtype field does not work for narrowing. - You can use
inoperator narrowing as an alternative:if ("retryAction" in props)narrows to the error variant. - The
satisfieskeyword can validate that an object matches a union without widening the type.
Gotchas
- Destructuring props before the switch breaks narrowing. Write
switch (props.variant)notconst { variant } = props; switch (variant)-- the latter loses the connection betweenvariantand the rest of props. - Adding a new union member without updating all switch statements is a silent bug unless you use the
assertNeverpattern or enablenoFallthroughCasesInSwitch. - 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 nullinstead ofassertNeverwill not catch missing cases at compile time.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Discriminated unions | Impossible states are unrepresentable | More verbose type definitions |
| Optional props | Simpler type definitions | Allows invalid prop combinations |
| Enum discriminant | Named constants, IDE autocomplete | Enums have runtime overhead, string unions preferred |
| Polymorphic components | Single component, many shapes | Complex type signatures |
| Separate components per variant | Each component is simple and focused | Duplicate 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
switchorifblocks. - Example: in
{ variant: "success" } | { variant: "error" }, the discriminant isvariant.
Why are discriminated unions more type-safe than optional props?
- They make impossible states unrepresentable.
- You cannot pass
retryActionto 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
defaultcase 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
stringornumbertype 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 betweenvariantand the rest ofprops. - TypeScript can no longer narrow
propsto 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.
datais only accessible whenstatusis"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 hasretryAction. - 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
assertNeverto get compile-time exhaustiveness checking.
How does the satisfies keyword work with discriminated unions?
satisfiesvalidates 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
onDismissis required only whendismissibleistrue. - When
dismissibleisfalseor omitted,onDismisscannot 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.