Problem Solved

TypeScript · discriminated union

Home › Courses › TypeScript › Problems › Discriminated union
🔷 Problem · 6 min read

How to use discriminated unions.
Impossible states, unrepresentable.

A discriminated union is the single most important pattern in TypeScript. It's how you model any "one of these states" data — API responses, form states, Redux actions, UI modes. Every member shares a tag field with a unique literal, and checking that tag narrows to the exact member. This is what makes large app state manageable — impossible states become compile errors, not production bugs.

Short answer: Give every union member a shared literal field (kind, status, type) with a unique value. type Result = { status: 'loading' } | { status: 'success'; data: User } | { status: 'error'; message: string }. Then switch (result.status) narrows to the exact member — you get data only on success, message only on error. Add exhaustive checking with never and the compiler finds every case you forgot.

✅ Solution 1 — Model an API response

The most common real-world use case.

A fetch request has exactly three or four possible states: idle, loading, success, error. Each state has different fields. A discriminated union makes the relationship explicit — you can't accidentally read data while loading, and you can't have loading: true alongside a data field.

api-result.ts
// ─── The discriminated union ─────────────────
interface User { id: string; name: string }

type ApiResult =
| { status: 'idle' }
| { status: 'loading'; startedAt: Date }
| { status: 'success'; data: T }
| { status: 'error'; message: string; retryable: boolean };

// ─── Render function with full narrowing ─────
function render(r: ApiResult): string {
switch (r.status) {
case 'idle':
return 'Ready';
case 'loading':
return Loading since ${r.startedAt.toISOString()}Data: ${JSON.stringify(r.data)}; // ✅ r.data
case 'error':
return Error: ${r.message}`; // ✅ r.message
}
}

// ─── The compiler prevents impossible states ─
// Can't construct loading with data:
// const bad: ApiResult = {
// status: 'loading',
// data: { id: '1', name: 'Alice' }
// // ❌ 'data' not in loading member
// };
//
// Can't read data on loading:
// function f(r: ApiResult) {
// if (r.status === 'loading') return r.data;
// // ❌ Property 'data' does not exist on type '{ status: "loading"; ... }'
// }

// ─── Type guards for reuse ───────────────────
function isSuccess(r: ApiResult): r is { status: 'success'; data: T } {
return r.status === 'success';
}

const result: ApiResult = { status: 'success', data: { id: '1', name: 'Alice' } };

if (isSuccess(result)) {
console.log(result.data.name); // ✅ narrowed to success member
}

console.log(render(result));
function render(r) {
switch (r.status) {
case 'idle': return 'Ready';
case 'loading': return Loading since${r.startedAt.toISOString()}Data: {r.message}`;
}
}

function isSuccess(r) {
return r.status === 'success';
}

const result = { status: 'success', data: { id: '1', name: 'Alice' } };

if (isSuccess(result)) {
console.log(result.data.name);
}

console.log(render(result));

Shared discriminant

status on every member, unique literal per state. That's the field the compiler uses to narrow.

Per-member fields

data only on success. message only on error. Accessing the wrong field is a compile error.

Switch narrows automatically

switch (r.status) — each case narrows r to the exact member. No casts, no assertions.

Generics compose

ApiResult — the success data type is a generic. One result type for every API endpoint.

If your state has more than two related fields, make it a discriminated union. The { loading: boolean; data?: T; error?: Error } shape permits { loading: true, data: user, error: err } — three impossible combinations that the compiler can't catch. A discriminated union makes those combinations unrepresentable. Two-way switches on a single literal are the cleanest way to model any UI state, form state, or request state in a real app. This is the pattern that separates robust codebases from buggy ones.

✅ Solution 2 — Exhaustive checking with never

Catch missing cases at compile time, forever.

When you add a new state to a discriminated union, TypeScript won't automatically warn you about all the places that need updating. Exhaustive checking solves this: force the compiler to verify every member is handled. Adding a new state triggers errors at every place that forgot it — like a compiler-enforced checklist.

exhaustive.ts
// ─── The pattern ─────────────────────────────
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'triangle'; base: number; height: number };

