TypeScript's type narrowing is one of its most powerful features, and also one of the most underused.
Most developers know typeof and instanceof. Fewer know about discriminated unions and assertion functions,
which are the patterns I reach for most often on production codebases.
The problem typeof can't solve
type Shape =
{ kind: "circle"; radius: number } | { kind: "square"; side: number };
function area(shape: Shape): number {
if (typeof shape === "object") {
// Still doesn't narrow — TypeScript knows it's an object, nothing more
return 0;
}
return 0;
}typeof narrows to primitive types. For objects, it's nearly useless.
Discriminated unions
When you control the type definition, add a literal kind field. TypeScript narrows on it automatically:
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // shape is { kind: "circle"; radius: number }
case "square":
return shape.side ** 2; // shape is { kind: "square"; side: number }
}
}This is readable, exhaustive, and the compiler enforces it. If you add a triangle to Shape without
updating the switch, TypeScript surfaces the gap.
Exhaustiveness checks
That gap-surfacing requires one extra step: a never assertion in the default branch.
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
return assertNever(shape); // TypeScript error if any case is unhandled
}
}assertNever takes never. If every kind is covered, shape in the default branch is never, and
the code compiles. If you add a new variant and forget to handle it, shape is no longer never — you
get a compile error pointing exactly at the gap. Zero runtime cost for a covered switch.
Assertion functions
For validation at system boundaries (API responses, form inputs), use assertion functions:
function assertIsString(val: unknown, field: string): asserts val is string {
if (typeof val !== "string") {
throw new TypeError(`${field} must be a string, got ${typeof val}`);
}
}
function processInput(raw: unknown) {
assertIsString(raw, "input");
// raw is narrowed to string here
return raw.toUpperCase();
}The asserts val is T return type tells TypeScript: "if this function returns normally (doesn't throw),
val is now T." You get runtime validation and type narrowing in one call.
Summary
| Pattern | Use when |
|---|---|
typeof | Narrowing primitives (string, number, boolean) |
instanceof | Narrowing class instances |
| Discriminated union | You own the type definition — almost always prefer this |
assertNever | Exhaustiveness on switch/if chains |
| Assertion function | Validating unknown data at boundaries |
These patterns are composable. A real codebase typically uses all five.