TypeScript Discriminated Unions: The Pattern That Eliminates a Whole Category of Bugs
3 min read
Every frontend codebase has a component that juggles loading, success, and error state — and most of them do it with a handful of independent variables that can silently contradict each other. Discriminated unions are TypeScript's answer, and once you adopt the pattern, an entire category of bug ("the spinner and the error showed at once") stops being possible. Not unlikely. Impossible — the compiler refuses to build it.
The problem: state that can lie
The standard data-fetching setup:
// ❌ Three independent facts that can disagree
let isLoading: boolean;
let data: User[] | undefined;
let error: string | undefined;Count the combinations: two booleans times two optionals gives eight representable
states — and only three of them are legitimate. isLoading: true with data present?
error set alongside data? Nothing prevents these, so every consumer defensively
re-checks everything:
if (!isLoading && !error && data) {
render(data); // and you'd better repeat this dance everywhere
}The undefined-checks aren't the disease; they're the symptom. The disease is that the type allows states that can't happen.
What a discriminated union actually is
A union of object shapes where every member carries the same literal-typed property — the discriminant — identifying which shape it is:
type FetchState =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; message: string };Read what this encodes: data doesn't exist at all unless status is 'success'.
There is no way to write a value of this type where loading and error coexist, or where
data is present mid-load. The illegal states aren't checked against — they're
unrepresentable.
TypeScript narrows on the discriminant
Because the discriminant is a literal type, checking it teaches the compiler the whole
shape — the same narrowing machinery that powers
typeof checks:
function render(state: FetchState) {
switch (state.status) {
case 'loading':
return <Spinner />;
case 'error':
return <Alert message={state.message} />; // message exists — guaranteed
case 'success':
return <UserList users={state.data} />; // data exists — guaranteed
}
}No optional chaining, no defensive re-checks, no ! assertions. Each branch's body is
checked against exactly the variant that can reach it. And there's a bonus: add a fourth
variant ({ status: 'refreshing'; data: User[] }) and every non-exhaustive switch in
the codebase becomes a compile error — the compiler hands you the complete list of
places that need updating. That's a refactoring safety net you can't get from booleans.
Before and after, in one component
// Before: 8 representable states, 3 legitimate, 5 bugs waiting
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState<User[]>();
const [error, setError] = useState<string>();
// After: 3 representable states, 3 legitimate, 0 contradictions possible
const [state, setState] = useState<FetchState>({ status: 'loading' });
// Transitions become atomic — no "forgot to clear the error" bugs:
setState({ status: 'success', data: users }); // one call, consistent by constructionThe atomic-transition point is underrated: with separate variables, every transition is
a multi-step ritual (setError(undefined); setIsLoading(false); setData(users)) where
forgetting one line creates a lying UI. With the union, a transition is one assignment
and cannot be partial.
Where else the pattern earns its keep
- Form state:
{ status: 'editing' } | { status: 'submitting' } | { status: 'submitted'; receipt: Receipt } | { status: 'invalid'; errors: FieldErrors }— the submit button's disabled logic falls out of the discriminant. - API response types: model your backend's envelope as
{ ok: true; data: T } | { ok: false; error: ApiError }and error-handling stops being optional at compile time. - Anything currently using two booleans in a trench coat —
isOpen/isClosing,isDraft/isPublished. If certain combinations are meaningless, that's the signal.
The design principle underneath is worth stating plainly: make illegal states unrepresentable. It's the cheapest bug prevention TypeScript offers — no runtime cost, no library, just modeling state as what it actually is: one thing at a time.