Type narrowing is TypeScript's ability to refine broad types into specific ones within conditional blocks — typeof checks primitives, instanceof checks classes, in checks properties, and custom type predicates (param is Type) enable reusable narrowing logic for discriminated unions and complex shapes.
typeof narrows primitives, instanceof narrows class instances, in checks property existence — all trigger automatic type narrowing in conditional blocks.
A common literal property (status, kind, type) enables exhaustive narrowing in if/switch — the most type-safe pattern for union types.
Functions returning param is Type encapsulate reusable narrowing logic — TypeScript trusts the predicate, so correctness is the developer's responsibility.
TypeScript tracks types through if/else, switch, return, throw, and logical operators — assignments and truthiness checks also narrow types automatically.
Type narrowing is how TypeScript refines a broad type into a more specific one based on runtime checks. When you check typeof x === 'string', TypeScript narrows x from string | number to string inside that branch. This is fundamental to working with union types safely.
The typeof operator narrows primitive types:
function format(value: string | number): string {
if (typeof value === 'string') {
return value.toUpperCase(); // value: string
}
return value.toFixed(2); // value: number
}typeof works for string, number, boolean, symbol, bigint, undefined, function, and object. Note: typeof null === 'object' — TypeScript knows this quirk and handles it correctly when you check for null separately.
instanceof narrows to class instances:
function handleError(error: Error | string) {
if (error instanceof TypeError) {
console.log(error.message); // error: TypeError
} else if (typeof error === 'string') {
console.log(error); // error: string
}
}instanceof checks the prototype chain — it works with classes and constructor functions, not plain interfaces or type aliases.
The in operator checks if a property exists on an object:
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ('swim' in animal) {
animal.swim(); // animal: Fish
} else {
animal.fly(); // animal: Bird
}
}in narrows based on property presence — useful when types don't share a common discriminant field.
The most powerful narrowing pattern uses a common literal property (the discriminant):
type Success = { status: 'success'; data: string };
type Failure = { status: 'error'; message: string };
type Result = Success | Failure;
function handle(result: Result) {
if (result.status === 'success') {
console.log(result.data); // result: Success
} else {
console.log(result.message); // result: Failure
}
}TypeScript sees the status literal check and narrows to the matching union member. This works in if, switch, and ternary expressions. Exhaustive checking in switch uses never in the default case to ensure all variants are handled.
For complex narrowing logic, write a function with a type predicate return type:
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isFish(animal: Fish | Bird): animal is Fish {
return 'swim' in animal;
}The param is Type return type tells TypeScript that if the function returns true, the parameter is the specified type. This lets you encapsulate narrowing logic in reusable functions.
Type predicates require manual correctness — TypeScript trusts that your runtime check matches the declared type predicate. A wrong predicate (returning true when the value isn't actually that type) causes unsound behavior.
Assertion functions narrow by throwing on failure rather than returning a boolean:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw new TypeError('Expected string');
}
let x: unknown = getValue();
assertIsString(x);
x.toUpperCase(); // x: string (narrowed after assertion)The asserts param is Type signature tells TypeScript that after the function returns (without throwing), the parameter has been narrowed.
TypeScript's narrowing works through control flow analysis — it tracks type information through if/else, switch, return, throw, and &&/||/?? operators. Assignments also narrow: let x: string | null = getString(); if (x === null) return; // x: string after this point.
Type narrowing refines broad types into specific ones through runtime checks. typeof for primitives, instanceof for classes, in for property existence, equality checks for discriminated unions. Custom type predicates (param is Type) encapsulate narrowing logic. Assertion functions (asserts param is Type) narrow by throwing on failure. TypeScript tracks narrowing through control flow analysis — it follows your conditional logic to determine the type at each point.
Fun Fact
TypeScript's control flow analysis was a breakthrough when it landed in TypeScript 2.0. Before that, you had to use explicit type assertions (<string>value or value as string) everywhere — the compiler couldn't track that an if check had narrowed a union type. Anders Hejlsberg demonstrated it at the TypeScript 2.0 launch and the audience reaction was so enthusiastic that the video clip went viral in the developer community.