The bug report was simple: a spinner and an error message showing at the same time. The cause was three independent booleans on a fetch state object, isLoading, isError, isSuccess, none of which prevented the other two from also being true. TypeScript didn't catch it because nothing about that shape was actually invalid, to the compiler.
TL;DR: Three independent booleans (
isLoading,isError,data) can represent 2^3 combinations when only 3 or 4 are ever valid, and TypeScript can't stop the invalid ones. A discriminated union, onestatusproperty with distinct literal values per state, makes the impossible combinations impossible to construct, and TypeScript narrows the rest of the object's shape automatically based on which branch of the union you're in.
The Boolean Soup Version
interface FetchState<T> {
isLoading: boolean;
isError: boolean;
error?: string;
data?: T;
}
function ProductList({ state }: { state: FetchState<Product[]> }) {
if (state.isLoading) {return <Spinner />;}
if (state.isError) {return <ErrorMessage message={state.error} />;}
// TypeScript still thinks state.data might be undefined here,
// nothing in the type actually guarantees it exists at this point
return <ul>{state.data!.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
That state.data! non-null assertion is the tell. The type system genuinely can't guarantee data exists just because isLoading and isError are both false, because nothing links those three fields together. You, the developer, know the intended relationship. TypeScript doesn't.
The Discriminated Union Version
type FetchState<T> =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: T };
function ProductList({ state }: { state: FetchState<Product[]> }) {
if (state.status === 'loading') {return <Spinner />;}
if (state.status === 'error') {return <ErrorMessage message={state.error} />;}
// TypeScript now KNOWS state.data exists here, no assertion needed
return <ul>{state.data.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
No !. state.status === 'success' is the discriminant check, and TypeScript narrows state's type to the third union member on that branch, where data is guaranteed to exist and error doesn't. There's also no longer a way to construct { status: 'loading', error: 'oops' }, that object doesn't match any member of the union, so TypeScript rejects it at the point of creation, not three components downstream when someone reads a stale error field that shouldn't be there.
Exhaustiveness Checking With switch
Discriminated unions pair naturally with switch, and TypeScript can verify you've handled every case:
function renderState(state: FetchState<Product[]>) {
switch (state.status) {
case 'loading':
return <Spinner />;
case 'error':
return <ErrorMessage message={state.error} />;
case 'success':
return <ProductGrid products={state.data} />;
default: {
const _exhaustive: never = state; // compile error if a case is missing
throw new Error(`Unhandled status: ${(_exhaustive as FetchState<unknown>).status}`);
}
}
}
The const _exhaustive: never = state line is the trick: if you add a fourth union member, { status: 'idle' }, later, and forget to add a case 'idle': branch, state's narrowed type inside default is no longer never, it's the unhandled member, and the assignment to never fails to compile. That's a compile-time reminder to handle the new case, exactly where you'd otherwise discover the gap at runtime instead.
A Second Example: Form Submission State
type SubmitState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'success'; confirmationId: string }
| { status: 'failed'; reason: string; canRetry: boolean };
Four states, each with only the fields that are actually meaningful for it. confirmationId doesn't exist unless status is 'success', canRetry doesn't exist unless it 'failed'. Compare that to a flat interface with isSubmitting, isSuccess, isFailed, confirmationId?, reason?, canRetry?, six fields where every optional one needs a manual mental note about which combination of booleans makes it meaningful.
When a Plain Boolean Is Still Fine
Not every boolean pair is boolean soup. A single, truly independent flag, disabled: boolean on a button, doesn't need a union, there's no other state it's entangled with. The pattern applies specifically when two or more flags describe mutually exclusive phases of the same process, loading versus error versus success are phases of one fetch, not independent facts that happen to co-occur.
Old Way vs Modern Way
| Boolean flags | Discriminated union | |
|---|---|---|
| Invalid combinations | Compile fine, must be prevented by discipline | Don't exist as constructible values |
| Accessing state-specific fields | Requires ! assertions or manual checks | Automatically narrowed, no assertion needed |
| Adding a new state | Add another boolean, update every check site | Add a union member, switch exhaustiveness flags missed handling |
| Readability | Six-plus loosely related fields | One tagged shape per state |
Conclusion
The bug that started this, a spinner and an error rendering together, wasn't a logic mistake in one component, it was a type shape that allowed an invalid state to exist in the first place. Discriminated unions don't just organize the code better, they remove the invalid combination from the set of values the type system will accept, which is a stronger guarantee than any amount of careful boolean-setting discipline.