Technology  /  TypeScript

๐Ÿ”ท TypeScript 16 guides ยท updated 2026

Type erasure, structural typing, generics, and the type-level programming toolkit โ€” TypeScript taught the way it actually behaves at compile time and at runtime.

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/else
function 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 exclude
function 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 types
function 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 helper
function 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.

Yes

No โ€” inside a closure,

after another call, etc.

Property or let variable narrowed

in an if-check

Used immediately,

same linear scope?

Narrowing holds

Narrowing lost โ€”

extract to a local const first


Narrowing Quick Reference

MechanismWorks forRuntime cost
typeof x === "..."Primitives (string, number, boolean, etc.)None โ€” native JS operator
x instanceof ClassNameClass instancesNone โ€” native JS operator, requires a real class
"prop" in xDistinguishing object shapes by property presenceNone โ€” native JS operator
x === literalValueLiteral type unionsNone
Truthiness (if (x))Quick null/undefined checks โ€” imprecise for 0/""None
Custom type guard (x is T)Anything the built-in checks canโ€™t expressWhatever your functionโ€™s logic costs
Assertion function (asserts x is T)Precondition checks that should throw, not branchWhatever 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.