The Single Most Useful Pattern in TypeScript
Discriminated Unions
A union of objects with a shared literal "tag" field. Switching on that field narrows the whole shape — type-safe variants.
What you'll learn
- Author a discriminated union
- Narrow on the discriminator
- Use `never` for exhaustive checks
A discriminated union is a union of object types where each shares a single literal “tag” field. Switching on the tag narrows the rest of the object. This is the most useful TS pattern you’ll learn.
The Shape
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; // s.radius is in scope
case "square":
return s.side ** 2; // s.side is in scope
case "triangle":
return 0.5 * s.base * s.height;
}
} Inside each case, TS narrows s to that specific variant. You
access only the fields that exist for that case.
Why It Beats Optional Fields
Compare:
// ✗ Loose — every field is optional
type Shape = {
kind: "circle" | "square" | "triangle";
radius?: number;
side?: number;
base?: number;
height?: number;
}; You can’t reliably tell which fields go with which kind. A
circle with a side? Compiler allows it. With a discriminated
union, illegal shapes don’t compile.
Common Names
The discriminator field is conventionally type, kind, tag,
status, or similar:
type Event =
| { type: "click"; x: number; y: number }
| { type: "key"; key: string };
type Result<T> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error }; Exhaustive Checks With never
A pattern: assign the un-narrowed value to a never parameter at
the end. If you add a new variant and forget to handle it, the
compiler complains:
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 0.5 * s.base * s.height;
default:
const _exhaustive: never = s; // ✗ if you forgot a case
throw new Error(`Unhandled: ${_exhaustive}`);
}
} In every handled case, s is one specific variant. By
default, s should be never. If you add { kind: "hexagon"; ... }
without handling it, the assignment fails — TS catches the missing
case at compile time.
The Result Pattern
Discriminated unions model “this or that” answers cleanly:
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
async function fetchUser(id: string): Promise<ApiResult<User>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
return { ok: true, data: await res.json() };
} catch (e) {
return { ok: false, error: String(e) };
}
}
const result = await fetchUser("1");
if (result.ok) {
console.log(result.data.name); // ✓ data exists in this branch
} else {
console.log(result.error); // ✓ error exists in this branch
} A cleaner alternative to throwing — errors and successes both have types.
End of Chapter
That wraps the type system essentials. Next chapter: functions and inference in depth.
Function Types →