Exhaustive Type Checking with Discriminated Unions
How to use TypeScript discriminated unions for exhaustive type checking, ensuring all cases are handled at compile time with never type assertions.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Discriminated unions in TypeScript combine multiple object types that share a common literal property (the discriminant). When you switch on the discriminant, TypeScript narrows the type to the matching branch. By adding a never check in the default case, you get a compile-time error if a new variant is added but not handled — this is exhaustive type checking.
When to Use
- State machines (idle, loading, success, error) where each state has different data
- Action types in reducers (Redux, useReducer) where each action carries different payloads
- API response handling (success with data, error with message, loading with progress)
- Form validation states (valid, invalid with errors, pending)
- Event handling where different events carry different payloads
When NOT to Use
- Simple boolean states —
isLoading: booleanis sufficient, no need for a union - Optional chaining scenarios — if you just need
data?.value, a union adds complexity - Runtime type checking — discriminated unions are compile-time only; use Zod or io-ts for runtime validation
Solution
Basic discriminated union
type Result<T, E = Error> =
| { status: "success"; data: T }
| { status: "error"; error: E }
| { status: "loading" };
function handleResult(result: Result<string>): string {
switch (result.status) {
case "success":
return result.data.toUpperCase();
case "error":
return result.error.message;
case "loading":
return "Loading...";
}
}
TypeScript narrows result to the matching type in each case branch, so result.data is only accessible in the success case.
Exhaustive checking with never
type Result<T, E = Error> =
| { status: "success"; data: T }
| { status: "error"; error: E }
| { status: "loading" };
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function handleResult(result: Result<string>): string {
switch (result.status) {
case "success":
return result.data.toUpperCase();
case "error":
return result.error.message;
case "loading":
return "Loading...";
default:
return assertNever(result);
}
}
If you add a new variant { status: "idle" } to the union without adding a case, TypeScript errors on assertNever(result) because result is no longer never — it’s { status: "idle" }.
State machine pattern
type FetchState<T> =
| { state: "idle" }
| { state: "loading" }
| { state: "success"; data: T }
| { state: "error"; error: string };
type FetchAction<T> =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: T }
| { type: "FETCH_ERROR"; payload: string }
| { type: "RESET" };
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case "FETCH_START":
return { state: "loading" };
case "FETCH_SUCCESS":
return { state: "success", data: action.payload };
case "FETCH_ERROR":
return { state: "error", error: action.payload };
case "RESET":
return { state: "idle" };
default: {
const _exhaustive: never = action;
throw new Error(`Unhandled action: ${_exhaustive}`);
}
}
}
Using discriminated unions with React
import { useReducer, useEffect } from "react";
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
type AsyncAction<T> =
| { type: "start" }
| { type: "success"; data: T }
| { type: "error"; error: string };
function asyncReducer<T>(state: AsyncState<T>, action: AsyncAction<T>): AsyncState<T> {
switch (action.type) {
case "start":
return { status: "loading" };
case "success":
return { status: "success", data: action.data };
case "error":
return { status: "error", error: action.error };
default: {
const _exhaustive: never = action;
throw new Error(`Unhandled action: ${_exhaustive}`);
}
}
}
function useAsync<T>(fn: () => Promise<T>) {
const [state, dispatch] = useReducer(asyncReducer<T>, { status: "idle" });
useEffect(() => {
dispatch({ type: "start" });
fn()
.then((data) => dispatch({ type: "success", data }))
.catch((error) => dispatch({ type: "error", error: String(error) }));
}, [fn]);
return state;
}
function UserProfile() {
const state = useAsync(() => fetch("/api/user").then((r) => r.json()));
switch (state.status) {
case "idle":
return <div>Click to load</div>;
case "loading":
return <div>Loading...</div>;
case "success":
return <div>{state.data.name}</div>;
case "error":
return <div>Error: {state.error}</div>;
default: {
const _exhaustive: never = state;
return <div>Unknown state</div>;
}
}
}
Discriminated union with shared properties
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "rectangle":
return shape.width * shape.height;
default: {
const _exhaustive: never = shape;
throw new Error(`Unknown shape: ${_exhaustive}`);
}
}
}
Using the satisfies operator (TypeScript 4.9+)
type EventMap =
| { type: "click"; x: number; y: number }
| { type: "scroll"; scrollTop: number }
| { type: "input"; value: string };
const handler = {
click: (e: { x: number; y: number }) => console.log(`Clicked at ${e.x}, ${e.y}`),
scroll: (e: { scrollTop: number }) => console.log(`Scrolled to ${e.scrollTop}`),
input: (e: { value: string }) => console.log(`Input: ${e.value}`),
} satisfies Record<EventMap["type"], (e: any) => void>;
Exhaustive checking with if-else
function handleEvent(event: EventMap): void {
if (event.type === "click") {
console.log(`Clicked at ${event.x}, ${event.y}`);
} else if (event.type === "scroll") {
console.log(`Scrolled to ${event.scrollTop}`);
} else if (event.type === "input") {
console.log(`Input: ${event.value}`);
} else {
const _exhaustive: never = event;
throw new Error(`Unhandled event: ${_exhaustive}`);
}
}
Discriminated union with optional fields
type ApiResponse<T> =
| { status: 200; data: T; cached?: boolean }
| { status: 404; error: string }
| { status: 500; error: string; stack?: string };
function processResponse<T>(response: ApiResponse<T>): T | null {
switch (response.status) {
case 200:
console.log(response.cached ? "From cache" : "Fresh data");
return response.data;
case 404:
console.error("Not found:", response.error);
return null;
case 500:
console.error("Server error:", response.error);
if (response.stack) console.error("Stack:", response.stack);
return null;
default: {
const _exhaustive: never = response;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}
}
Variants
Using discriminated unions with Zod for runtime validation
import { z } from "zod";
const EventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("scroll"), scrollTop: z.number() }),
z.object({ type: z.literal("input"), value: z.string() }),
]);
type Event = z.infer<typeof EventSchema>;
function handleEvent(event: unknown) {
const parsed = EventSchema.parse(event); // Runtime validation
// parsed is now typed as the discriminated union
switch (parsed.type) {
case "click":
console.log(parsed.x, parsed.y);
break;
case "scroll":
console.log(parsed.scrollTop);
break;
case "input":
console.log(parsed.value);
break;
default: {
const _exhaustive: never = parsed;
throw new Error(`Unhandled: ${_exhaustive}`);
}
}
}
Using discriminated unions with arrays
type Task =
| { status: "pending"; id: string; createdAt: Date }
| { status: "in-progress"; id: string; startedAt: Date }
| { status: "done"; id: string; completedAt: Date; result: string };
function getActiveTasks(tasks: Task[]): Task[] {
return tasks.filter(
(task): task is Extract<Task, { status: "in-progress" }> =>
task.status === "in-progress"
);
}
function summarizeTasks(tasks: Task[]): string {
const pending = tasks.filter((t) => t.status === "pending").length;
const inProgress = tasks.filter((t) => t.status === "in-progress").length;
const done = tasks.filter((t) => t.status === "done").length;
return `${pending} pending, ${inProgress} in progress, ${done} done`;
}
Best Practices
-
For a deeper guide, see Complete Guide to TypeScript Advanced Types.
-
Always add a
defaultcase withneverassertion — this catches missing cases at compile time -
Use a single discriminant property name across all variants (
status,type,kind) -
Use string literals for discriminants, not numbers — they’re more readable in error messages
-
Keep the discriminant as the first property in the type for readability
-
Use
satisfiesto ensure an object covers all union variants without losing type information -
Combine with Zod for runtime validation at API boundaries — discriminated unions are compile-time only
Common Mistakes
- Forgetting the
nevercheck: withoutassertNever, adding a new variant doesn’t produce a compile error — the switch silently falls through. - Using non-literal discriminants:
status: stringinstead ofstatus: "success"— TypeScript can’t narrow on arbitrary strings. - Not using
breakorreturnin switch cases: fall-through causes runtime bugs even when types are correct. - Mixing discriminant property names:
{ type: "a" } | { kind: "b" }— TypeScript can’t narrow because there’s no common discriminant. - Not handling the
nevercase in if-else chains: the same exhaustiveness issue applies to if-else as to switch.
FAQ
What is the never type?
never is the type of values that never occur. In exhaustive checking, after all cases are handled, the remaining type is never. If a new variant is added, the remaining type is no longer never, causing a type error.
Can I use discriminated unions with enums?
Yes, but string literals are preferred. Enums add indirection and don’t tree-shake well. Use type Status = "idle" | "loading" | "success" | "error" instead of an enum.
What happens if I don’t add the never check?
TypeScript doesn’t error — the switch just doesn’t have a case for the new variant. The function might return undefined or fall through to the wrong branch. The never check is what makes it exhaustive.
Can I use discriminated unions with generics?
Yes. The generic parameter is preserved through the narrowing:
type Box<T> = { kind: "some"; value: T } | { kind: "none" };
function unwrap<T>(box: Box<T>): T | null {
return box.kind === "some" ? box.value : null;
}
How do discriminated unions differ from regular unions?
Regular unions (string | number) don’t have a discriminant property. TypeScript narrows them with typeof checks. Discriminated unions have a common literal property that TypeScript narrows on with equality checks — this is more ergonomic and supports richer types.
Related Resources
Build Reusable Utility Types with Generics
How to create reusable TypeScript utility types using conditional types, mapped types, template literals, and generic constraints for type-safe APIs.
RecipeWhen to Use useMemo and useCallback
How and when to use React's useMemo and useCallback hooks for performance optimization, and when they add unnecessary overhead.
RecipeForm Validation with react-hook-form and Zod
How to build type-safe forms in React using react-hook-form with Zod schema validation, including nested fields, async validation, and dynamic fields.
RecipeDesign Tokens with CSS Custom Properties
How to build a design token system using CSS custom properties, including color scales, spacing, typography, themes, and responsive scaling.
GuideComplete Guide to TypeScript Advanced Types
Master TypeScript advanced types: conditional types, mapped types, template literal types, infer, distributive types, and type-level programming patterns.