Master TypeScript Discriminated Unions for Type-Safe State Machines
TypeScript discriminated unions help you build predictable state machines. Learn how to implement an exhaustive switch for cleaner, bug-free application logic.
During a recent refactor of a complex checkout flow, I realized we were drowning in optional properties and undefined checks. We had states like loading, error, and success scattered across multiple boolean flags, leading to "impossible" states like loading: true and error: true at the same time. Switching to typescript discriminated unions cleaned up our logic and eliminated an entire class of bugs overnight.
If you’ve ever found yourself writing if (state.data && !state.loading) to convince the compiler your code is safe, you’re fighting the language. Let’s look at how to structure these states properly.
Modeling State with Tagged Unions
The core idea behind tagged unions—or discriminated unions—is to provide a common literal property (the "discriminant") that TypeScript can use to narrow the type of an object. Instead of one massive interface with nullable fields, we define discrete shapes for every possible state.
TYPESCRIPTtype RequestState = | { status: CE9178">'idle' } | { status: CE9178">'loading' } | { status: CE9178">'success'; data: string } | { status: CE9178">'error'; error: Error };
When you check the status property, TypeScript automatically knows which fields are available. You don't need to check if data exists when the status is success. This approach is far superior to loose objects where every property is optional, as discussed in our guide on discriminated unions in TypeScript: modeling state without bugs.
Implementing the TypeScript Exhaustive Switch
Once you have your states defined, the next challenge is ensuring you handle every single one. If you add a new state—say, retrying—your existing logic might silently fail or fall through to a default block. This is where a typescript exhaustive switch becomes your best friend.
We use the never type to force the compiler to complain if we miss a case.
TYPESCRIPTfunction handleState(state: RequestState) { switch (state.status) { case CE9178">'idle': return CE9178">'Waiting...'; case CE9178">'loading': return CE9178">'Fetching...'; case CE9178">'success': return CE9178">`Result: ${state.data}`; case CE9178">'error': return CE9178">`Error: ${state.error.message}`; default: const _exhaustiveCheck: never = state; return _exhaustiveCheck; } }
If you add a new member to the RequestState union but forget to update the switch, the default block will trigger a type error. The compiler will point exactly to the missing case, which is a massive productivity boost during large-scale refactors. This is the same principle I use when managing complex state trees, which I’ve written about in advanced context patterns: scalable state for react dashboards.
Why This Beats Booleans
I used to rely on boolean flags for everything. It seemed easier until the business requirements grew.
| Pattern | Type Safety | Complexity | Maintenance |
|---|---|---|---|
| Boolean Flags | Low | High | Difficult |
| Optional Fields | Medium | Medium | Moderate |
| Discriminated Unions | High | Low | Easy |
By using type narrowing, you remove the need for defensive programming. Your UI components only receive the data they are actually allowed to render, and you eliminate the risk of accessing undefined where it shouldn't be.
Practical Caveats
While powerful, these patterns can become verbose if your state machine has dozens of transitions. I’ve found that when you cross about 6 or 7 states, it’s time to extract your logic into a dedicated reducer or a state machine library like XState. Don’t force your components to handle the logic if it makes them bloated.
I also recommend keeping your unions in a separate file from your UI components. It makes testing easier and prevents circular dependencies. If you’re integrating this into React, ensure your state updates are immutable; it’s tempting to mutate the state object, but that will break your re-renders and lead to hard-to-trace bugs.
I’m still experimenting with using readonly fields in these unions to prevent accidental state mutation. It adds a bit more boilerplate, but in a large team, the extra safety is usually worth the trade-off.
FAQ
What happens if I forget a case in my switch?
Because of the never type assignment in the default block, TypeScript will throw a compile-time error identifying the missing union member.
Can I use discriminated unions with arrays? Yes, but they are most effective on objects. If you have a list of states, you might want to map over them or use a lookup object instead of a switch statement.
Are these patterns overkill for small apps? Maybe. If your state is just a single loading spinner, a simple boolean is fine. But for anything involving data fetching or multi-step forms, the upfront cost of defining the union pays for itself within a week of development.