Union Types and Discriminated Unions
One Variable, Multiple Possible Types
In JavaScript, a variable can hold different types at different times. A function might return a string on success and null on failure. An API response might be a user object or an error object. TypeScript models this reality with union types.
But raw unions are clumsy. The real power comes from discriminated unions — a pattern that turns TypeScript's type system into a state machine verifier.
Think of a union type as a train station departure board. The board shows multiple possible destinations (types), but at any given moment, only one train is actually departing. TypeScript starts knowing it could be any destination. Narrowing is the act of reading the platform number to determine which specific train it is. Discriminated unions give every train a unique platform number (the discriminant property), so you always know which train you're on.
Basic Union Types
// A value that can be one of several types
type StringOrNumber = string | number;
function format(value: StringOrNumber): string {
// Can't call .toUpperCase() — might be a number
// Can't call .toFixed() — might be a string
// Must narrow first
if (typeof value === "string") {
return value.toUpperCase(); // OK — narrowed to string
}
return value.toFixed(2); // OK — narrowed to number
}
// Union of literals — extremely useful for finite sets
type Direction = "north" | "south" | "east" | "west";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type LogLevel = "debug" | "info" | "warn" | "error";
Discriminated Unions — The Real Power
A discriminated union is a union where every member has a common property (the discriminant) with a unique literal type:
// Each state has a 'status' discriminant with a unique literal value
type RequestState =
| { status: "idle" }
| { status: "loading"; startedAt: number }
| { status: "success"; data: unknown; duration: number }
| { status: "error"; error: Error; retryCount: number };
function renderRequest(state: RequestState) {
switch (state.status) {
case "idle":
return "Ready to fetch";
case "loading":
return `Loading since ${state.startedAt}`; // startedAt available
case "success":
return `Got data in ${state.duration}ms`; // data, duration available
case "error":
return `Error: ${state.error.message} (retry #${state.retryCount})`; // error, retryCount available
}
}
After checking state.status, TypeScript knows exactly which variant you're in and which properties are available. No optional properties, no null checks, no casting.
How the compiler narrows discriminated unions
When TypeScript sees a check on a discriminant property (like state.status === "loading"), it performs control flow analysis. The compiler tracks which branches eliminate which union members:
- Before the check:
stateisRequestState(all four variants) - In the
"loading"case:stateis{ status: "loading"; startedAt: number }(only the loading variant) - After the switch without a match:
stateisnever(all variants eliminated)
This narrowing happens at every control flow branch — if/else, switch, ternary, early returns. The compiler maintains a narrowed type for each variable at each point in the code.
Discriminated Unions as State Machines
This is where discriminated unions shine in production. Instead of boolean flags and optional properties, model your state as a union:
// BAD — boolean soup
interface FormState {
isSubmitting: boolean;
isSuccess: boolean;
isError: boolean;
data?: ResponseData;
error?: Error;
}
// Problem: isSubmitting && isSuccess — is that valid? TypeScript can't tell.
// Problem: data exists when isError is true? No compile-time protection.
// GOOD — discriminated union
type FormState =
| { status: "idle" }
| { status: "validating"; fields: Record<string, string> }
| { status: "submitting"; fields: Record<string, string> }
| { status: "success"; data: ResponseData }
| { status: "error"; error: Error; fields: Record<string, string> };
// Impossible states are literally unrepresentable
// You can never have data AND error simultaneously
// You can never be submitting AND idle simultaneously
The "boolean soup" anti-pattern is the most common state modeling mistake in React applications. Multiple boolean flags create 2^n possible combinations, most of which are invalid. A discriminated union with n variants has exactly n valid states. If your component has isLoading, isError, isSuccess as separate booleans, refactor to a discriminated union immediately.
Exhaustive Checking with never
The never type ensures you handle every variant in a discriminated union:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: 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 "triangle":
return 0.5 * shape.base * shape.height;
default: {
// If all cases are handled, shape is 'never' here
const _exhaustive: never = shape;
return _exhaustive;
}
}
}
// If someone adds a new variant:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number }
| { kind: "rectangle"; width: number; height: number }; // NEW
// The default case now ERROR:
// Type '{ kind: "rectangle"; width: number; height: number }' is not assignable to type 'never'
// ^ Forces you to add the missing case
Extract the exhaustive check into a reusable function for cleaner code:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
// Usage
default:
return assertNever(shape, `Unknown shape kind: ${(shape as any).kind}`);This also provides a runtime safety net — if an impossible value somehow reaches the default case (e.g., from untyped JavaScript), it throws instead of silently returning undefined.
Production Scenario: API Response Handling
// Type-safe API responses with discriminated unions
type ApiResponse<T> =
| { ok: true; data: T; status: number }
| { ok: false; error: { code: string; message: string }; status: number };
async function fetchUser(id: string): Promise<ApiResponse<User>> {
const res = await fetch(`/api/users/${id}`);
const body = await res.json();
if (res.ok) {
return { ok: true, data: body as User, status: res.status };
}
return { ok: false, error: body, status: res.status };
}
// Caller gets exhaustive type safety
async function displayUser(id: string) {
const result = await fetchUser(id);
if (result.ok) {
// result.data is User — no optional chaining needed
console.log(result.data.name);
} else {
// result.error is { code: string; message: string }
console.error(`${result.error.code}: ${result.error.message}`);
}
}
// React component with discriminated union state
type UserPageState =
| { phase: "loading" }
| { phase: "loaded"; user: User; posts: Post[] }
| { phase: "error"; message: string; retry: () => void }
| { phase: "not-found" };
function UserPage({ state }: { state: UserPageState }) {
switch (state.phase) {
case "loading":
return <Skeleton />;
case "loaded":
return <UserProfile user={state.user} posts={state.posts} />;
case "error":
return <ErrorBanner message={state.message} onRetry={state.retry} />;
case "not-found":
return <NotFoundPage />;
}
}
-
Wrong: Modeling state with multiple booleans: isLoading, isError, isSuccess Right: Use a discriminated union with a single status field
-
Wrong: Forgetting the exhaustive never check in switch statements Right: Always add a default case that assigns to never
-
Wrong: Using optional properties instead of separate union variants Right: Put properties on only the variants where they exist
-
Wrong: Using string as the discriminant type instead of string literals Right: Use literal types: status: 'loading' not status: string
Refactor Boolean Soup to Discriminated Union
Show Answer
type ModalState =
| { phase: "closed" }
| { phase: "opening"; content: React.ReactNode }
| { phase: "open"; content: React.ReactNode }
| { phase: "confirming"; content: React.ReactNode; onConfirm: () => void }
| { phase: "error"; content: React.ReactNode; error: string }
| { phase: "closing" };Now every state is explicit. You cannot have content without the modal being open. You cannot be confirming without a confirm handler. You cannot have an error on a closed modal. The type system enforces the valid state transitions, and every switch on phase gives you exactly the properties available in that state.
- 1Discriminated unions model state machines — each variant has a discriminant literal and only the properties valid for that state
- 2Always use exhaustive checking (never in default) to catch unhandled variants at compile time
- 3Replace boolean soup (isLoading, isError, isSuccess) with a single discriminated union — impossible states become unrepresentable
- 4Union members can only be accessed by their shared properties — narrow before accessing variant-specific properties
- 5The discriminant must be a literal type (not just string/number) for narrowing to work