Discriminated unions
Models a set of mutually exclusive shapes with a shared literal tag the compiler can switch on.
type Shape = { kind: "circle"; radius: number } | { kind: "square"; sideLength: number }- Added in TypeScript 2.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$kind | "circle" | "square" | The discriminant: a property present on every member, whose type in each member is a distinct singleton (string, number, boolean or enum literal). The name is conventional, not special. |
$members | Circle | Square | The union members. Each may carry any additional properties; they become reachable only inside the matching branch. |
Errors
Nothing at runtime. tsc errors when you read a property that only exists on some members before narrowing.
Changelog
- PHP 2.0Tagged union types introduced: a check against the discriminant narrows the whole union.
- PHP 3.2Object literals of a discriminated union type can be spread and destructured while keeping the discriminant.
- PHP 4.4Extracting the discriminant into a const (`const { kind } = shape;`) still narrows the object it came from.
- PHP 4.6Dependent parameter destructuring: destructuring a discriminated union in a parameter list correlates the tag with the payload.
A discriminated union gives every member of a union the same property with a different literal type. Test that one property and the compiler narrows the whole object, including all its other fields.
Why it beats the alternatives
The usual first attempt is one interface with everything optional: { kind: string; radius?: number; sideLength?: number }. That type permits a circle with a side length, a square with neither, and forces a non-null assertion at every use. A discriminated union makes those states unrepresentable — the payload only exists on the branch that owns it.
Rules that matter
- The discriminant must be a literal type in each member.
kind: stringnarrows nothing; writekind: "circle". - It must be present on every member. An optional discriminant defeats the analysis.
- Boolean and numeric literals work too —
{ ok: true; data: T } | { ok: false; error: string }is a perfectly good discriminated union.
What trips people up
Building a value in a let and assigning fields one at a time widens kind to string. Construct the object literal in one expression, or annotate it, or use as const. And a switch with no default silently returns undefined when a new member is added — pair it with an exhaustiveness check.
Example
When to use it
- Modelling request state as idle | loading | success | error so the payload only exists where it is valid
- Returning { ok: true, value } | { ok: false, error } instead of throwing
- Walking an AST or a message protocol where each node type carries different fields
Frequently asked questions
Why doesn't my union narrow when I check the kind property?
The discriminant is probably typed `string` instead of a literal. Each member needs a distinct singleton type such as `kind: "circle"`; if you build the value in a variable first, use `as const` or annotate it so the tag does not widen.
Should the discriminant always be called kind or type?
No, the name is pure convention — TypeScript looks for any property whose type is a distinct literal in every member. `status`, `tag`, `ok` and `_tag` are all common.
Do discriminated unions cost anything at runtime?
Only the tag field itself, one string or boolean per object. All the type machinery is erased; what remains is a plain switch on a property.
More examples
Request state, and why the optional-fields version fails
Prints [ 'Nothing yet', 'Loading since t=0', 'Got 2 items', 'Failed: 429' ]. Each case reaches a field that does not exist on the other members — `s.data` in the loading branch is a compile error, which is exactly the protection an all-optional interface cannot give you.
type State =
| { status: "idle" }
| { status: "loading"; startedAt: number }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
function view(s: State): string {
switch (s.status) {
case "idle": return "Nothing yet";
case "loading": return `Loading since t=${s.startedAt}`;
case "success": return `Got ${s.data.length} items`;
case "error": return `Failed: ${s.message}`;
}
}
// The bad version would allow { status: "success", message: "?" }
// and force s.data! at every read.
const states: State[] = [
{ status: "idle" },
{ status: "loading", startedAt: 0 },
{ status: "success", data: ["a", "b"] },
{ status: "error", message: "429" },
];
console.log(states.map(view));A boolean discriminant as a Result type
Prints port 8080, error: not a port: 0, error: not a port: nope. `true` and `false` are literal types, so a boolean field is a valid discriminant — no string tag needed. This is the standard alternative to throwing for expected failures.
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function parsePort(raw: string): Result<number> {
const n = Number(raw);
if (!Number.isInteger(n) || n < 1 || n > 65535) {
return { ok: false, error: `not a port: ${raw}` };
}
return { ok: true, value: n };
}
for (const raw of ["8080", "0", "nope"]) {
const r = parsePort(raw);
// r.value is unreachable until ok is checked.
console.log(r.ok ? `port ${r.value}` : `error: ${r.error}`);
}
Discussion