Concept
The beginner framing: a variable's type isn't fixed for the whole function it lives in, TypeScript tracks how that type changes as code passes through conditionals, early returns, and other checks, a process called narrowing.
function process(value: string | number | null) {
// here: value is string | number | null
if (value === null) return;
// here: value is string | number, null eliminated by the early return
if (typeof value === "string") {
value.toUpperCase(); // here: value is string, safe to call string methods
}
}This is control-flow analysis: TypeScript follows the actual branches and guards in the code, narrowing (shrinking) the set of possible types at each point based on what's already been checked.
function handle(x: string | number | null) {// ...}
At the top of the function, TypeScript knows only what the parameter's declared type says, the full union, no narrowing applied yet.
The guard forms: typeof, in, instanceof, and discriminated unions
function describe(value: string | number) {
if (typeof value === "string") return `text: ${value}`; // typeof, primitives
return `number: ${value}`;
}
interface Cat { meow(): void }
interface Dog { bark(): void }
function speak(pet: Cat | Dog) {
if ("meow" in pet) pet.meow(); // in, checks for a property's PRESENCE
else pet.bark();
}
class ApiError extends Error {}
function handle(err: Error) {
if (err instanceof ApiError) { /* narrowed to ApiError */ } // instanceof, class instances
}
type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
function area(s: Shape) {
if (s.kind === "circle") return Math.PI * s.radius ** 2; // narrowed via the shared "kind" literal
return s.side ** 2;
}The last pattern, a shared literal property (often called kind, type, or tag) used to distinguish union members, is a discriminated union, confirmed to narrow correctly via a simple equality check on that one property (s.kind === "circle"), without needing typeof/in/instanceof at all. This is one of the most common, idiomatic narrowing patterns in real TypeScript code.
Custom type predicates: x is T
function isCat(pet: Cat | Dog): pet is Cat {
return "meow" in pet;
}
function handle(pet: Cat | Dog) {
if (isCat(pet)) {
pet.meow(); // narrowed to Cat, based on the FUNCTION's return, not inline logic
}
}A function whose return type is written as x is T (rather than plain boolean) is a type predicate, confirmed to narrow correctly at call sites, letting you package narrowing logic into a reusable, named function rather than repeating an inline check everywhere it's needed.
Inferred type predicates, stable since TypeScript 5.5
const items: (string | null | undefined)[] = ["a", null, "b", undefined];
const filtered = items.filter((x) => x != null);
const strings: string[] = filtered; // ✅ compiles, filtered is ALREADY string[]Confirmed directly against this repo's TypeScript 5.9.3: as of 5.5, TypeScript automatically infers a type predicate from a .filter() callback like (x) => x != null, without any manual x is string annotation anywhere, filtered's type is inferred as string[], with null/undefined already excluded. This specifically matters as a version-currency fact: before 5.5, this exact code would have produced (string | null | undefined)[], the same, unfiltered union type, requiring a manually-written type predicate function to narrow correctly. A lot of existing code (and tutorials) still work around this with .filter(Boolean) plus a separate cast, which is no longer necessary for this common case.
The closure gotcha: narrowing can WIDEN again
function handle(x: string | number | null) {
if (typeof x !== "string") return;
// x: string HERE
setTimeout(() => {
console.log(x.toUpperCase()); // ✅ fine, x is NEVER reassigned anywhere in this function
}, 100);
}function handle(x: string | number | null) {
if (typeof x !== "string") return;
setTimeout(() => {
console.log(x.toUpperCase()); // ❌ NOW an error!
}, 100);
x = 5; // x IS reassigned somewhere in this function
}Confirmed by compiling both: the first version works fine, the narrowing to string persists into the closure, since x is never reassigned anywhere. The second version, identical except for one added reassignment later in the function, fails to compile inside the closure specifically, TypeScript widens x's type back to the full original union there, since it can't prove whether the closure runs before or after that reassignment. This is genuinely surprising the first time it's encountered: adding an unrelated line far away from the closure changes what's type-safe inside it.
Try It
Predict the outcome before checking the solution.
function getLength(value: string | string[]) {
if (Array.isArray(value)) {
return value.length; // does this narrow correctly?
}
return value.length;
}Does Array.isArray work as a narrowing guard here, the same way typeof/instanceof do?
Solution
Yes, Array.isArray() is a built-in type predicate (TypeScript's own lib types declare it with an is return type), so it narrows value to string[] inside the if block and to string in the implicit else path. Both .length accesses are safe: string[]'s .length and string's .length both exist, just conceptually meaning different things (array length vs. character count), this compiles cleanly either way, which is worth noting as a case where narrowing succeeds but doesn't necessarily save you from a logical mixup, only a type mixup.
Implement It Yourself
Build a custom type predicate function and use it to narrow a union:
interface SuccessResult { status: "success"; data: string }
interface ErrorResult { status: "error"; message: string }
type ApiResult = SuccessResult | ErrorResult;
function isSuccess(result: ApiResult): result is SuccessResult {
return result.status === "success";
}
function handleResult(result: ApiResult) {
if (isSuccess(result)) {
console.log(result.data); // narrowed to SuccessResult, .data exists
} else {
console.log(result.message); // narrowed to ErrorResult, .message exists
}
}This packages the discriminated-union check into a reusable, named predicate, useful when the same narrowing logic needs to be applied in multiple places, rather than repeating result.status === "success" inline everywhere.
Under the Hood
Narrowing is fundamentally a compile-time simulation of the exact runtime behavior covered in Coercion, typeof value === "string" at compile time is checking the same runtime operator that JavaScript itself evaluates when the code actually runs; TypeScript's narrowing is "smart" specifically because it tracks what a real runtime check like this proves about a value. The discriminated union pattern here is also what powers the never-based exhaustiveness technique from Types, that pattern IS narrowing, applied specifically to catch unhandled cases in a switch statement.
Common Mistakes
1. Assuming narrowing always survives into any closure
function handle(x: string | number | null) {
if (typeof x !== "string") return;
doSomethingElseWith(x); // ❓ might reassign x internally? No, x is a local narrowing target
setTimeout(() => console.log(x.toUpperCase()), 100); // depends ENTIRELY on whether x is reassigned ANYWHERE in this function
}Whether narrowing survives into a closure isn't about how far away the closure is from the narrowing check, it's specifically about whether the captured variable is reassigned anywhere in the enclosing function, full stop.
2. Writing a custom type predicate that lies
function isString(x: unknown): x is string {
return true; // ❌ ALWAYS returns true, TypeScript trusts this completely, with no verification
}TypeScript does not verify that a type predicate's return value actually corresponds to reality, it trusts the function's is annotation completely. A predicate with genuinely broken logic can narrow to an incorrect type with no compile error at all, since the compiler has no way to check the runtime logic inside the predicate function against its declared narrowing claim.
3. Assuming Array.isArray/similar built-ins can't be used for narrowing
if (Array.isArray(value)) { /* many people don't realize this narrows correctly */ }Several built-in JavaScript functions (Array.isArray, Number.isInteger in some contexts) have TypeScript lib definitions that already include proper type predicates, no need to write a custom wrapper.
Best Practices
- Prefer discriminated unions with a literal
kind/typeproperty for representing "one of several distinct variants", they narrow cleanly with a simple equality check and pair well with thenever-based exhaustiveness pattern. - Extract repeated narrowing logic into a named type predicate function rather than duplicating the same inline check across multiple places.
- Be aware of the closure-widening gotcha specifically when a narrowed variable is captured by a callback, if the variable is reassigned anywhere in the function, don't rely on the narrowing surviving inside that callback.
- Take advantage of inferred type predicates (5.5+) for common filtering patterns like removing
null/undefined, no need for a manual predicate function or anascast for this case anymore.
Performance Tips
- Narrowing is entirely a compile-time analysis, it adds zero runtime cost; the actual runtime checks (
typeof,in,instanceof) you write are the same checks you'd need regardless of TypeScript's involvement. - A custom type predicate function's body still executes at runtime exactly like any other function call, there's no special optimization or elision for predicate functions specifically, so an expensive predicate has the same runtime cost as an equally expensive regular function.
