Narrowing is how TypeScript moves from a broad type (like string | number) to a specific one inside a branch of code. It’s TS’s secret sauce — the compiler reads our control flow and adjusts the type at each step.
In simple language, the compiler runs through our if/else and updates what it thinks the type is, the same way our brain does when reading the code.
typeof — for primitives
The classic. TS recognizes typeof x === "string" | "number" | "boolean" | "undefined" | "object" | "function" | "bigint" | "symbol".
function double(x: string | number): string | number {
if (typeof x === "number") {
return x * 2; // x is number here
}
return x.repeat(2); // x is string here
}
instanceof — for class instances
class Dog { bark() { console.log("woof"); } }
class Cat { meow() { console.log("meow"); } }
function speak(pet: Dog | Cat) {
if (pet instanceof Dog) {
pet.bark(); // narrowed to Dog
} else {
pet.meow(); // narrowed to Cat
}
}
in operator — check property existence
Great for narrowing between object types without a discriminator.
type Bird = { fly: () => void };
type Fish = { swim: () => void };
function move(animal: Bird | Fish) {
if ("fly" in animal) {
animal.fly(); // Bird
} else {
animal.swim(); // Fish
}
}
Equality narrowing
Comparing to a literal narrows.
function f(x: string | null) {
if (x === null) return;
x.toUpperCase(); // x is string here
}
This is also how discriminated unions narrow on a kind/type field.
Truthiness narrowing
if (value) narrows away null, undefined, "", 0, false.
function f(x: string | null | undefined) {
if (x) {
x.toUpperCase(); // narrowed to string
}
}
Be careful — empty string and 0 are falsy. If we mean “not null/undefined”, use x != null (loose equality on purpose — catches both).
User-defined type guards (type predicates)
When the built-in narrowing isn’t enough, we can write our own. A type predicate is a function that returns arg is SomeType.
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function isCat(animal: Cat | Dog): animal is Cat {
return "meow" in animal;
}
function pet(animal: Cat | Dog) {
if (isCat(animal)) {
animal.meow(); // narrowed to Cat
} else {
animal.bark(); // narrowed to Dog
}
}
The signature animal is Cat is a promise to the compiler: “if I return true, treat the argument as Cat from here on”. TS trusts us — make sure the runtime check actually matches.
Assertion functions
Cousin of type predicates. Throws if the check fails, narrows on the rest of the function after the call.
function assertIsString(val: unknown): asserts val is string {
if (typeof val !== "string") throw new Error("Not a string");
}
function f(input: unknown) {
assertIsString(input);
input.toUpperCase(); // narrowed to string here on
}
Discriminated unions
The cleanest narrowing pattern — a shared literal field that uniquely identifies each variant. Big enough topic to have its own note.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number };
function area(s: Shape): number {
if (s.kind === "circle") return Math.PI * s.radius ** 2;
return s.size ** 2;
}
never for exhaustiveness
A handy pattern — in the default branch of a switch, assign to never. If a new variant is added later, TS errors here.
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "square": return s.size ** 2;
default:
const _exhaustive: never = s; // ❌ if Shape grows
return _exhaustive;
}
}
Interview soundbite
“Narrowing is TS reading our control flow and shrinking a broad type inside branches. Built-in tools: typeof, instanceof, in, equality, truthiness. For custom checks we write user-defined type guards returning arg is Type. The pattern matters because it’s how we work safely with union types.”