One shared tag property
The production-grade pattern for "data that comes in several shapes" is the discriminated union: every shape carries the same literal-typed property, called the tag or discriminant, and you branch on it.
type Shape = | { kind: "circle"; radius: number } | { kind: "rectangle"; width: number; height: number };
Each member declares kind with a different literal type from lesson 2-3. Checking the tag narrows perfectly:
if (shape.kind === "circle") { // shape is the circle member, radius exists }
You will meet this pattern constantly in real codebases. A web API response that is either data or an error, a user action in a front-end app, a message arriving from another program: each is one union with a tag saying which shape arrived.
An if on the tag works for two members. Past that, the same check reads best as a switch on the tag. Writing switch (shape.kind) compares the tag against each case with === and runs the matching branch, and TypeScript narrows inside each case exactly as it does inside an if.
Two shapes, one tag check
type Shape = | { kind: "circle"; radius: number } | { kind: "rectangle"; width: number; height: number }; function area(shape: Shape): number { if (shape.kind === "circle") { return Math.PI * shape.radius * shape.radius; } return shape.width * shape.height; } console.log(area({ kind: "rectangle", width: 3, height: 4 })); console.log(area({ kind: "circle", radius: 1 }).toFixed(2));
Output
12 3.14
Reading shape.radius before the if is blocked by the compiler, and the reason is precise. Before the check, shape could be either member, and the only property both members share is kind. So kind is the only property readable at that point, which is exactly why the tag has to be the thing you check.
After the check succeeds, TypeScript knows the value is the circle member and radius comes into reach. After the if, only the rectangle member remains, so width and height become readable instead.
Scaling to three members with a switch
An if on the tag works for two members. Past that, the same check reads better as a switch. Here is the three-member version.
type Shape = | { kind: "circle"; radius: number } | { kind: "rectangle"; width: number; height: number } | { kind: "square"; side: number }; function area(shape: Shape): number { switch (shape.kind) { case "circle": return Math.PI * shape.radius * shape.radius; case "rectangle": return shape.width * shape.height; case "square": return shape.side * shape.side; } } console.log(area({ kind: "square", side: 5 })); console.log(area({ kind: "rectangle", width: 2, height: 8 }));
Output
25 16
What the switch buys you
- Each
casenarrowsshapeto exactly one member, so the right properties are in reach per branch. Readingshape.sideinside the"circle"case is rejected, because a circle has noside. - Every case returns, so no
breakstatements are needed here. - The compiler accepts the function with no
defaultbranch and no finalreturn, because the three cases cover every member of the union. That is a genuinely useful signal: add a fourth member toShapelater and this function starts failing to compile until you handle it, so the type system points you at every place that needs updating.
What makes a union discriminated
A union is discriminated when every member shares one property whose type is a different literal value in each member.
That shared literal-typed property is the tag, or discriminant. In the Shape union it is kind, holding "circle" in one member and "rectangle" in another. Because no two members can carry the same tag value, comparing the tag against one literal tells the compiler exactly which member you hold, and every other property of that member narrows into reach.
You will meet this pattern constantly in real codebases: a web API response that is either data or an error, a user action in a front-end app, a message arriving from another program. Each is one union with a tag saying which shape arrived.
describeResult: modelling success and failure
LoadResult is a discriminated union with a status tag: one member carries a numeric value, the other carries an error message. This is the single most common real-world use of the pattern.
type LoadResult = | { status: "ok"; value: number } | { status: "error"; message: string }; function describeResult(r: LoadResult): string { if (r.status === "ok") { return "value is " + r.value; } return "failed: " + r.message; } console.log(describeResult({ status: "ok", value: 7 })); console.log(describeResult({ status: "error", message: "not found" }));
Output
value is 7
failed: not foundReading the union
- The tag here is
status, compared with===against the literal"ok". - Inside the ok branch
r.valueexists, and in the other branchr.messageexists. Neither is reachable in the wrong branch, which makes it impossible to read a value out of a failed result. - Notice what this rules out by construction. A single shape with optional
valueand optionalmessagewould allow nonsense combinations, such as a success with no value or an error carrying both. The union permits exactly the two states that make sense.