Type Narrowing and Type Guards in TypeScript: Writing Safer Conditionals
Every union type you write eventually needs to be split back apart โ you declare string | number, and somewhere downstream you need to know which one you actually have before calling a method that only exists on one of them. Narrowing is TypeScriptโs mechanism for that: the compiler watches your control flow (if, switch, &&, early returns) and progressively refines a variableโs type based on the checks youโve already performed. This guide covers every narrowing mechanism TypeScript actually understands, plus the moments narrowing silently breaks โ which is where most real confusion comes from.
typeof Narrowing
function formatValue(value: string | number | boolean): string { if (typeof value === "string") { return value.toUpperCase(); // narrowed to string } if (typeof value === "number") { return value.toFixed(2); // narrowed to number } return value ? "yes" : "no"; // narrowed to boolean by elimination}typeof is a genuine JavaScript runtime operator (unlike TypeScript-only concepts like interfaces), which is exactly why it works for narrowing โ the check that runs at compile time to narrow the type is the same check that actually executes at runtime to determine behavior. TypeScript recognizes a fixed set of typeof results ("string", "number", "boolean", "undefined", "function", "object", "symbol", "bigint") and narrows accordingly โ including the last branch above, where TypeScript infers boolean purely by process of elimination, having already ruled out string and number in the earlier branches.
instanceof Narrowing
class ApiError extends Error { constructor(public statusCode: number, message: string) { super(message); }}
function handleError(error: Error | ApiError) { if (error instanceof ApiError) { console.log(`API error ${error.statusCode}: ${error.message}`); // narrowed to ApiError } else { console.log(`Generic error: ${error.message}`); }}instanceof works for narrowing specifically because classes (unlike interfaces) create a real runtime constructor function โ instanceof checks the actual prototype chain at runtime, which is information TypeScriptโs compile-time type system can align with. This is one concrete, practical reason to model error types (and other cases needing runtime type identity) as classes rather than interfaces: only classes support this kind of narrowing.
in Narrowing
interface Circle { radius: number;}interface Square { side: number;}
function area(shape: Circle | Square): number { if ("radius" in shape) { return Math.PI * shape.radius ** 2; // narrowed to Circle } return shape.side ** 2; // narrowed to Square}in checks for the presence of a property at runtime and narrows accordingly โ genuinely useful when the union members donโt share a clean discriminant property (see the union types guide for the discriminant pattern) but do have distinguishing property names. Unlike typeof/instanceof, in works for narrowing plain object shapes that have no runtime class identity at all.
Equality and Literal Narrowing
type Status = "pending" | "active" | "closed";
function describe(status: Status) { if (status === "pending") { return "Waiting to start"; // narrowed to the literal type "pending" } if (status !== "closed") { return "In progress"; // narrowed to "active" by elimination } return "Finished";}// Switch statements narrow identically to chained if/elsefunction describeSwitch(status: Status): string { switch (status) { case "pending": return "Waiting to start"; case "active": return "In progress"; case "closed": return "Finished"; }}Direct equality checks against literal types (===, !==) narrow exactly the way typeof does, because theyโre just as much a genuine runtime check โ TypeScript tracks which literal values have already been ruled out branch by branch, which is what let it narrow status down to "active" purely from having ruled out "pending" and "closed" above.
Truthiness Narrowing
function printLength(value: string | null | undefined) { if (value) { console.log(value.length); // narrowed to string โ null and undefined are both falsy }}Truthiness narrowing is convenient but imprecise: it eliminates every falsy value at once (null, undefined, "", 0, false, NaN), which can narrow away more than intended. For string | null | undefined, this is exactly right โ an empty string being treated as โabsentโ is often what you actually want. But for number | null, if (value) also excludes the perfectly valid value 0, which is a common, genuine bug:
function processQuantity(quantity: number | null) { if (quantity) { // BUG: this branch is skipped when quantity is 0 โ a valid quantity! processOrder(quantity); }}
// FIX: check explicitly for the value you actually mean to excludefunction processQuantityFixed(quantity: number | null) { if (quantity !== null) { processOrder(quantity); // correctly runs even when quantity is 0 }}This is the same โfalsy but validโ trap covered from the JavaScript side in the ?? vs || discussion โ truthiness narrowing inherits it directly, because itโs built on the exact same JavaScript truthiness rules.
Custom Type Guards (is Predicates)
interface Cat { meow(): void;}interface Dog { bark(): void;}
function isCat(animal: Cat | Dog): animal is Cat { return (animal as Cat).meow !== undefined;}
function makeSound(animal: Cat | Dog) { if (isCat(animal)) { animal.meow(); // narrowed to Cat, based on isCat's return } else { animal.bark(); }}animal is Cat is a type predicate โ it tells TypeScript โif this function returns true, treat the argument as narrowed to Cat in the calling code from that point on.โ The functionโs actual runtime logic (checking for the .meow property here) is entirely up to you; TypeScript doesnโt verify that the logic is correct, only that the functionโs signature promises a specific narrowing. This means a poorly-written custom type guard can lie to the compiler โ itโs your responsibility to make sure the runtime check genuinely corresponds to the type being asserted.
// A custom type guard is essential once "in" or "typeof" can't distinguish your typesfunction isNonEmptyArray<T>(arr: T[]): arr is [T, ...T[]] { return arr.length > 0;}
function getFirst<T>(arr: T[]): T | undefined { if (isNonEmptyArray(arr)) { return arr[0]; // narrowed to a tuple type guaranteeing at least one element } return undefined;}Assertion Functions
function assertIsString(value: unknown): asserts value is string { if (typeof value !== "string") { throw new Error("Expected a string"); }}
function process(value: unknown) { assertIsString(value); console.log(value.toUpperCase()); // narrowed to string for the rest of this scope, after the assertion}An assertion function is a close cousin of a type guard, but instead of returning a boolean that the caller branches on, it throws if the assertion fails and narrows the type for all code after the call, unconditionally โ no if needed. This maps directly onto the common validation pattern โcheck preconditions at the top of a function, then proceed with narrower types for the rest of it,โ which is far more common in real code than the boolean-returning style above for simple precondition checks.
// A generic, reusable assertion helperfunction assert(condition: unknown, message: string): asserts condition { if (!condition) { throw new Error(message); }}
function divide(a: number, b: number): number { assert(b !== 0, "Division by zero"); return a / b; // no narrowing benefit here specifically, but the pattern generalizes to nullable checks}
function getUser(user: User | null): User { assert(user !== null, "User must be logged in"); return user; // narrowed to User (not User | null) after the assertion}Where Narrowing Breaks
interface Config { timeout?: number;}
function useConfig(config: Config) { if (config.timeout !== undefined) { setTimeout(doSomething, config.timeout); // fine โ narrowed here }
// But through a callback or a stored reference, narrowing doesn't survive: setTimeout(() => { console.log(config.timeout.toFixed(0)); // Error โ narrowing doesn't persist inside a later closure }, 1000);}Narrowing is a static, local analysis tied to the exact code path the compiler can see linearly โ it doesnโt survive across an async callback, a separate function call that might have mutated the object in between, or an object property (as opposed to a local variable) that TypeScript canโt prove wasnโt changed by some other code that ran between the check and the use. The fix is almost always to copy the narrowed value into a local constant, which does survive:
function useConfig(config: Config) { const timeout = config.timeout; if (timeout !== undefined) { setTimeout(() => { console.log(timeout.toFixed(0)); // fine โ a local const, not a property, retains its narrowed type }, 1000); }}This distinction โ narrowing works reliably on local const variables, and only conditionally on object properties or let variables that could be reassigned โ is one of the most common sources of โbut I already checked for null!โ compile errors, and knowing the fix (extract to a local const) resolves the overwhelming majority of them.
Narrowing Quick Reference
| Mechanism | Works for | Runtime cost |
|---|---|---|
typeof x === "..." | Primitives (string, number, boolean, etc.) | None โ native JS operator |
x instanceof ClassName | Class instances | None โ native JS operator, requires a real class |
"prop" in x | Distinguishing object shapes by property presence | None โ native JS operator |
x === literalValue | Literal type unions | None |
Truthiness (if (x)) | Quick null/undefined checks โ imprecise for 0/"" | None |
Custom type guard (x is T) | Anything the built-in checks canโt express | Whatever your functionโs logic costs |
Assertion function (asserts x is T) | Precondition checks that should throw, not branch | Whatever your functionโs logic costs |
Frequently Asked Questions
Why doesnโt narrowing work on an object property the way it works on a local variable? TypeScript canโt fully guarantee an objectโs property wasnโt mutated by other code (a setter, a function call with a reference to the same object) between the check and the use โ a local variable canโt be changed out from under you in the same way, so narrowing on it is safe to trust more broadly.
Can I narrow a value coming from an external API without validating it manually? No โ narrowing only works on values TypeScript already has some declared type for (even if that type is a wide union or unknown). Data from JSON.parse() or an untyped API is any by default, which defeats narrowing entirely (see the basic types guide) โ validate it into a known shape with a runtime library (Zod, io-ts) first, or write an explicit custom type guard for it.
What happens if my custom type guardโs logic is wrong? TypeScript will trust it and narrow anyway โ a type predicate is a promise youโre making to the compiler, not something it independently verifies against your function body. A buggy type guard is a genuinely dangerous class of bug precisely because it makes the type system confidently wrong.
Is there a difference between a type guard and an assertion function beyond syntax? Yes โ a type guard returns a boolean the caller branches on (useful when both the true and false cases need handling), while an assertion function throws on failure and narrows unconditionally for the rest of the scope (useful for โthis must be true to proceed, and there is no meaningful false case to keep handlingโ).
Does narrowing have any runtime cost? No โ narrowing is a purely compile-time analysis. The runtime checks that enable narrowing (typeof, instanceof, in) do have their normal JavaScript runtime cost, but that cost exists whether or not TypeScript is involved โ narrowing itself adds nothing at runtime.
Whatโs Next
You can now split a union apart safely using every mechanism TypeScript actually recognizes, and you know exactly where narrowing silently stops working. This closes out the core type-system fundamentals. Next: Generics in TypeScript, where we move from typing specific, known shapes to writing genuinely reusable code that works correctly across many types at once.