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.