This is the single most useful modelling pattern in TypeScript: it makes illegal states unrepresentable.
A discriminated union is a union of object types that all share a property — the discriminant or tag — whose type is a different literal in each member:
type Result =
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: string[] };Checking the tag narrows the whole object: inside if (r.status === "success") TypeScript knows r.data exists, and r.error is not even accessible. A switch on the tag is the idiomatic way to handle every case, ideally with an exhaustiveness check in default (assign to never) so that adding a new variant breaks the build in every place that must handle it.
Why this beats a single "bag of optionals" like { loading: boolean; error?: Error; data?: string[] }: that type permits nonsense states (loading and errored, with data) and forces every reader to guess which combinations are real. The union lists exactly the legal states and the data each one carries.
Tips:
- Use string literals for tags;
kind,typeandstatusare common names. Booleans work too (ok: true/ok: false). - Destructuring the tag into a separate
constbefore checking still narrows, as long as both areconst. - The same pattern models messages, actions in reducers, AST nodes, events and API responses.
- Libraries that validate data at runtime (Zod, Valibot) can produce these unions from schemas.
A Result-style type for errors — { ok: true; value: T } | { ok: false; error: E } — is a lightweight way to make failure explicit in function signatures.
Example
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rect"; width: 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 "rect": return shape.width * shape.height;
default: {
const unhandled: never = shape; // compile error if a kind is missed
throw new Error(`Unknown shape: ${JSON.stringify(unhandled)}`);
}
}
}
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function parseAge(input: string): Result<number> {
const n = Number(input);
return Number.isInteger(n) && n >= 0 ? { ok: true, value: n } : { ok: false, error: `bad age: ${input}` };
}
const r = parseAge("42");
console.log(r.ok ? r.value + 1 : r.error, area({ kind: "rect", width: 2, height: 5 }));Remember
- Give each variant a literal tag; checking the tag narrows the whole object
- Add a
nevercheck indefaultto catch unhandled variants - Prefer unions of states over objects full of optional fields
Watch out for
- Making the tag a plain
stringinstead of a literal — then nothing narrows.