Often a variable can hold a value of one of several types: a string or a number, an object or null, a success or an error. Type narrowing is how TypeScript understands, inside a block of code, which specific type it is dealing with right now and allows you to work with it safely.
Why narrow types
Suppose a function accepts a value that can be either a string or a number:
function printId(id: string | number) {
// id.toUpperCase() — error: number has no toUpperCase
console.log(id);
}
TypeScript won't let you call id.toUpperCase() because id might be a number. To gain access to string methods, you first need to prove to the compiler that in this branch id is definitely a string. That is narrowing: a runtime check that TypeScript knows how to read and apply to types.
Short rule: checked a value with a condition — inside the branch the type is narrowed.
typeof — for primitives
The typeof operator returns a string with the type name of the value. TypeScript understands such checks and narrows the type in each branch:
function printId(id: string | number) {
if (typeof id === "string") {
// here id: string — string methods are available
console.log(id.toUpperCase());
} else {
// here id: number
console.log(id.toFixed(2));
}
}
typeof distinguishes primitives: "string", "number", "boolean", "bigint", "symbol", "undefined", "function", and "object". An important gotcha: typeof null === "object" — filtering out null requires a separate check (see below).
instanceof — for classes
When a value can be an instance of a class, instanceof helps. It checks the prototype chain and also narrows the type:
function describe(value: Date | string) {
if (value instanceof Date) {
// here value: Date
return value.toISOString();
}
// here value: string
return value.trim();
}
instanceof works with things created via new: the built-ins Date, Error, Map, as well as your own classes.
The in operator — by property presence
If types differ by their set of fields, you can check for a property's existence using the in operator:
interface Cat { meow(): void }
interface Dog { bark(): void }
function speak(animal: Cat | Dog) {
if ("meow" in animal) {
// here animal: Cat
animal.meow();
} else {
// here animal: Dog
animal.bark();
}
}
This is handy when you have interfaces with no common class — instanceof doesn't apply to them, but in works.
Null and undefined checks
null and undefined are a frequent source of errors. TypeScript narrows the type here too:
function greet(name: string | null) {
if (name === null) {
return "Hello, guest";
}
// here name: string
return `Hello, ${name.toUpperCase()}`;
}
A simple truthiness check filters out both null and undefined and an empty string at once:
function length(text?: string) {
if (!text) {
return 0; // text is undefined or empty string here
}
return text.length; // text: string
}
A useful pattern is an early return: filter out the "empty" case at the top, then work with a guaranteed valid value below.
User-defined type guards (is)
Sometimes the check is more complex than a single operator, and you want to extract it into a separate function. For TypeScript to continue narrowing the type after such a function, it must return a type predicate — an expression of the form parameter is Type:
interface User {
id: number;
email: string;
}
// guard function: returns not just boolean, but "value is User"
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"email" in value
);
}
function handle(payload: unknown) {
if (isUser(payload)) {
// here payload: User
console.log(payload.email);
}
}
The value is User syntax tells the compiler: "if this function returned true, treat the argument as type User". This lets you reuse a non-trivial check and preserve the narrowing.
Discriminated unions
The most reliable pattern for objects is a discriminated union. Each variant has a common tag field (a literal type) by which TypeScript unambiguously distinguishes variants:
type Result =
| { status: "ok"; data: string }
| { status: "error"; message: string };
function render(result: Result) {
switch (result.status) {
case "ok":
// here result: { status: "ok"; data: string }
return result.data;
case "error":
// here result: { status: "error"; message: string }
return result.message;
}
}
The status field here is the discriminant. Once checked, TypeScript immediately knows what other fields are available: in the "ok" branch there is data, in the "error" branch there is message. This is safer than checking for fields one by one.
A bonus — exhaustiveness checking. If you add a third variant to Result and forget to handle it, the compiler will warn you via the never trick:
function render(result: Result): string {
switch (result.status) {
case "ok":
return result.data;
case "error":
return result.message;
default:
// if all variants are handled, never is reached here
const exhaustive: never = result;
return exhaustive;
}
}
Working with unknown
The unknown type is "safe any". It means "a value exists, but the type is unknown", and TypeScript forbids any operation on it until the type is narrowed. This is what makes it the right choice for external data: network responses, JSON parsing, user input.
function parse(json: string) {
const data: unknown = JSON.parse(json); // JSON.parse returns any, we fix it as unknown
// data.id — error: type is unknown
if (isUser(data)) {
console.log(data.id); // here data: User
}
}
Unlike any, unknown forces you to check the value first — meaning a type error surfaces at compile time, not at runtime. Short rule: accept external data as unknown and narrow it with a guard before use.
Why narrowing makes code safe
Every narrowing check works on two levels at once. At runtime it is an ordinary condition. At compile time TypeScript uses the same check to forbid invalid operations in each branch. That's why "undefined has no such method" and similar failures are caught before running: the compiler simply won't let you access a field that might not exist in this branch.
In short
- Type narrowing is how TypeScript understands the specific type of a value from a union inside a branch.
typeofnarrows primitives,instanceofnarrows class instances,innarrows by property presence, comparison withnull/undefinedfilters out "empty" values.- Remember the gotcha
typeof null === "object"—nullneeds a separate check. - A user-defined type guard returning
value is Typereuses a complex check and preserves the narrowing. - A discriminated union with a discriminant field is the most reliable way to distinguish object variants; the
nevertrick gives exhaustiveness checking. - Accept external data as unknown and narrow it with a guard — the compiler will force you to check the type before use.
What to read next
- Basic type system — unions, literal types and the building blocks we narrow.
- Generics — generic types that often combine with type guards.
- Async and the event loop — where
unknownis especially useful when parsing network responses.