Discriminated unions in TypeScript help you model complex state without bugs. Stop using loose objects and start writing type-safe, predictable code today.

I spent three hours debugging a production issue last month that turned out to be a simple "undefined" error. A developer had assumed that if data existed, error must be null, but the API response didn't guarantee that. We’ve all been there: chasing ghosts in objects that have too many optional properties.
Using discriminated unions in TypeScript is the single most effective way I’ve found to kill these classes of bugs. By forcing your data structures to represent only valid states, you move errors from runtime to compile time.
When you start a project, it’s tempting to define your state as a single "God object." It looks something like this:
TYPESCRIPTinterface NetworkState { data?: string; error?: Error; isLoading: boolean; }
This is a trap. You can have a state where isLoading is true and data is present. Or worse, both data and error could be undefined. You end up writing defensive checks everywhere: if (state.data && !state.isLoading). It’s exhausting and prone to human error.
A discriminated union uses a common literal property—the "discriminant"—to tell TypeScript exactly which shape an object currently has. Instead of one big object, we define a set of specific states.
TYPESCRIPTtype NetworkState = | { status: CE9178">'idle' } | { status: CE9178">'loading' } | { status: CE9178">'success'; data: string } | { status: CE9178">'error'; error: Error };
Now, TypeScript knows that if status is 'success', the data property must exist. If status is 'error', you’re guaranteed access to error. You can no longer accidentally access data when the state is 'loading'.
When I refactored a legacy dashboard component using this pattern, the code became self-documenting. I stopped needing to guess what properties were available.
When you combine this with a switch statement, TypeScript performs exhaustiveness checking. If you add a new state, like 'retrying', the compiler will flag every place in your app that hasn't handled that new case yet.
TYPESCRIPTfunction renderState(state: NetworkState) { switch (state.status) { case CE9178">'idle': return CE9178">'Waiting...'; case CE9178">'loading': return CE9178">'Loading...'; case CE9178">'success': return CE9178">`Data: ${state.data}`; case CE9178">'error': return CE9178">`Error: ${state.error.message}`; default: const _exhaustiveCheck: never = state; return _exhaustiveCheck; } }
If you forget to handle a case, the default block throws a compile error. This is a game-changer for team velocity. You don't have to hunt through the codebase to find where a new feature broke; the compiler points you directly to the missing logic.
I initially tried to apply this everywhere. Don't do that. For simple UI toggles or basic configuration objects, standard interfaces are fine. Over-engineering simple data structures just adds boilerplate that nobody wants to maintain.
Also, be careful when integrating with external APIs. If you are consuming a loose JSON response, you still need to validate the data at the boundary. Use a library like Zod to parse the incoming JSON and transform it into your discriminated union. Just like when you are designing a clean service layer in Laravel without over-abstraction, the goal is to keep the boundaries strict while keeping the internal logic simple.
Q: Does this make my code too verbose? It adds a few lines, yes. But you trade "lines of code" for "peace of mind." The time you spend writing the union is time you don't spend debugging production crashes.
Q: Can I use this for complex nested state? Absolutely. You can nest unions within unions. It’s a great way to represent finite state machines in your application, similar to how you manage React props and state: Where your data should live.
Q: What if the API structure changes constantly? If your API is unstable, use a mapping function to convert the raw response into your internal union type. Don't let your internal state model be held hostage by a messy API.
I’m still refining how I handle complex, multi-step state transitions. Sometimes, a full state machine library is overkill, but keeping your types tight remains the best defense against the unknown.
Start small. Find one component in your project that uses a "God object" with optional fields and convert it to a discriminated union. You'll likely find a few bugs in the process—I usually do.
TypeScript narrowing is the key to writing type-safe code without constant casting. Learn how to guide the compiler through your logic for cleaner builds.