Telling the cases apart
A union of objects with one field that says which one it is, a switch the compiler follows, and the check that proves every case was handled.
When typeof is not enough
A union of text and number splits cleanly with a typeof check. A union of two objects does not: both are "object", and the shapes may overlap. The split has to be a field the objects actually carry.
A shared field with one literal value per member is called a discriminant — kind, type, status, whatever the domain calls it. Each member declares its own value: { kind: "circle", radius: number }. The field is not special syntax; it is a convention the compiler can follow because every member gives it a different exact value.
One field, two shapes, a switch the compiler follows
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius * shape.radius;
case "square":
return shape.side * shape.side;
}
}The compiler is inside the switch
Inside case "circle", the shape is the circle member — radius is there and side is not. TypeScript narrowed the union from the discriminant alone, with no casts and no guessing.
A return in every branch means no answer escapes unfinished. Drop one case and the function quietly returns nothing where a number was promised, which the return type refuses.
The check that proves it is complete
A default branch that assigns the value to a never is the belt to the return type’s braces: if a new member is added to the union later, the old code that handled only two cases stops compiling instead of silently mishandling the third.
That is the pattern behind the warning you will see in a good codebase: const unreachable: never = shape;. It reads oddly the first time and pays for itself the first time the union grows.
A guard is a function with a claim
A type guard packages a run-time check and a compile-time claim in one signature: function isTodo(value: unknown): value is Todo. The body does the checking; the value is Todo return type tells the compiler what a true result proves.
The claim is only as good as the body. TypeScript does not verify that the function really checks the fields — it trusts the annotation. A guard that returns true without looking is a cast with extra steps, and every later use inherits the lie.
The `in` operator as a narrow
For two object shapes, if ("radius" in shape) narrows the union to the members that carry radius. It is the lightest check when the shapes have a field only one of them owns, and it needs no discriminant at all.
A discriminant is still the better default when there are more than two cases, because a switch over one field reads as a list of cases. Use in for two shapes that are already distinct by their fields.
How the discriminant is checked
When you write shape.kind, the compiler types the expression as the union of the literal values: "circle" | "square". Comparing it with "circle" in a case removes every member whose kind cannot be "circle", and inside that block the narrowed type is the one member that remains. This is ordinary narrowing, applied to a property instead of a variable.
Two details make it reliable. The field must be a literal type in each member, not string, or there is nothing to compare against; and it must be required, not optional, or undefined joins the union and the switch stops being exhaustive. A discriminant that is optional quietly turns a complete check into a partial one.
What goes wrong
A switch without a default looks exhaustive and is, until someone adds a member. The compiler then reports that the function may return undefined, at the function rather than at the missing case. That error is the check working; the fix is a new case, not a default that returns zero.
The other failure is a discriminant written as a widened string. If one member declares { kind: string }, every comparison is legal and none of them narrows, so shape.radius is refused inside the circle branch. The exact value in the type is what makes the split possible; keep it exact.
Three ways a guard fails. It can return true too early, before checking every field the type promises. It can be written as a boolean function without value is T, in which case the compiler learns nothing. And a default branch that returns a made-up value hides the case that was never handled — the never assignment is there to make that omission loud.
Try these next
- Add a third shape and let the
nevercheck point at the switch that must grow. - Write a guard for
unknownthat checks two fields before claiming the shape. - Replace a discriminant switch with
inchecks and decide which version reads better.
Circle or square
Give each member of the union a `kind` of its own, type the parameter as Shape, and return the right area with a switch.
3 left to do
- Each shape says which one it is
- The two cases are separated
- The parameter is a Shape
The discriminant goes on every member: { kind: "circle"; radius: number }. Then switch (shape.kind) and a case per value.
A guard for unknown data
Write `isTodo` so it checks for an object with a text field and a done field, and claims the narrowed type when it passes.
3 left to do
- The function claims the narrowed type
- The value is checked to be an object
- The text field is checked
Start with typeof value === "object" && value !== null, then test the fields with in. The return type is value is Todo.
Name the discriminant after what it means in the domain — kind for shapes, status for an order. The field is the question the code asks, so let it read like one.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
Why can two object types not be told apart with typeof?
