I spent three hours debugging a TypeScript error once. The error message was something like "Property 'statusCode' does not exist on type 'APIResponse'." It turned out I was checking the wrong field on the wrong shape. The worst part? The logic was technically correct — just impossible for the type checker to verify because everything was a wide union.
That's when discriminated unions finally clicked for me.
The Problem: Truthiness-Based Narrowing Is Brittle
Here's the kind of code I used to write everywhere:
type APIResponse =
| { data: User[]; error: null }
| { data: null; error: string }
| { data: null; error: null };
Looks fine on the surface. But without a shared literal field on every variant — a discriminant — you end up narrowing by truthiness:
function handleResponse(res: APIResponse) {
// TypeScript narrows this via truthiness — brittle
if (res.data) {
// res is { data: User[]; error: null }
console.log(res.data.length);
}
if (res.error) {
// res is { data: null; error: string }
console.log(res.error.toUpperCase());
}
}
This works until data is an empty array ([] is truthy) or error is null in one variant but undefined in another. Your narrowing silently breaks and you're back to runtime bugs. There's also no clean way to represent an "idle" or "loading" state without adding more overlapping nullable fields.
The Fix: A Single Literal Discriminant Key
The fix is one field — conventionally called kind, type, or status — that every variant shares with a unique literal string value:
type APIResponse =
| { kind: "success"; data: User[] }
| { kind: "error"; statusCode: number; message: string }
| { kind: "loading" }
| { kind: "idle" };
Now each variant has a single, non-nullable, non-overlapping field. TypeScript narrows the entire object when you check that one field:
function handleResponse(res: APIResponse) {
switch (res.kind) {
case "idle":
return null; // res is { kind: "idle" }
case "loading":
return "<loading>"; // res is { kind: "loading" }
case "success":
return res.data.map(u => u.name).join(", "); // res is { kind: "success"; data: User[] }
case "error":
return `Error ${res.statusCode}: ${res.message}`; // res is { kind: "error"; statusCode: number; message: string }
}
}
Every case branch is fully typed. No truthiness traps. If you add a new variant and forget to handle it, TypeScript's switch exhaustiveness check (with never) catches it at compile time.
Real-World: Fetch Handler That Actually Works
Here's the pattern I use daily now — a type-safe fetch wrapper:
type FetchState<T> =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "success"; data: T; cachedAt: Date }
| { kind: "error"; message: string; statusCode: number; retryAfter?: number };
function isStale<T>(state: FetchState<T>): boolean {
if (state.kind !== "success") return true;
// state is narrowed to { kind: "success"; data: T; cachedAt: Date }
return Date.now() - state.cachedAt.getTime() > 60_000;
}
The beauty is that adding or removing a field from one variant never breaks the others. You change the success shape to add cachedAt — every case "success" branch immediately knows about it. No hunting through fifty if (res.data) checks wondering which variant they belong to.
The Bonus: Exhaustive Matching
Pair discriminated unions with a never-check helper and you get compile-time guarantees:
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function renderState<T>(state: FetchState<T>) {
switch (state.kind) {
case "idle": return "Click to load";
case "loading": return "Loading...";
case "success": return `Got ${(state.data as any[]).length} items`;
case "error": return `Error: ${state.message}`;
default: return assertNever(state); // <-- TypeScript error here if we missed a variant
}
}
If someone adds a "refreshing" variant to FetchState, this function won't compile until you handle it.
When I Reach For This Pattern
I use discriminated unions everywhere now — API state, form state, navigation state, WebSocket messages, and especially Redux actions. Anywhere I have a value that can be in multiple distinct modes, I reach for a discriminated union first.
It saved me from that three-hour debugging session and probably a dozen more since. The upfront cost of writing out the types is minimal. The downstream savings in "oh, that's what this value is supposed to look like" are enormous.
Try it next time you write | { type: string } — make it a literal and watch your switch statements come alive.
Top comments (0)