Discriminated unions cheatsheet.
The pattern
type Result<T> =
| { kind: "ok"; value: T }
| { kind: "err"; error: string };
A union of objects sharing a literal field (the discriminator). TS narrows on it.
Common discriminator names
kind/type/tag/status— pick one per project and stick to it.
Switch on discriminator
function unwrap<T>(r: Result<T>): T {
switch (r.kind) {
case "ok": return r.value;
case "err": throw new Error(r.error);
}
}
Exhaustive check
function f(r: Result<string>): string {
switch (r.kind) {
case "ok": return r.value;
case "err": return `error: ${r.error}`;
default: {
const _: never = r;
throw new Error("unreachable");
}
}
}
Add a variant; TS errors on the _: never line.
Modeling loading state
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function render(s: FetchState<User>) {
switch (s.status) {
case "idle": return null;
case "loading": return <Spinner />;
case "success": return <User user={s.data} />;
case "error": return <Error msg={s.error.message} />;
}
}
Forces you to handle every state. Much safer than { loading: bool; error: Error | null; data: T | null } (16 combos vs 4).
Result type (no exceptions)
type Ok<T> = { kind: "ok"; value: T };
type Err<E> = { kind: "err"; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;
const ok = <T>(v: T): Ok<T> => ({ kind: "ok", value: v });
const err = <E>(e: E): Err<E> => ({ kind: "err", error: e });
function divide(a: number, b: number): Result<number> {
if (b === 0) return err(new Error("div by zero"));
return ok(a / b);
}
const r = divide(10, 2);
if (r.kind === "ok") console.log(r.value);
else console.log(r.error);
Modeling events
type DomEvent =
| { type: "click"; x: number; y: number }
| { type: "keydown"; key: string }
| { type: "scroll"; delta: number };
function handle(e: DomEvent) {
switch (e.type) {
case "click": console.log(e.x, e.y); break;
case "keydown": console.log(e.key); break;
case "scroll": console.log(e.delta); break;
}
}
Adding common fields
type BaseEvent = { id: string; ts: Date };
type AppEvent = BaseEvent & (
| { type: "click"; x: number }
| { type: "scroll"; delta: number }
);
ts-pattern (library)
import { match, P } from "ts-pattern";
const msg = match(result)
.with({ kind: "ok", value: P.select() }, (v) => `got ${v}`)
.with({ kind: "err", error: P.select() }, (e) => `oops ${e}`)
.exhaustive();
Exhaustive matching with destructuring. Worth pulling in for complex unions.
Nested narrowing
type Shape =
| { kind: "circle"; r: number }
| { kind: "poly"; points: Array<{ x: number; y: number }> };
function f(s: Shape) {
if (s.kind === "poly") {
for (const p of s.points) {
console.log(p.x, p.y);
}
}
}
Generic discriminated unions
type Op<T> =
| { kind: "set"; value: T }
| { kind: "merge"; patch: Partial<T> }
| { kind: "delete" };
function apply<T>(state: T, op: Op<T>): T | undefined {
switch (op.kind) {
case "set": return op.value;
case "merge": return { ...state, ...op.patch };
case "delete": return undefined;
}
}
Why prefer over booleans
// BAD: 16 invalid states
type State = { loading: boolean; data?: User; error?: Error };
// GOOD: 4 valid states
type State =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "data"; data: User }
| { kind: "error"; error: Error };
Make illegal states unrepresentable.
Conversion from REST API shapes
API JSON often uses booleans/nulls. Convert at the boundary:
function fromApi(json: any): FetchState<User> {
if (json.error) return { status: "error", error: new Error(json.error) };
if (json.data) return { status: "success", data: json.data };
return { status: "loading" };
}
Common mistakes
- Two different discriminator names —
kindvstype— narrowing breaks. - Discriminator must be a literal type (
"ok", notstring). - Switch without exhaustive check — silent breakage.
- Putting union inside an optional field —
kind?defeats discrimination. - Sharing the same union value across variants — ambiguous.
Read this next
If you want my Result + AsyncResult library, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .