TypeScript course menu
TypeScript Discriminated Unions: Modelling States Safely
Learn discriminated (tagged) unions in TypeScript: a shared literal property that identifies each member, narrowing with if and switch, exhaustive checks with never, modelling loading states, API results and actions, and avoiding impossible states.
On this page
A discriminated union (or tagged union) is a union of object types that all share one property, the discriminant, whose value is a different literal in each member. By checking that property, TypeScript knows exactly which member you have. It is the standard way to model things that can be in one of several states.
The pattern#
type Circle = { kind: "circle"; radius: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Triangle = { kind: "triangle"; base: number; height: number };
type Shape = Circle | Rectangle | Triangle;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // shape: Circle
case "rectangle":
return shape.width * shape.height; // shape: Rectangle
case "triangle":
return (shape.base * shape.height) / 2; // shape: Triangle
}
}
const shapes: Shape[] = [
{ kind: "circle", radius: 1 },
{ kind: "rectangle", width: 3, height: 4 },
{ kind: "triangle", base: 6, height: 2 },
];
console.log(shapes.map((s) => area(s).toFixed(2)));[ '3.14', '12.00', '6.00' ]- Every member has a
kindproperty with a unique literal value. - Checking
shape.kindnarrowsshapeto one member, so only that member's properties are available.
Using a property that belongs to another member is an error:
type Circle = { kind: "circle"; radius: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Shape = Circle | Rectangle;
function describe(shape: Shape): string {
if (shape.kind === "circle") {
return `Width ${shape.width}`;
}
return "rectangle";
}error TS2339: Property 'width' does not exist on type 'Circle'.Exhaustiveness checking#
Add a default branch that assigns the value to never. If someone adds a new shape and forgets to handle it, the code stops compiling:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "hexagon"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default: {
const unhandled: never = shape;
throw new Error(`Unknown shape: ${JSON.stringify(unhandled)}`);
}
}
}error TS2322: Type '{ kind: "hexagon"; side: number; }' is not assignable to type 'never'.The error names the forgotten member. This is one of the most valuable safety nets in TypeScript for growing codebases (never).
Modelling loading states#
A classic beginner model for loading data uses separate booleans and optional fields:
type BadState = {
isLoading: boolean;
error?: string;
data?: string[];
};It allows impossible states: isLoading: true with an error and data at the same time. A discriminated union allows only the states that make sense:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
function render(state: RequestState): string {
switch (state.status) {
case "idle":
return "Press the button to load courses";
case "loading":
return "Loading...";
case "success":
return `Loaded: ${state.data.join(", ")}`;
case "error":
return `Something went wrong: ${state.message}`;
}
}
const states: RequestState[] = [
{ status: "idle" },
{ status: "loading" },
{ status: "success", data: ["HTML", "CSS"] },
{ status: "error", message: "Network timeout" },
];
for (const s of states) console.log(render(s));Press the button to load courses
Loading...
Loaded: HTML, CSS
Something went wrong: Network timeoutdata only exists in the success state, so you cannot accidentally read it while loading.
Results instead of exceptions#
The same idea models operations that can succeed or fail:
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function parsePercent(input: string): Result<number> {
const n = Number(input);
if (Number.isNaN(n)) return { ok: false, error: `"${input}" is not a number` };
if (n < 0 || n > 100) return { ok: false, error: `${n} is out of range` };
return { ok: true, value: n };
}
for (const input of ["42", "abc", "150"]) {
const result = parsePercent(input);
console.log(result.ok ? `value ${result.value}` : `error: ${result.error}`);
}value 42
error: "abc" is not a number
error: 150 is out of rangeHere the discriminant is the boolean literal ok. Callers cannot use value without checking ok first.
Where you will see this pattern#
- Redux and
useReduceractions:{ type: "add"; item: Item } | { type: "remove"; id: string }. - API responses with a
statusortypefield. - Events and messages in WebSocket or worker communication.
- Payment methods, notification types, form steps.
Practice#
Practice exercise
Model cart actions as a discriminated union: add (with name and price), remove (with name) and clear. Write reduce(cart: { name: string; price: number }[], action: CartAction) that returns a new cart for each action, with an exhaustive switch. Apply add, add, remove and print the final cart total.
Show solution
type Item = { name: string; price: number };
type CartAction =
| { type: "add"; name: string; price: number }
| { type: "remove"; name: string }
| { type: "clear" };
function reduce(cart: Item[], action: CartAction): Item[] {
switch (action.type) {
case "add":
return [...cart, { name: action.name, price: action.price }];
case "remove":
return cart.filter((item) => item.name !== action.name);
case "clear":
return [];
default: {
const unhandled: never = action;
return unhandled;
}
}
}
const actions: CartAction[] = [
{ type: "add", name: "Pen", price: 20 },
{ type: "add", name: "Notebook", price: 60 },
{ type: "remove", name: "Pen" },
];
const cart = actions.reduce(reduce, []);
console.log(cart, cart.reduce((sum, i) => sum + i.price, 0));[ { name: 'Notebook', price: 60 } ] 60Key takeaway#
Finished this lesson?