Type Narrowing and Type Guards
Introduction
Union types describe possible shapes; narrowing refines them inside branches so TypeScript knows which members and methods are safe. Built-in checks (typeof, instanceof, in) and custom type predicates (value is Type) are how you turn unions into precise, checked code.
Prerequisites
Narrowing with typeof
For primitive unions:
function padLeft(value: string, padding: string | number): string {
if (typeof padding === "number") {
return " ".repeat(padding) + value;
}
return padding + value;
}typeof works well for: string, number, boolean, bigint, symbol, undefined, function. It does not distinguish object classes beyond object / function.
Narrowing with instanceof
For class instances:
Code explanation:
instanceofchecks the prototype chain at runtime- Prefer discriminated unions for plain objects (no class)
Narrowing with in
Check property existence on objects:
type Fish = { swim(): void };
type Bird = { fly(): void };
function move(animal: Fish | Bird): void {
if ("swim" in animal) {
animal.swim();
} else {
animal.fly();
}
}Useful when types differ by member names.
Equality and Literal Narrowing
type Status = "pending" | "done" | "failed";
function icon(status: Status): string {
if (status === "pending") {
return "⏳";
}
if (status === "done") {
return "✅";
}
return "❌";
}After checking all literals, status in the final branch is "failed".
Truthiness Narrowing
function printName(name: string | null | undefined): void {
if (!name) {
console.log("Guest");
return;
}
console.log(name.toUpperCase());
}Code explanation:
- Early return removes
nullandundefinedfrom the rest of the block - Empty string
""is also falsy—decide if that is intended
Warning
0 and "" Are Falsy
if (value) removes 0 and "" too. Use value != null or explicit checks when those are valid.
Discriminated Unions
Shared field with literal values:
Code explanation:
kinddiscriminates; eachcasenarrowsshapeneverassignment catches missing cases whenShapegrows
User-Defined Type Guards (Type Predicates)
Code explanation:
- Return type
account is Admintells TypeScript the branch type - Implementation must be honest—wrong predicates cause runtime bugs
Predicate on primitives
function isString(value: unknown): value is string {
return typeof value === "string";
}
function len(value: unknown): number {
if (isString(value)) {
return value.length;
}
return 0;
}Assertion Functions (Brief)
function assertIsNumber(value: unknown): asserts value is number {
if (typeof value !== "number") {
throw new Error("Expected number");
}
}
function double(value: unknown): number {
assertIsNumber(value);
return value * 2;
}After assertIsNumber, value is number in that scope.
Control Flow Analysis
TypeScript tracks assignments across branches:
function example(x: string | number) {
if (typeof x === "string") {
console.log(x.length);
} else {
console.log(x.toFixed(2));
}
}Reassignments can widen types again—keep functions small when unions are complex.
Practical Example: Parsing API Payload
Common Mistakes
| Mistake | Result | Fix |
|---|---|---|
| No discriminant on object unions | Properties not narrowed | Add kind / type field |
| Fake type predicate | Runtime errors | Match predicate to real check |
Relying on typeof for arrays | object, not array | Use Array.isArray |
| Mutating narrowed variable | Type widens again | Use const or separate variables |
FAQ
Narrowing vs type assertion?
Narrowing is checked by control flow. Assertions override the compiler—prefer narrowing when possible.
Does narrowing work in callbacks?
Sometimes not—let mutated in closures can confuse analysis. Pass narrowed values into callbacks as parameters.
Can I narrow Promise types?
Await the promise, then narrow the resolved value.
instanceof with interfaces?
Interfaces do not exist at runtime—use discriminated fields or custom guards.
What is control flow narrowing?
The compiler’s analysis of if, switch, return, and assignments to refine types.
Are type guards slow?
Small in / typeof checks are cheap. Prefer clarity over micro-optimization.