A discriminated union is a union type whose members all share one property, the discriminant, set to a different literal value in each member. Checking that property tells TypeScript exactly which member you're holding. The bug report that sent me looking for it 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.
Quick take: Three booleans (
isLoading,isError,data) can represent sixteen combinations when only three are valid, and TypeScript cannot stop the other thirteen. A discriminated union replaces them with onestatusproperty carrying a distinct literal per state, so the impossible combinations stop being representable.
What Does the Boolean Soup Version Look Like?
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>;
}
Boolean soup is an interface where several independent flags describe one process, and the state.data! non-null assertion above is the tell that it has taken hold. The type system genuinely can't guarantee data exists just because isLoading and isError are both false, because nothing in the type links those three fields together. You know the intended relationship. TypeScript doesn't.
Count the representable values and the problem is arithmetic rather than opinion. Two booleans plus two optional fields give sixteen combinations, of which three are states this component should ever be in. The other thirteen compile perfectly and each one is a bug waiting for the right sequence of setState calls.
The non-null assertion is how teams paper over it, and it works right up until an early return gets added above it, or an error path sets isError back to false without populating data.
What Does the Discriminated Union Version Look Like?
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>;
}
The discriminated union version replaces those flags with one status property carrying a distinct literal per state, and the ! disappears. state.status === 'success' is the discriminant check, and TypeScript narrows state 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 matches no member of the union, so TypeScript rejects it at the point of creation rather than three components downstream when someone reads a stale error field.
That shift, from catching bad states at read time to making them unrepresentable at write time, is the entire value of the pattern. Sixteen representable combinations became three, and the three that remain are exactly the three that mean something.
How Do You Check Exhaustiveness With switch?
Exhaustiveness checking works by assigning the narrowed value to never in the default branch, which fails to compile the moment a union member goes unhandled. Discriminated unions pair naturally with switch for exactly this reason:
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. TypeScript 5.0 onwards reports it as a plain assignability error on the never line, which reads badly the first time and unmistakably every time after.
A discriminated union makes the impossible combinations impossible to construct, which is a stronger guarantee than any amount of careful boolean-setting discipline.
How Does This Apply to Form Submission State?
type SubmitState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'success'; confirmationId: string }
| { status: 'failed'; reason: string; canRetry: boolean };
Form submission is the same shape with four states instead of three, and each member carries only the fields that are meaningful for it. confirmationId doesn't exist unless status is 'success'; canRetry doesn't exist unless it's 'failed'.
Compare that to the flat interface it replaces: isSubmitting, isSuccess, isFailed, confirmationId?, reason?, canRetry?. Six fields, three of them optional, and every optional one carries an unwritten note about which combination of booleans makes it meaningful. That note lives in the head of whoever wrote it and nowhere else.
The union version also survives a redesign better. Adding a 'validating' phase means adding one member, and the exhaustiveness check immediately lists every place that needs updating rather than leaving you to grep for isSubmitting and hope.
When Is a Plain Boolean Still Fine?
A plain boolean is still right when the flag is genuinely independent of everything else. Not every boolean pair is boolean soup. A single flag like disabled: boolean on a button has no other state it's entangled with, so wrapping it in a union buys nothing and costs a layer of indirection.
The pattern applies specifically when two or more flags describe mutually exclusive phases of one process. Loading, error, and success are phases of a single fetch, not independent facts that happen to co-occur. disabled and focused on the same button genuinely are independent: a control can be both, or neither.
The test I use takes about five seconds. Write out every combination of the booleans and ask whether each one describes a real situation. If some rows are nonsense, you have phases and you want a union. If every row makes sense, you have independent flags and booleans are correct.
Which Should You Reach For?
Three terms do the work in this pattern, and they get used interchangeably in a way that hides what is actually happening:
- Discriminant is the shared property every union member declares with a different literal value, conventionally
statusorkind. - Narrowing is what TypeScript does after it sees a check against that discriminant: it discards the members that cannot match.
- Exhaustiveness is the guarantee that every member has been handled, which the
neverassignment turns from a convention into a compile error.
Reaching for one or the other comes down to four questions:
- Do the flags describe phases of one process, or independent facts? Phases want a union.
- Does any field only make sense in some states? An optional field with an unwritten rule attached is the strongest signal.
- Are you writing
!orasto convince the compiler a field exists? That is the type system telling you the model is wrong. - Will the set of states grow? Unions scale by one line; boolean soup scales by doubling.
Question three is the one worth internalising, because it catches the problem without you having to think about it. Every ! and every as in state-handling code is a place where you know something the compiler doesn't, and in almost every case the reason it doesn't know is that the type permits combinations you never intended. Fixing the model deletes the assertion as a side effect rather than as a separate cleanup task.
| 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.
Related Guides
- TypeScript strict mode guide
- TypeScript code smells and anti-patterns
- the satisfies operator coming soon