function area(s: Shape): number {
switch (s.kind) {
case 'circle':
return Math.PI * s.radius ** 2;
case 'square':
return s.side ** 2;
case 'triangle':
return (s.base * s.height) / 2;
default:
// s is never here — all cases handled
const _exhaustive: never = s;
return _exhaustive;
}
}

// ─── Add a new member → compile error ────────
type Shape2 =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'triangle'; base: number; height: number }
| { kind: 'rectangle'; width: number; height: number };

function area2(s: Shape2): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
case 'triangle': return (s.base * s.height) / 2;
// forgot rectangle!
default:
// ❌ Type 'Shape2' is not assignable to type 'never'.
// Type '{ kind: "rectangle"; ... }' is not assignable
const _exhaustive: never = s;
return _exhaustive;
}
}

// ─── The helper version ──────────────────────
function assertNever(x: never): never {
throw new Error(Unexpected value: ${JSON.stringify(x)}`);
}

function area3(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
case 'triangle': return (s.base * s.height) / 2;
default:
return assertNever(s); // cleaner
}
}

console.log(area({ kind: 'circle', radius: 2 }));
console.log(area({ kind: 'square', side: 3 }));
function area(s) {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
case 'triangle': return (s.base * s.height) / 2;
default:
const _exhaustive = s;
return _exhaustive;
}
}

function area2(s) {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
case 'triangle': return (s.base * s.height) / 2;
default:
const _exhaustive = s;
return _exhaustive;
}
}

function assertNever(x) {
throw new Error(Unexpected value:${JSON.stringify(x)}`);
}

function area3(s) {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
case 'triangle': return (s.base * s.height) / 2;
default:
return assertNever(s);
}
}

console.log(area({ kind: 'circle', radius: 2 }));
console.log(area({ kind: 'square', side: 3 }));

never as proof

When all cases are handled, s is never. Assigning it proves no cases were missed — it's a compile-time proof of exhaustiveness.

New member = error

Add a case to the union, forget the switch, and the default fails. The compiler finds every place that needs updating.

assertNever helper

A function that takes never. If reached at runtime, throws. Cleaner than inline const _: never in every function.

Works with if-else too

Same pattern with if-else: after all the branches, s is never. Assign to a variable of type never at the end.

✅ Solution 3 — Reducer actions with discriminated unions

The pattern behind Redux, useReducer, and every state machine.

Every action in a reducer is one of a fixed set. Each action has different payload requirements. A discriminated union makes this explicit — type: 'add' requires a by, type: 'reset' requires nothing. Dispatching a wrong shape is a compile error, not a silent no-op.

reducer.ts
// ─── State and actions ───────────────────────
interface State {
count: number;
history: number[];
}

type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'add'; by: number }
| { type: 'reset' }
| { type: 'undo' };

// ─── The reducer ─────────────────────────────
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { count: state.count + 1, history: [...state.history, state.count + 1] };
case 'decrement':
return { count: state.count - 1, history: [...state.history, state.count - 1] };
case 'add':
return { count: state.count + action.by, history: [...state.history, state.count + action.by] };
// ↑ action.by exists only here
case 'reset':
return { count: 0, history: [] };
case 'undo':
const prev = state.history[state.history.length - 2] ?? 0;
return { count: prev, history: state.history.slice(0, -1) };
default:
const _exhaustive: never = action;
return _exhaustive;
}
}

// ─── Dispatch type-safety ────────────────────
function dispatch(action: Action): void {
console.log('dispatch', action);
}

dispatch({ type: 'increment' }); // ✅ no payload
dispatch({ type: 'add', by: 5 }); // ✅ by: number required
dispatch({ type: 'reset' }); // ✅

// ─── Type errors caught at dispatch ──────────
// dispatch({ type: 'add' });
// ❌ Property 'by' is missing
// dispatch({ type: 'reset', by: 5 });
// ❌ Object literal may only specify known properties
// dispatch({ type: 'multiply' });
// ❌ Type '"multiply"' is not assignable to type 'Action["type"]'

// ─── Same shape for Redux ────────────────────
// Redux Toolkit uses exactly this pattern:
//
// const slice = createSlice({
// name: 'counter',
// initialState: { count: 0 } as State,
// reducers: {
// increment: (s) => { s.count++ },
// add: (s, action: PayloadAction) => { s.count += action.payload }
// }
// });
//
// Every action is a discriminated union member —
// action.type narrows to the exact shape.

