Discriminated Unions is useful in cases where you have a discriminant, which is a common property with a literal type (like 'idle', 'loading') that exists in every member.
TypeScript will narrow the type based on the discriminator, a common property to discriminate between union members. , making your code much safer.
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: string }
| { status: 'error'; error: Error }
function handleState(state: State) {
switch (state.status) {
case 'idle':
// TypeScript knows state is { status: 'idle' }
break
case 'loading':
// TypeScript knows state is { status: 'loading' }
break
case 'success':
// TypeScript knows state has 'data' property
console.log(state.data.toUpperCase())
break
case 'error':
// TypeScript knows state has 'error' property
console.error(state.error.message)
break
default:
const _exhaustive: never = status
throw new Error(`Unhandled status: ${status}`)
}
}
The most powerful patterns in TypeScript. Discriminated Unions allow you to create a type-safe state machine where the compiler ensures you only access data that actually exists in a given state.
Here is a breakdown of why this pattern is the gold standard for state management.
1. The Core Components
To create a Discriminated Union, you need three things:
- The Members: Individual object types representing different states.
- The Discriminant: A common property with a literal type (like
'idle','loading') that exists in every member. - The Union: A type that combines them using the
|operator.
Example Anatomy
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: string } // 'data' only exists here
| { status: 'error'; error: Error }; // 'error' only exists here
2. Why It Beats "Optional Property" Hell
Without unions, developers often use one giant object with optional properties. This is dangerous because it allows for "impossible states."
| The "Bad" Way (Optional Properties) | The "Good" Way (Discriminated Unions) |
|---|---|
data?: string; error?: Error; |
Data and Error are tied to specific statuses. |
You could accidentally have both data and error at the same time. |
The type system makes it impossible to have data while in an error state. |
Requires constant null checks or "non-null assertions" (!). |
TypeScript narrows the type automatically. |
3. Type Narrowing in Action
As you showed in your post's switch statement, once you check the status property, TypeScript "narrows" the object to that specific member of the union.
function handleState(state: State) {
if (state.status === 'success') {
// Inside this block, TypeScript knows 'data' exists.
// You don't need to check if state.data is undefined.
console.log(state.data);
}
}
4. Exhaustiveness Checking
One of the best "pro tips" for state management is ensuring you've handled every possible state. You can use the never type to catch unhandled cases at compile time:
function handleState(state: State) {
switch (state.status) {
case 'idle': return 'Waiting...';
case 'loading': return 'Loading...';
case 'success': return state.data;
case 'error': return state.error.message;
default:
// If you add a new state like 'processing' later,
// TypeScript will throw an error here because 'processing'
// cannot be assigned to 'never'.
const _exhaustiveCheck: never = state;
return _exhaustiveCheck;
}
}
Summary of Benefits
-
Safety: Prevents accessing
datawhen the app is stillloading. - Clarity: The code serves as documentation for what data is available when.
-
Maintainability: Adding a new state (e.g.,
reconnecting) triggers compiler errors in every function that hasn't accounted for it yet.
Top comments (0)