The Problem: State You Can't Trust
Every developer has written code like this:
interface AsyncState {
loading: boolean
data: User | null
error: Error | null
}
Seems fine. But this type allows combinations that shouldn't exist:
loading: true, data: { ... }โ loading and has data?loading: false, data: null, error: nullโ not loading, no data, no error?loading: true, error: Errorโ loading and errored?
These are impossible states that your code has to defensively handle โ or it crashes.
The Solution: Discriminated Unions
A discriminated union is a union of types that each have a shared literal type field (the discriminant) that TypeScript uses to narrow the type:
type AsyncState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: Error }
Now impossible states are impossible in the type system. You can't have status: 'loading' and data at the same time.
Using Discriminated Unions in Practice
TypeScript narrows the type when you switch on the discriminant:
function renderState(state: AsyncState) {
switch (state.status) {
case 'idle':
return <IdleView />
case 'loading':
return <Spinner />
case 'success':
return <UserCard user={state.data} /> // TypeScript knows data exists here
case 'error':
return <ErrorMessage error={state.error} /> // TypeScript knows error exists
}
}
No data?. optional chaining needed. No if (data !== null) guards. The type tells TypeScript exactly what's available in each branch.
Exhaustiveness Checking
Add a never check to ensure you handle every case:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`)
}
function renderState(state: AsyncState) {
switch (state.status) {
case 'idle': return <IdleView />
case 'loading': return <Spinner />
case 'success': return <UserCard user={state.data} />
case 'error': return <ErrorMessage error={state.error} />
default: return assertNever(state) // Error if you add a new status and forget this function
}
}
Now when you add a new status variant to AsyncState, TypeScript will error in renderState until you handle it. Compile-time exhaustiveness checking.
Real-World Example: API Result Type
type ApiResult<T> =
| { ok: true; data: T; status: number }
| { ok: false; error: string; status: number }
async function fetchUser(id: string): Promise<ApiResult<User>> {
try {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) {
return { ok: false, error: await res.text(), status: res.status }
}
return { ok: true, data: await res.json(), status: res.status }
} catch (e) {
return { ok: false, error: String(e), status: 0 }
}
}
// Using it:
const result = await fetchUser('123')
if (result.ok) {
console.log(result.data.name) // TypeScript knows data exists
} else {
console.log(result.error) // TypeScript knows error exists
}
Beyond Status Fields: Any Literal Works
The discriminant doesn't have to be called status. Any literal type property works:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number }
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2
case 'rect': return shape.width * shape.height
case 'triangle': return 0.5 * shape.base * shape.height
}
}
Combining with Utility Types
// Extract only the success state
type SuccessState = Extract<AsyncState, { status: 'success' }>
// { status: 'success'; data: User }
// Get the data type from success state
type UserData = SuccessState['data']
// User
Key Takeaways
- Discriminated unions make impossible states unrepresentable in the type system
- The discriminant is a shared literal type field (like
status,kind,type) - TypeScript automatically narrows the type in switch/if blocks on the discriminant
- Use
assertNeverfor exhaustiveness checking โ catch missing cases at compile time - This pattern eliminates entire classes of null-check bugs and defensive programming