TypeScript Type Narrowing
Once a variable’s type includes a union — such as string | number or Fish | Bird — TypeScript won’t let you use a member that only exists on one branch of that union until it can prove which branch you’re actually working with. Type narrowing is the process by which the compiler follows the control flow of your code and progressively refines a broad type into a more specific one inside each branch. It is the single mechanism that makes union types genuinely usable day to day, and understanding it well is one of the clearest signs of moving from “I write TypeScript” to “I think in TypeScript’s type system.”
Overview: How Narrowing Works
TypeScript performs what’s called control flow analysis (CFA). For every reference to a variable, the compiler doesn’t just look at its declared type — it computes the most specific type that variable could have at that exact point in the code, based on every check, assignment, and return statement that ran on the path leading there. A variable declared as string | number keeps that declared type in general, but inside an if block that has proven it’s a string, references to that variable are treated as string only — even though nothing about the variable’s declaration changed.
TypeScript recognizes a specific set of JavaScript expressions as “type guards” and gives them special meaning during CFA:
typeofchecks — narrows to primitive kinds ("string","number","boolean","undefined","function","object","symbol","bigint").instanceofchecks — narrows to a class based on its prototype chain.- The
inoperator — narrows based on whether a property exists on the value. - Equality and
switch—===,!==, andswitchcases narrow based on comparing to a specific literal value. - Truthiness — a plain
if (x)check narrows awaynull,undefined,0,"", and other falsy values. - Discriminated unions — checking a shared literal property (often named
kindortype) narrows the whole object. - User-defined type predicates — a function whose return type is
x is Fooacts as a custom, reusable type guard. - Assertion functions — a function typed as
asserts x is Foonarrows the type of its argument for the rest of the enclosing scope after it’s called, throwing if the assertion fails.
After a successful check, TypeScript narrows the type to only the union members that are compatible with it. Once the branches merge back together (after the if/else, or after a switch), the type widens back out to the union of whatever each branch produced — unless every other branch returned, threw, or otherwise stopped control flow, in which case the narrowing from the surviving branch carries forward.
It’s worth emphasizing early: none of this exists at runtime. TypeScript’s type system is completely erased during compilation. The typeof, instanceof, and in checks you write for narrowing are ordinary JavaScript operators — they run exactly as written in the compiled output. TypeScript just happens to understand what they mean well enough to update the type it tracks for your variable in the branches that follow.
Syntax
There’s no dedicated “narrowing syntax” — narrowing happens automatically whenever you write one of the checks below. The table summarizes the most common techniques:
| Technique | Example | Narrows to |
|---|---|---|
typeof |
typeof x === "string" |
The matching primitive type |
instanceof |
x instanceof Date |
The class and its subtypes |
in |
"radius" in shape |
Union members that declare that property |
Equality / switch |
shape.kind === "circle" |
Union members matching the literal |
| Truthiness | if (value) |
Removes null, undefined, and other falsy values |
Array.isArray |
Array.isArray(x) |
Array types |
| Type predicate | function isFish(p): p is Fish |
Custom logic, reusable |
| Assertion function | function assertIsFoo(x): asserts x is Foo |
Narrows for the rest of the scope after the call |
A single function can combine several of these techniques, narrowing a bit further with each check:
function describe(input: string | number | Date | null): string {
if (input === null) {
return "no value";
}
if (typeof input === "string") {
return `string: ${input}`;
}
if (typeof input === "number") {
return `number: ${input}`;
}
if (input instanceof Date) {
return `date: ${input.toISOString()}`;
}
return "unreachable";
}
console.log(describe(null));
console.log(describe("hi"));
console.log(describe(42));
console.log(describe(new Date(0)));
Output:
no value
string: hi
number: 42
date: 1970-01-01T00:00:00.000Z
Each if in describe rules out one possibility, and because every branch returns, TypeScript can track exactly what’s left by the time it reaches the final line. By then input can only logically be Date, but the compiler still requires a final statement to satisfy the “must return a value” check, since it doesn’t prove that last branch is unreachable.
Examples
Example 1: Narrowing with typeof
The most common narrowing check is typeof, used whenever a union mixes primitive types:
function formatValue(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase();
}
return value.toFixed(2);
}
console.log(formatValue("hello"));
console.log(formatValue(3.14159));
Output:
HELLO
3.14
Inside the if block, value is narrowed from string | number down to just string, so .toUpperCase() is available. Because that branch returns, everything after the if is only reached when the check was false — TypeScript narrows value to number there without another explicit check, which is why .toFixed(2) type-checks on the final line.
Example 2: Discriminated Unions with switch
The most powerful narrowing pattern is the discriminated union: give every member of a union a shared property with a distinct literal type (commonly called the “discriminant” or “tag”), and TypeScript will narrow the entire object whenever you check that property.
interface Circle {
kind: "circle";
radius: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
type Shape = Circle | Rectangle;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
default: {
const _exhaustive: never = shape;
return _exhaustive;
}
}
}
const shapes: Shape[] = [
{ kind: "circle", radius: 2 },
{ kind: "rectangle", width: 3, height: 4 },
];
for (const shape of shapes) {
console.log(area(shape).toFixed(2));
}
Output:
12.57
12.00
Inside case "circle", shape is narrowed to Circle, so shape.radius is valid; inside case "rectangle", it’s narrowed to Rectangle. The default branch demonstrates the exhaustiveness check pattern: if every case has been handled, the only type left for shape is never, so assigning it to a variable typed never type-checks. If a new shape were added to the union later and a case were forgotten, that assignment would fail to compile — turning a missed case into a compile-time error instead of a runtime bug.
Example 3: Custom Type Guards
When the shape of your data doesn’t include a convenient discriminant, you can write your own type guard: a function whose return type is parameterName is Type.
interface Fish {
swim(): void;
}
interface Bird {
fly(): void;
}
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
function move(pet: Fish | Bird): string {
if (isFish(pet)) {
pet.swim();
return "swimming";
}
pet.fly();
return "flying";
}
const fish: Fish = { swim: () => console.log("splash") };
const bird: Bird = { fly: () => console.log("flap") };
console.log(move(fish));
console.log(move(bird));
Output:
splash
swimming
flap
flying
isFish runs an arbitrary runtime check (does this object have a swim method?) and tells the compiler, via its pet is Fish return type, to trust that result for narrowing purposes. Anywhere isFish(pet) is used as a condition, TypeScript narrows pet to Fish in the truthy branch and to the remaining union member, Bird, in the falsy branch — even though the underlying check is just a property lookup that TypeScript itself could never verify was accurate.
Under the Hood: Narrowing Step by Step
Walking through formatValue from Example 1 shows exactly what the compiler is doing at each line:
- At the function’s start,
valuehas its declared type,string | number. - The condition
typeof value === "string"is recognized as a type guard. Inside theifblock, the compiler intersects the declared type with “is a string,” leaving juststring. value.toUpperCase()type-checks becausestringhas that method; had you triedvalue.toFixed(2)in that same branch, it would fail, sincetoFixeddoesn’t exist onstring.- Because the
ifbranch alwaysreturns, TypeScript knows execution only reaches the line after theifwhen the condition was false. It narrowsvalueby removingstringfrom the union, leavingnumber— without you writing a second explicit check.
Now compare that to what actually ships to the browser or Node. Run this file through tsc and every type annotation — the : string | number parameter type, the : string return type — disappears completely. What’s left is:
function formatValue(value) {
if (typeof value === "string") {
return value.toUpperCase();
}
return value.toFixed(2);
}
The typeof check survives because it was already valid, ordinary JavaScript — narrowing didn’t add a runtime check, it just let the compiler reason about a check you were going to write anyway. This is why narrowing is sometimes described as “free”: you get stronger guarantees purely from code you’d write regardless, with no runtime cost and no extra bytes in the compiled output.
Common Mistakes
Mistake 1: Forgetting that typeof x === "object" includes null
A famous quirk inherited from JavaScript itself: typeof null is "object". Checking for "object" alone does not rule out null, and TypeScript will hold you to that:
function printLength(value: string | { length: number } | null): void {
if (typeof value === "object") {
console.log(value.length);
}
}
This fails to compile with an error along the lines of “Object is possibly ‘null’.” (ts(2531)), because after the typeof check, value is narrowed to { length: number } | null — not just the object type. Fix it by explicitly excluding null as part of the same check:
function printLength(value: string | { length: number } | null): void {
if (typeof value === "object" && value !== null) {
console.log(value.length);
} else if (typeof value === "string") {
console.log(value.length);
}
}
printLength("hello");
printLength({ length: 42 });
printLength(null);
Output:
5
42
The third call, printLength(null), matches neither branch, so it silently produces no output — which is exactly the point of handling null explicitly instead of letting it slip through.
Mistake 2: Expecting narrowing to survive inside a closure
Narrowing a plain variable persists inside nested functions, but narrowing a property access like box.value generally does not, because TypeScript can’t guarantee box.value hasn’t changed by the time an inner function actually runs:
interface Box {
value: string | number;
}
function process(box: Box): void {
if (typeof box.value === "string") {
setTimeout(() => {
console.log(box.value.toUpperCase());
}, 0);
}
}
Inside the setTimeout callback, TypeScript reports “Property ‘toUpperCase’ does not exist on type ‘string | number’.” (ts(2339)) — the narrowing of box.value from the outer if doesn’t carry into the closure, because the callback might run after something else has reassigned box.value to a number. The fix is to copy the narrowed value into its own local variable before creating the closure — a local const can’t be reassigned, so TypeScript is happy to trust its narrowed type anywhere it’s used:
interface Box {
value: string | number;
}
function process(box: Box): void {
const value = box.value;
if (typeof value === "string") {
setTimeout(() => {
console.log(value.toUpperCase());
}, 0);
}
}
process({ value: "narrowing" });
Output:
NARROWING
This pattern — narrow once, capture into a const, then use the const everywhere, including inside callbacks — comes up constantly once you’re working with union-typed object properties.
Best Practices
- Prefer discriminated unions (a shared literal
kind/typeproperty) over loosely related interfaces — they letswitchand exhaustiveness checks do most of the work for you. - Add a
defaultcase that assigns the remaining value to a variable typedneverso new union members cause a compile error if you forget to handle them. - When narrowing an object property that will be used inside a callback, closure, or after an
await, copy it into a localconstfirst. - Write reusable user-defined type guards (
x is Foo) for checks you repeat in more than one place, instead of repeating type assertions (as Foo). - Avoid type assertions (
as) as a substitute for narrowing — they turn off type checking instead of proving anything, and can hide real bugs. - Remember
typeof null === "object"— always check fornullexplicitly rather than assuming an"object"check excludes it. - Use
Array.isArray(x)to narrow a value that might beT | T[]; a plaintypeofcheck won’t help since arrays are also"object". - Keep narrowing checks close to where the narrowed value is used — the further apart they are, the more likely an intervening assignment or function call invalidates the narrowing.
Practice Exercises
- Write a function
describeInput(value: string | string[] | undefined): stringthat returns"empty"forundefined, joins the array with commas if it’s an array, and returns the string itself otherwise. Use narrowing — no type assertions. - Model a discriminated union
type Result = { status: "ok"; data: string } | { status: "error"; message: string }and write a functionreport(result: Result): stringthat uses aswitchonstatuswith an exhaustiveness check in thedefaultcase. - Write a user-defined type guard
isStringArray(value: unknown): value is string[]that checks the value is an array and every element is a string. Use it to narrow avalue: unknownparameter before calling.join(", ")on it.
Summary
- Type narrowing lets TypeScript refine a broad (often union) type into a more specific one within a branch of code, based on control flow analysis.
- Recognized narrowing checks include
typeof,instanceof,in, equality/switch, truthiness,Array.isArray, discriminated unions, user-defined type predicates, and assertion functions. - Discriminated unions paired with
switchand anever-typed exhaustiveness check are the most robust pattern for modeling variant data. - Narrowing is compile-time only — it’s erased entirely at runtime, and the checks you write are ordinary JavaScript operators the compiler happens to interpret specially.
typeof null === "object"is a classic trap; always rule outnullexplicitly.- Narrowing of object property accesses doesn’t reliably survive inside closures — capture the narrowed value into a local
constfirst.
