Type Narrowing
When a value could be one of several types, TypeScript follows your if statements, switch cases and early returns to work out which type it must be at each point in the code. This is called narrowing, and it's how unions become usable.
typeof narrowing
function pad(value: string | number, width: number): string { if (typeof value === "number") { return value.toFixed(2).padStart(width); } return value.padStart(width); } console.log(`[${pad(3.14159, 8)}]`); console.log(`[${pad("hi", 5)}]`);
[ 3.14] [ hi]
Inside the if, value is a number. After the return, TypeScript knows the number case is gone, so value must be a string. typeof can check for "string", "number", "bigint", "boolean", "symbol", "undefined", "object" and "function".
typeof null is "object" — a long-standing JavaScript quirk. TypeScript knows this, so after typeof x === "object" the type still includes null until you check for it separately.Truthiness narrowing
function greet(name?: string | null) { if (name) { return `Hi ${name.trim()}`; } return "Hi there"; } console.log(greet(" Lu "), "|", greet(), "|", greet(""));
Hi Lu | Hi there | Hi there
An if (name) check removes null and undefined — but also the empty string, 0 and false, which may not be what you want. When those are valid values, check name != null instead.
Equality narrowing
type Unit = "px" | "em" | "%"; function css(value: number, unit: Unit | null) { if (unit === null) return String(value); if (unit === "%") return `${value * 100}%`; return `${value}${unit}`; } console.log(css(0.5, "%"), css(12, "px"), css(3, null));
50% 12px 3
instanceof narrowing
function describe(when: Date | string) { if (when instanceof Date) { return when.toISOString().slice(0, 10); } return when; } console.log(describe(new Date(Date.UTC(2026, 4, 1))), describe("tomorrow"));
2026-05-01 tomorrow
instanceof works for anything created by a class or constructor function: Date, Error, Map, your own classes.
The in operator
For plain objects, checking whether a property exists narrows to the union members that have it:
type Fish = { swim: () => string }; type Bird = { fly: () => string }; function move(animal: Fish | Bird) { if ("swim" in animal) { return animal.swim(); } return animal.fly(); } console.log(move({ swim: () => "splash" }), move({ fly: () => "flap" }));
splash flap
Narrowing with switch
function size(value: string | number | boolean): number { switch (typeof value) { case "string": return value.length; case "number": return value; default: return value ? 1 : 0; } } console.log(size("hello"), size(7), size(true));
5 7 1
Control flow analysis
TypeScript tracks types through assignments and early exits too. Each time you assign a value, the variable's type narrows to what you assigned:
let id: string | number = "abc"; console.log(id.toUpperCase()); id = 42; console.log(id.toFixed(1)); function process(input: string | undefined) { if (!input) throw new Error("input required"); return input.split(","); } console.log(process("a,b"));
ABC 42.0 [ 'a', 'b' ]
After the throw in process, TypeScript knows input must be a non-empty string, because the only other path left the function.
Array.isArray
function toList(input: string | string[]): string[] { return Array.isArray(input) ? input : input.split(" "); } console.log(toList("a b c"), toList(["x"]));
[ 'a', 'b', 'c' ] [ 'x' ]