TS Discriminated Unions
Discriminated unions are the cleanest way to model "this is one of several variants". A common literal property — the discriminant — lets TypeScript narrow inside a switch with no extra checks.
The pattern
TS
type Shape =
| { kind: 'circle'; r: number }
| { kind: 'rect'; w: number; h: number }
| { kind: 'triangle'; base: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.r ** 2;
case 'rect': return s.w * s.h;
case 'triangle': return 0.5 * s.base * s.height;
}
}
Why this pattern wins
- The discriminant
kindis just a string at runtime — no class or instanceof. - TypeScript narrows automatically inside each case.
- Adding a new variant is one line; the compiler shows every switch you forgot to update.
- Serialises to JSON cleanly — perfect over the wire.
Network state — Loading / Error / Data
TS
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: T };
function render(state: RequestState<User>) {
switch (state.status) {
case 'idle': return 'Press the button.';
case 'loading': return 'Loading…';
case 'error': return state.error; // narrowed — has .error
case 'success': return state.data.name; // narrowed — has .data
}
}
The bad alternative: { data?: User; loading: boolean; error?: string } — every consumer has to check three flags and remember which combinations are legal.
Exhaustiveness check
TS
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.r ** 2;
case 'rect': return s.w * s.h;
case 'triangle': return 0.5 * s.base * s.height;
default: {
const _: never = s; // errors if a new variant slips in
throw new Error('unhandled');
}
}
}
Result type (Rust-style)
TS
type Result<T, E = string> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseId(s: string): Result<number> {
const n = Number(s);
return Number.isInteger(n)
? { ok: true, value: n }
: { ok: false, error: `not an int: ${s}` };
}
Tip: If you ever find yourself writing
x?: T; y?: U; z?: V on a single object, ask if the same data could be a discriminated union. The signature becomes self-documenting and bugs vanish.Example
Example
type Shape =
| { kind: 'circle'; r: number }
| { kind: 'rect'; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.r ** 2;
case 'rect': return s.w * s.h;
}
}
console.log(area({ kind: 'circle', r: 5 }));
console.log(area({ kind: 'rect', w: 4, h: 3 }));
Try it Yourself »
Exercise
Use a literal property as a tag — convention is the field…
type Shape = {
: 'circle'; r: number } | {
: 'rect'; w: number; h: number };
Four letters; same in both blanks.
Discussion
Loading…