Mastering Discriminated Unions in TypeScript for Bulletproof State Management
The Problem: The 'Optional Property' Trap
We've all been there. You're working on a complex feature—maybe a data fetching layer or a multi-step form—and you define an interface that tries to represent every possible state of your application. Usually, it looks something like this:
interface FetchState {
data?: User[];
isLoading: boolean;
error?: string;
}
On the surface, this looks fine. But as a senior dev, this is where I start seeing red flags. This pattern is what I call the "Impossible State" problem. Because every property is technically optional or independently boolean, your type system can't prevent logical nonsense. According to this interface, it is perfectly valid to have isLoading: true and data: [...] at the same time. Or worse, you could have isLoading: false, error: undefined, and data: undefined. Your code is now running in a state that should be physically impossible in your business logic.
When your UI components consume this state, you end up writing defensive, messy code like if (state.data && !state.isLoading) just to satisfy the compiler. That's not type safety; that's just fighting the tool.
The Solution: Discriminated Unions
Instead of creating one giant interface with many optional fields, we should use Discriminated Unions. This technique uses a common literal property (the "discriminant") to allow TypeScript to narrow down exactly which type you are working with in a specific branch of code.
By grouping mutually exclusive states into distinct types, we move the logic from runtime checks into the type system itself. If the state is 'loading', the 'data' property literally doesn't exist in that type's definition. You can't access it, and the compiler won't let you.
// 1. Define specific interfaces for each distinct state
interface IdleState {
status: 'idle';
}
interface LoadingState {
status: 'loading';
}
interface SuccessState {
status: 'success';
data: User[]; // Data is only available when status is 'success'
}
interface ErrorState {
status: 'error';
error: Error; // Error is only available when status is 'error'
}
// 2. Create the Union type
type FetchState = IdleState | LoadingState | SuccessState | ErrorState;
Real-World Implementation: A Robust API Handler
Let's look at how this actually plays out in a production-grade service. Imagine we are building a hook or a service layer that manages user profile loading. Watch how the switch statement or if blocks act as a "type guard," providing full autocomplete and safety.
type User = { id: string; name: string };
type UIState =
| { type: 'IDLE' }
| { type: 'LOADING' }
| { type: 'SUCCESS'; payload: User[] }
| { type: 'ERROR'; message: string };
function renderUI(state: UIState) {
// TypeScript knows that 'state' could be any of the four types
switch (state.type) {
case 'IDLE':
console.log("Waiting for user input...");
break;
case 'LOADING':
// Here, TypeScript knows state is LoadingState.
// Attempting to access state.payload would throw a compile error.
console.log("Fetching data...");
break;
case 'SUCCESS':
// Inside this block, TypeScript 'narrows' the type.
// We have full autocomplete for 'state.payload'
console.log("Data received:", state.payload.map(u => u.name));
break;
case 'ERROR':
// Only here is 'state.message' accessible
console.error("Something went wrong:", state.message);
break;
}
}
// --- Usage Examples ---
// Valid: Success state
renderUI({ type: 'SUCCESS', payload: [{ id: '1', name: 'Alice' }] });
// Valid: Error state
renderUI({ type: 'ERROR', message: '404 Not Found' });
// INVALID: This will cause a TypeScript error!
// @ts-expect-error: Property 'payload' does not exist on type '{ type: 'LOADING'; }'
renderUI({ type: 'LOADING', payload: [] });
Why This Matters for Maintenance
You might think, "This is a lot of extra typing up front. Is it worth it?" In my experience, the answer is a resounding yes, for three main reasons:
- Elimination of Runtime Bugs: You stop writing code that handles "impossible" states. If your logic says you can't be in an error state and a success state simultaneously, your types now enforce that.
- Refactoring Confidence: If you decide to rename
payloadtodata, TypeScript will immediately flag every single place in your entire codebase where that specific state is handled. With optional properties, you'd likely miss a few, leading toundefinederrors in production. - Self-Documenting Code: A junior developer joining your team can look at the
UIStateunion and immediately understand the entire lifecycle of the feature without reading a single line of implementation logic.
Pro Tip: Use string literal types (like 'success' | 'error') rather than enums for your discriminants. String literals are easier to debug in logs and don't require importing an enum object everywhere you use them.
Summary
Stop relying on optional properties to represent state. It's a recipe for undefined errors and fragile logic. By embracing Discriminated Unions, you leverage the full power of TypeScript's type narrowing. You aren't just writing code that works; you're writing code that cannot be used incorrectly. That is the hallmark of senior-level engineering.