Type narrowing and control flow analysis
TypeScript watches the actual runtime checks your code performs — an if, a typeof, an early return — and updates a value's known type accordingly, at that exact point in the code. This is what lets a union type actually be used safely.
4 min read
The problem narrowing solves
function printLength(value: string | number) {
console.log(value.length); // Error: Property 'length' does not exist on type 'number'
}value.length is valid if value is a string, but number has no .length property at all — with a bare union type, TypeScript has no way to know which member of the union you're actually holding at this specific line, so it refuses to let you call anything that isn't safe for every member. Fixing this requires proving, to the compiler, which specific member you have — that proof is exactly what narrowing is.
typeof: the most common narrowing check
function printLength(value: string | number) {
if (typeof value === "string") {
console.log(value.length); // fine — TypeScript knows value is string HERE
} else {
console.log(value.toFixed(2)); // fine — TypeScript knows value is number HERE
}
}Inside the if (typeof value === "string") branch, TypeScript's control flow analysis narrows value's type to just string, for that branch specifically — the else branch, by elimination, narrows it to number. This isn't a hint or an annotation you write — TypeScript works this out automatically by tracing the actual runtime check you wrote and reasoning about which branch is reachable when that check is true or false.
in: narrowing by checking whether a property exists
interface Bird { fly(): void; }
interface Fish { swim(): void; }
function move(animal: Bird | Fish) {
if ("fly" in animal) {
animal.fly(); // narrowed to Bird
} else {
animal.swim(); // narrowed to Fish
}
}typeof only distinguishes JavaScript's primitive types (string, number, boolean, and a few others) — it can't tell two different object shapes apart, since typeof on any object is just "object". The in operator checks whether a specific property name exists on a value at runtime, and TypeScript narrows based on that check too — "fly" in animal being true is only possible if animal is a Bird, so that's what it narrows to inside the branch.
instanceof: narrowing by class
class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
function handle(error: Error | ApiError) {
if (error instanceof ApiError) {
console.log(error.statusCode); // narrowed to ApiError — statusCode exists here
}
}instanceof checks whether a value was constructed by a specific class (or one of its subclasses) — the one narrowing mechanism that's about actual runtime class identity rather than plain object shape, which makes it the natural fit for a union involving classes specifically, Error subclasses being the most common real example.
Discriminated unions: the pattern that makes narrowing genuinely reliable at scale
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; sideLength: number };
type Shape = Circle | Square;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // narrowed to Circle
case "square":
return shape.sideLength ** 2; // narrowed to Square
}
}A discriminant is a shared field (here, kind) present on every member of a union, where each member gives it a different literal-type value. Once every variant carries this common tag, a single switch (or if) on that one field narrows the entire rest of the object correctly in each branch — this is the standard, idiomatic way to model "one of several distinct shapes" in TypeScript, precisely because it scales cleanly to unions with many members without a tangle of separate typeof/in checks for each one.
Custom type guards: writing your own narrowing function
interface Cat { meow(): void; }
interface Dog { bark(): void; }
function isCat(animal: Cat | Dog): animal is Cat {
return "meow" in animal;
}
function greet(animal: Cat | Dog) {
if (isCat(animal)) {
animal.meow(); // narrowed to Cat, because of the type-guard return type
}
}animal is Cat (a type predicate) is a special return-type annotation telling TypeScript: "when this function returns true, treat its argument as narrowed to Cat from that point on." This is what lets narrowing logic be extracted into a reusable, named function instead of repeating the same "meow" in animal check inline everywhere it's needed — the function's body can contain any logic at all; the type predicate in the signature is what actually communicates the narrowing to the compiler, and TypeScript trusts it without independently re-verifying the body's logic matches.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Inside `if (typeof value === "string") { ... }`, given `value: string | number`, what type does TypeScript narrow `value` to within that block?
2. Why can't `typeof` distinguish between two different object shapes, like a `Bird` and a `Fish` interface?
3. What is a "discriminant" in the discriminated-union pattern (e.g. a shared `kind` field)?
4. What does the return type `animal is Cat` on a custom function actually tell TypeScript?