Back to Blog
TypeScriptJavaScriptJune 20, 20264 min read

Discriminated unions in TypeScript: Modeling state without bugs

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

TypeScriptJavaScriptFrontendArchitectureRefactoring
Evening train at Denver Union Station with iconic sign and transit information display.

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.

The Problem with Optional Properties

When you start a project, it’s tempting to define your state as a single "God object." It looks something like this:

TYPESCRIPT
interface 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.

Modeling State with Discriminated Unions

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.

TYPESCRIPT
type 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'.

Why This Architecture Scales

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.

TYPESCRIPT
function 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.

Trade-offs and Lessons Learned

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.

FAQ: Common Pitfalls

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.

Final Thoughts

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.

Similar Posts