// ─── useReducer in React ─────────────────────
// The same pattern applies directly:
//
// const [state, dispatch] = useReducer(reducer, { count: 0, history: [] });
// dispatch({ type: 'add', by: 10 });
//
// dispatch is fully typed from the Action union.
// Try dispatching a wrong shape — compile error.

const initial: State = { count: 0, history: [] };
const after1 = reducer(initial, { type: 'increment' });
const after2 = reducer(after1, { type: 'add', by: 10 });
console.log(after2);
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1, history: [...state.history, state.count + 1] };
case 'decrement':
return { count: state.count - 1, history: [...state.history, state.count - 1] };
case 'add':
return { count: state.count + action.by, history: [...state.history, state.count + action.by] };
case 'reset':
return { count: 0, history: [] };
case 'undo':
const prev = state.history[state.history.length - 2] ?? 0;
return { count: prev, history: state.history.slice(0, -1) };
default:
const _exhaustive = action;
return _exhaustive;
}
}

function dispatch(action) {
console.log('dispatch', action);
}

dispatch({ type: 'increment' });
dispatch({ type: 'add', by: 5 });
dispatch({ type: 'reset' });

const initial = { count: 0, history: [] };
const after1 = reducer(initial, { type: 'increment' });
const after2 = reducer(after1, { type: 'add', by: 10 });
console.log(after2);

Actions as union

Each action variant is a member with its own required fields. 'add' requires by: number; 'reset' requires nothing.

Switch narrows the action

switch (action.type) — each case gets the specific action shape. action.by only exists in the 'add' case.

Dispatch is checked

dispatch({ type: 'add' }) fails — by is missing. dispatch({ type: 'multiply' }) fails — not a valid type.

Works with useReducer

Same pattern inside useReducer. dispatch is typed from the Action union. React's built-in reducer support takes this shape directly.

⚠️ Common mistakes

The discriminated union bugs that cost real time.

1. Using optional fields instead

{ loading: boolean; data?: User; error?: Error } allows impossible combinations. Fix: discriminated union — each state is its own shape.

2. Missing the discriminant field

Two objects with no shared literal field can't be narrowed. Fix: add kind, status, or type to every member.

3. Not using exhaustive checking

No never in the default — adding a new state doesn't error. Fix: always include const _: never = x in the default.

4. Using a non-literal discriminant

status: string isn't a discriminant — it's an index signature. Fix: status: 'loading' | 'success' | 'error' — must be literal types.

5. Forgetting to handle a case

switch with a missing case — TypeScript may allow it if no never check. Fix: exhaustive pattern finds these.

6. Casting instead of narrowing

(r as Success).data — bypasses the check. Fix: check r.status === 'success' first. The compiler then narrows without a cast.

7. Using the discriminant in a variable

const s = r.status; if (s === 'success') { r.data } — s being success doesn't narrow r. Fix: check r.status === 'success' directly.

8. Not extracting per-state helpers

Every component doing its own switch is repetition. Fix: extract isSuccess, isError guards — reusable, composable.

❓ Frequently asked

What is a discriminated union in TypeScript?

A discriminated union is a union where every member has a shared property (the discriminant) with a unique literal value. type Result = { status: 'loading' } | { status: 'success'; data: User } | { status: 'error'; message: string }. The status field is the discriminant. Checking result.status === 'success' narrows the type to that specific member.

Why use a discriminated union instead of optional fields?

Optional fields allow impossible combinations. { loading: boolean; data?: User; error?: Error } permits { loading: true, data: user, error: error } — a state that shouldn't exist. A discriminated union makes it unrepresentable: { status: 'loading' } has no data field, so you can't construct that bug.

What is exhaustive checking?

Exhaustive checking ensures you handle every member of a union. In a switch with a case per discriminant, the default branch gets type never. Assigning the value to a never variable proves all cases were handled — and adding a new union member without handling it causes a compile error.

What should I name the discriminant field?

Common conventions: kind (for shapes), status (for state machines), type (for Redux actions), tag (for tagged unions). The name doesn't matter to TypeScript — any field with a unique literal per member works. Pick a name that matches the domain.

🔗 Related

← All TypeScript problems
Problem solved · TypeScript