TypeScript Fundamentals: Types, Generics and Real-World Patterns · TypeScript Basics: Types, Narrowing and Functions
Unions, Narrowing and Discriminated Unions
Model values that can be one of several types, narrow them with typeof, in and equality checks, and use discriminated unions with exhaustive checking to handle every case.
What you'll build
A shape calculator. Each shape is a different kind of object, and one area function handles all of them. If you add a new shape and forget to handle it, the compiler tells you.
The idea
A union type A | B means a value is either A or B. Unions model real situations: an id that is a string or number, a request that succeeded or failed, a shape that is a circle or a square.
Before you use a union value you must narrow it, so the compiler knows which member you have. Narrowing happens through:
typeof x === "string": primitives."radius" in shape: tests for a property.x instanceof Date: class instances.- Equality and truthiness checks:
if (value === null) return;.
The most useful pattern is the discriminated union: every member has a literal kind (the discriminant). Checking it narrows the whole object:
type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
In a switch (shape.kind) each case sees the matching member. Add a default that assigns the value to never; if you ever add a member without handling it, that line stops compiling. This is called exhaustive checking.
The code
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; 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 "rectangle":
return shape.width * shape.height;
default: {
// If a new kind is added to Shape and not handled above, this line fails to compile.
const unreachable: never = shape;
return unreachable;
}
}
}
const shapes: Shape[] = [
{ kind: "circle", radius: 2 },
{ kind: "square", side: 3 },
{ kind: "rectangle", width: 4, height: 5 },
];
for (const shape of shapes) {
console.log(`${shape.kind.padEnd(9)} area = ${area(shape).toFixed(2)}`);
}
// Narrowing a plain union with typeof
function formatId(id: string | number): string {
return typeof id === "string" ? id.toUpperCase() : id.toString().padStart(5, "0");
}
console.log(formatId("ab-12"), formatId(42));
// Narrowing with `in`
type Fish = { swim: () => string };
type Bird = { fly: () => string };
function move(animal: Fish | Bird): string {
return "swim" in animal ? animal.swim() : animal.fly();
}
console.log(move({ swim: () => "splash" }), move({ fly: () => "flap" }));
Output
circle area = 12.57 square area = 9.00 rectangle area = 20.00 AB-12 00042 splash flap
Try adding { kind: "triangle"; base: number; height: number } to Shape in your editor. The never assignment immediately reports an error, pointing you to the missing case.
Common mistakes
- Using a boolean flag instead of a discriminant.
{ ok: boolean; value?: T; error?: string }allows impossible states. Use{ ok: true; value: T } | { ok: false; error: string }. - Using inconsistent discriminant values (
typein one member,kindin another). Use the same property everywhere. - Narrowing with truthiness when
0or""are valid.if (count)treats0as missing; compare toundefinedexplicitly. - Skipping the exhaustive
nevercheck. Without it, a new union member silently falls through. - Casting with
asto avoid narrowing. A cast hides bugs; a check proves the type.
Key points
- A union
A | Bis one of several types; you must narrow it before using member-specific properties. - Narrow with
typeof,in,instanceof, equality and discriminant checks. - A discriminated union (a shared literal
kind) letsswitchnarrow whole objects. - Assign to
neverindefaultto make the compiler enforce exhaustive handling.
Further reading: The Handbook: Narrowing.
Practice questions
What is a discriminated union and why is it useful?
Answer
A union whose members share a property with a distinct literal type (the discriminant, for example kind: "circle"). Checking that property narrows the whole object, so each branch sees exactly the right fields, and a switch can be checked for exhaustiveness.
What does assigning the value to `never` in a `default` branch achieve?
Answer
It makes the compiler verify that every union member has been handled. If a new member is added and not handled, the value is no longer never and the assignment becomes a compile error.
Open in the app to mark lessons complete, take notes and track your progress.