Type Narrowing and Control-Flow Analysis
Master how TypeScript narrows union types within conditional branches using typeof, instanceof, in, truthiness, discriminated unions, and custom type guards, and learn to write exhaustive checks that catch missing cases at compile time.
Type narrowing is how TypeScript turns a broad type like string | number into a specific, safe type inside a particular branch of your code, just by looking at the checks you already wrote — an if, a switch, or a typeof test. This is what makes union types practical to work with instead of forcing constant manual casting.
Built-in narrowing: typeof, instanceof, in, and equality
typeof narrowing works for primitives: if (typeof value === "string") narrows value to string inside that branch, and to whatever remains of the union in the else. This is exactly the pattern in the required example — print(value: string | number) uses typeof value === "string" to safely call .toUpperCase() in one branch and .toFixed(2) in the other, where value is narrowed to number.
instanceof narrows based on prototype chain, which is essential for class instances and built-in error/DOM types: if (error instanceof RangeError) narrows error to RangeError inside that branch, giving you access to RangeError-specific properties safely.
The in operator narrows based on whether a property exists on an object, which is especially useful for union types made of plain object shapes rather than classes: if ("wingspan" in animal) can narrow animal from Bird | Fish down to just Bird if only Bird has a wingspan property.
Equality narrowing compares a value directly against a literal: if (value === "loading") narrows a union like "idle" | "loading" | "error" down to exactly "loading" in that branch. Comparing two variables of union types against each other can also narrow both sides simultaneously to their common overlap.
Truthiness narrowing and null/undefined under strict mode
A plain if (value) check narrows away falsy values — null, undefined, 0, "", NaN, and false — which is convenient but imprecise: if (value) on a string doesn't just rule out null/undefined, it also rules out the empty string, which may not be what you intended if an empty string is a valid, meaningful value in your domain.
Under strictNullChecks (part of strict mode, and something every serious TypeScript project should enable), null and undefined are not automatically included in every type — a string really only holds strings, and you must write string | null explicitly if null is possible. This forces every place a value might be missing to be handled deliberately, which is one of strict mode's biggest real-world bug-prevention wins.
The optional chaining operator (?.) and nullish coalescing operator (??) work naturally with this: user?.address?.city ?? "Unknown" short-circuits to undefined (then falls back to "Unknown") at the first null/undefined link in the chain, without needing a manual chain of if checks.
Discriminated unions and exhaustive switch patterns
A discriminated union is a union of object types that all share a common literal-typed property — the 'discriminant' or 'tag' — that TypeScript can switch on to narrow the whole object at once. For example, type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number } lets you write if (shape.kind === "circle") and have shape.radius become accessible in that branch, with no manual casting.
This pattern shines in a switch (shape.kind) statement with one case per tag value — each case body gets the exactly matching member type of the union, giving full property access with full safety.
Exhaustiveness checking catches the case where you add a new member to the union later (say, { kind: "triangle"; ... }) but forget to add a corresponding case. The standard pattern assigns the value in the default branch to a variable typed never: default: const _exhaustive: never = shape;. If any union member wasn't handled, TypeScript reports a compile error right there, because that unhandled member's type can't be assigned to never.
User-defined type guards and assertion functions
When built-in narrowing isn't expressive enough, you can write your own type guard function using a special return type: function isFish(pet: Fish | Bird): pet is Fish { return (pet as Fish).swim !== undefined; }. The pet is Fish return type (a 'type predicate') tells TypeScript that whenever this function returns true, the argument should be narrowed to Fish in the calling code — and to the complementary type in the else branch.
Assertion functions are a related but distinct pattern: instead of returning a boolean, they either narrow the argument's type as a side effect or throw. Written as function assertIsString(val: unknown): asserts val is string { if (typeof val !== "string") throw new Error("not a string"); }, calling assertIsString(x) narrows x to string for every line of code after the call — no if block needed.
TypeScript's control-flow analysis tracks narrowing through reassignments, early returns, and thrown errors — if a function starts with if (typeof value !== "string") return;, everything below that line correctly sees value as string, because TypeScript understands the early return eliminates the other branch.
Core narrowing patterns
The pet is Fish syntax is a type predicate — only valid as a function's return type annotation. The exhaustive default: const x: never = shape; pattern only compiles if every union member was already handled by a prior case.
// typeof
function print(value: string | number) {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value.toFixed(2));
}
}
// instanceof
function handle(error: Error) {
if (error instanceof RangeError) {
console.log(error.message, "(range)");
}
}
// discriminated union + exhaustive switch
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
const _exhaustive: never = shape;
return _exhaustive;
}
}
// user-defined type guard
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}Code Examples
Inside the if branch, TypeScript narrows value to exactly string, so .toUpperCase() is available; in the else branch, it's narrowed to number, so .toFixed() is available. Calling the wrong method in the wrong branch would be a compile error. Expected result: HELLO 3.14
If a new shape variant like { kind: "triangle"; base: number; height: number } is added to the Shape union later but no matching case is added here, the default branch's shape would still include triangle, which can't be assigned to never — producing an immediate compile error that flags the missing case.
Common mistake: Omitting the default: const _exhaustive: never = shape; line means a forgotten case silently falls through with no return value (or an implicit undefined) instead of being caught at compile time.
function print(value: string | number) {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value.toFixed(2));
}
}
print("hello"); // HELLO
print(3.14159); // 3.14type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
const _exhaustive: never = shape;
return _exhaustive;
}
}Narrowing a union across a branch
Each branch of the conditional carries its own narrowed type, computed automatically by TypeScript's control-flow analysis.
value: string | number
|
| if (typeof value === "string")
v
+-----------------+ +--------------------+
| branch: true | | branch: false |
| value: string | | value: number |
+-----------------+ +--------------------+
| |
value.toUpperCase() value.toFixed(2)
(safe, string-only) (safe, number-only)Important Rules
Prefer discriminated unions with a literal kind/type tag over loosely related object shapes — they narrow cleanly with a simple equality or switch check.
Always add the default: const x: never = value; exhaustiveness pattern to switch statements over a closed union, so new variants can't silently slip through unhandled.
Write a custom type guard (x is T) when a check is more complex than a single typeof/instanceof/in, and you'll reuse that check in multiple places.
Enable strictNullChecks (via strict: true) on every real project — without it, null and undefined narrowing doesn't meaningfully exist, and a huge class of runtime errors goes unchecked.
Where this shows up
Discriminated unions with exhaustive switches are the standard way to model API responses ({ status: "success"; data: T } | { status: "error"; message: string }), Redux/reducer actions, and parser/AST node types. User-defined type guards are common at the boundary between untyped external data (JSON responses, form input) and your typed application code, where a function like isValidUser(data: unknown): data is User validates and narrows in one step.
function isFish(pet: Fish | Bird): boolean { return (pet as Fish).swim !== undefined; } // Calling code still sees pet: Fish | Bird even after isFish(pet) is true function isFish(pet: Fish | Bird): pet is Fish { return (pet as Fish).swim !== undefined; } // Calling code narrows to Fish after isFish(pet) is true
A plain boolean return type does the same runtime check but gives TypeScript no way to narrow the caller's variable — only the pet is Fish type predicate return type does that.
[multiple-choice] In if (typeof value === "string") { ... } else { ... } where value: string | number, what is the type of value inside the else branch? A. string | number B. string C. number D. any
C. number — TypeScript removes string from the union in the branch where the typeof check failed, leaving exactly number.
[conceptual] What is the purpose of assigning a value to a never-typed variable in the default case of an exhaustive switch? A. It improves runtime performance B. It documents the code but has no compile-time effect C. It causes a compile error if any union member wasn't handled by an earlier case, catching missed cases D. It converts the switch into a numeric enum
C. It causes a compile error if any union member wasn't handled by an earlier case, catching missed cases — Only a value of type never can be assigned to a never-typed variable; if any union member slips through unhandled to the default case, its type won't be never, and TypeScript flags the mismatch.
[predict-output] What does a type predicate return type like pet is Fish do that a plain boolean return type does not? A. It changes the function's runtime behavior B. It narrows the argument's type in the caller's code after the check returns true C. It makes the function generic D. It prevents the function from being called with a Bird
B. It narrows the argument's type in the caller's code after the check returns true — A type predicate is a compile-time signal to TypeScript's narrowing analysis; a plain boolean return gives the same runtime result but no narrowing benefit for the caller.
Write a discriminated union type Result<T> = { ok: true; value: T } | { ok: false; error: string } and a function unwrap<T>(result: Result<T>): T that returns result.value when ok is true and throws new Error(result.error) otherwise, using narrowing on the ok property.
type Result<T> = { ok: true; value: T } | { ok: false; error: string }; function unwrap<T>(result: Result<T>): T { if (result.ok) { return result.value; } throw new Error(result.error); }
Check if (result.ok) first — inside that branch, TypeScript narrows to the { ok: true; value: T } member automatically.