Mastering Discriminated Unions for Type-Safe State Management in TypeScript
The Problem with 'Maybe' Types
I spent a good portion of my early years in TypeScript fighting with interfaces that tried to do too much. We've all been there: you have an object representing an API response or a UI state, and you end up with a bunch of optional properties. Your interface looks something like this:
interface SearchState {
isLoading: boolean;
data?: string[];
error?: string;
query?: string;
}
On the surface, this looks fine. But from a type-safety perspective, it's a nightmare. If isLoading is true, I know data should be null and error should be undefined. However, TypeScript doesn't know that. It sees data and error as optional, meaning I can accidentally write logic that tries to map over data while isLoading is still true, leading to those dreaded runtime errors.
When you use optional properties to represent mutually exclusive states, you aren't actually modeling your logic; you're just describing a collection of possibilities that might or might not coexist. This forces you to write defensive code everywhere—constant null checks, if (data) guards, and as string[] assertions—which defeats the purpose of using a strictly typed language.
Enter: Discriminated Unions
The most effective way to handle this is through Discriminated Unions (also known as Tagged Unions). Instead of one bloated interface with optional fields, we create a set of distinct interfaces that share a common property—the "discriminant"—which acts as a single source of truth for the object's current state.
Let's refactor that messy search state into something robust:
type SearchState =
| { type: 'idle'; query: string }
| { type: 'loading'; query: string }
| { type: 'success'; data: string[]; query: string }
| { type: 'error'; error: string; query: string };
By adding a type literal to every member of the union, we've given the TypeScript compiler a way to "narrow" the type. When we check the value of type, TypeScript automatically understands which specific interface we are working with within that code block.
Real-World Application: Building a Robust Data Fetcher
Imagine you're building a dashboard component that fetches user profiles. You want to ensure that your UI can't accidentally render a "User List" while the system is in an "Error" state. Here is how I implement this pattern in a production environment:
interface User { id: string; name: string; }
// 1. Define the union with a clear discriminant
type FetchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'resolved'; users: User[] }
| { status: 'rejected'; reason: string };
class UserDashboard {
private state: FetchState = { status: 'idle' };
async loadUsers() {
// Transition to loading
this.state = { status: 'loading' };
try {
const response = await fetch('/api/users');
const data: User[] = await response.json();
// Transition to success
this.state = { status: 'resolved', users: data };
} catch (err) {
// Transition to error
this.state = {
status: 'rejected',
reason: err instanceof Error ? err.message : 'Unknown error'
};
}
this.render();
}
render() {
// 2. Use a switch statement to narrow the type
// TypeScript will provide autocomplete based on the status
switch (this.state.status) {
case 'idle':
console.log('Waiting for user interaction...');
break;
case 'loading':
console.log('Fetching data...');
break;
case 'resolved':
// Inside this block, 'users' is guaranteed to exist
console.log('Users loaded:', this.state.users.map(u => u.name));
break;
case 'rejected':
// Inside this block, 'reason' is guaranteed to exist
console.error('Error fetching users:', this.state.reason);
break;
}
}
}
const dashboard = new UserDashboard();
dashboard.loadUsers();
Why This Matters for Long-Term Maintenance
You might think, "This is a lot of typing just to avoid a few null checks." But as a senior developer, I look at it differently. I look at exhaustiveness and predictability.
- Exhaustiveness Checking: If you add a new state to your union—say,
'paused—TypeScript will actually yell at you in yourswitchstatement if you haven't handled that new case. This prevents a class of bugs where new features are added but old logic paths are forgotten. - Eliminating Impossible States: In the old interface, it was theoretically possible to have
{ isLoading: true, error: 'Failed' }. In a Discriminated Union, that state is structurally impossible to represent. You cannot have astatus: 'loading'and areason: stringin the same object. - Developer Experience (DX): When you're typing
this.state.inside acase 'resolved':block, your IDE won't suggestreasonorisLoading. It only suggestsusers. This reduces cognitive load and prevents typos.
Pro Tip: To enforce exhaustiveness, use thenevertype. If you add a case to your switch that doesn't handle a new union member, assigning the variable toneverwill trigger a compile-time error.
default: {
const _exhaustiveCheck: never = this.state;
return _exhaustiveCheck;
}
By adopting this pattern, you move the complexity from your runtime logic into your type system. You spend more time thinking about your data structures upfront, which saves you countless hours of debugging "undefined is not a function" errors in production later.