Discriminated Unions: The TypeScript Feature Most Teams Underuse
Model state with a tag field instead of optional properties, and let the compiler prove your components can never render an impossible combination.
- typescript
- react
- types
Most bugs in a typed codebase are not type errors. They are state errors — a
component that renders a spinner and an error message at the same time, or a
handler that reads data.id on a request that never finished. TypeScript will
happily wave those through, because the shape you gave it allows them.
Discriminated unions close that gap. They are not an advanced trick; they are the default way to model anything that has more than one mode.
The problem with optional properties
Here is a shape almost every codebase has written at some point:
type RequestState = {
loading: boolean
error?: string
data?: Course[]
}Four fields, but far more than four possible values. loading: true with a
populated data and an error is a legal value of this type. So is
loading: false with everything undefined — the state that produces the blank
screen nobody can reproduce. The type does not describe your program. It
describes a superset of your program, and every consumer has to narrow it by
hand with checks the compiler cannot verify.
Add a tag
A discriminated union gives every variant a shared literal field — the discriminant — and lists only the properties that variant actually has.
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; data: Course[] }Four states, exactly four values. There is no way to construct a loading state that carries data, because that object is not assignable to any member.
When you check the discriminant, TypeScript narrows the union to a single member and the rest of the properties become available without a cast:
function CourseList({ state }: { state: RequestState }) {
switch (state.status) {
case 'idle':
case 'loading':
return <Skeleton />
case 'error':
return <ErrorBanner message={state.message} />
case 'success':
return <Grid courses={state.data} />
}
}state.message is only reachable inside the error branch. Try it in the
success branch and the build fails.
Exhaustiveness for free
The real payoff arrives the day you add a fifth state. Assign the narrowed
value to never in the default branch and every unhandled case becomes a
compile error:
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`)
}
default:
return assertNever(state)Add { status: 'refreshing'; data: Course[] } to the union and the compiler
immediately points at every switch that has not been updated. That is a
refactor the type system drives instead of one you drive with grep.
Choosing a discriminant
The discriminant must be a literal type — a string union, a number, or a
boolean. String literals are the pragmatic choice because they survive being
logged and serialised. Use the same key across a codebase (status, kind,
type) so narrowing reads the same everywhere.
Where this pays off in a real app
Three places, immediately:
- Data fetching. Your query hook already returns something like this. Map it to a union at the boundary once, and every component below stops writing defensive checks.
- Form submission. Idle, submitting, server error, field errors, success — five states that are otherwise five booleans and 32 nominal combinations.
- Webhook and event payloads. Stripe and Mux both send a
typefield. That field is a discriminant you get for free; declare the union and let the handler narrow instead of casting the payload.
The rule of thumb is simple: if two optional properties are never both present, you have a union hiding inside a record. Write the union down. The compiler will start finding the bugs you were finding in production.