Narrowing
Narrowing is the process by which the TypeScript compiler refines a value’s type within a region of code. The compiler performs control-flow analysis — tracking how a variable’s type changes through if, switch, type guards, and assignments. The principal narrowing forms are typeof guards (typeof x === "string"), instanceof guards (x instanceof Animal), equality narrowing (x === null), truthiness narrowing (if (x)), the in operator, and user-defined type predicates (x is Foo). The combination — automatic narrowing in branches, exhaustiveness checking via never, discriminated unions — is one of TypeScript’s most distinctive features.
typeof narrowing
The typeof operator returns the JavaScript type as a string:
function format(x: string | number): string {
if (typeof x === "string") {
return x.toUpperCase(); // x is narrowed to string
}
return x.toFixed(2); // x is narrowed to number
}
The typeof returns one of: "string", "number", "bigint", "boolean", "symbol", "undefined", "object", "function". A subtle pitfall: typeof null === "object":
function process(x: object | null) {
if (typeof x === "object") {
x.toString(); // ERROR: x might still be null
}
}
// Defence:
function process(x: object | null) {
if (x !== null) {
x.toString(); // OK; narrowed to object
}
}
Truthiness narrowing
if (x) narrows out null, undefined, 0, "", false, and NaN:
function format(s: string | undefined): string {
if (s) {
return s.toUpperCase(); // s is narrowed to string
}
return "default";
}
function area(items: number[] | null): number {
if (items) {
return items.reduce((a, b) => a + b, 0); // narrowed to number[]
}
return 0;
}
A pitfall: empty arrays are truthy:
const arr: number[] = [];
if (arr) console.log("truthy"); // prints
The convention checks arr.length for emptiness:
if (items && items.length > 0) {
/* ... */
}
Equality narrowing
function process(x: string | number, y: string | boolean) {
if (x === y) {
// both narrowed to string (the only common type)
x.toUpperCase();
y.toUpperCase();
}
}
function nullable(x: string | null) {
if (x !== null) {
return x.toUpperCase(); // narrowed to string
}
return "default";
}
The === null, !== null, === undefined, !== undefined checks narrow the type accordingly.
For checking both null and undefined, the loose == is the conventional defence:
function nullable(x: string | null | undefined) {
if (x == null) { // matches null OR undefined
return "default";
}
return x.toUpperCase(); // narrowed to string
}
(One of the rare conventional uses of ==.)
instanceof narrowing
class Animal { name: string = ""; }
class Dog extends Animal { breed: string = ""; }
function describe(x: Animal | Dog | string) {
if (x instanceof Dog) {
return `Dog: ${x.breed}`; // x is Dog
}
if (x instanceof Animal) {
return `Animal: ${x.name}`; // x is Animal
}
return `String: ${x}`; // x is string
}
instanceof checks the prototype chain; the mechanism admits substantial discrimination on class hierarchies.
in narrowing
The in operator checks whether a key exists in an object:
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim(); // narrowed to Fish
} else {
animal.fly(); // narrowed to Bird
}
}
The form admits substantial discrimination on object structure.
Discriminated unions
A discriminated union (also called tagged union) is a union of object types with a common literal-typed property — the discriminant:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2; // narrowed to circle variant
case "square":
return s.side ** 2; // narrowed to square
case "triangle":
return (s.base * s.height) / 2; // narrowed to triangle
}
}
The pattern is one of TypeScript’s most distinctive idioms; conventional for state machines, parser ASTs, action types in reducers, and similar.
Exhaustiveness checking with never
For exhaustive matching, assign the discriminant to a never-typed variable in the default branch:
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2;
case "square":
return s.side ** 2;
case "triangle":
return (s.base * s.height) / 2;
default:
const _exhaustive: never = s; // ERROR if s is not narrowed to never
return _exhaustive;
}
}
If a new variant is added to Shape, the assignment fails: s cannot be narrowed to never, indicating an unhandled case. The mechanism admits compile-time exhaustiveness checking.
A reusable helper:
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "square": return s.side ** 2;
case "triangle": return (s.base * s.height) / 2;
default: return assertNever(s);
}
}
User-defined type predicates
The value is Type return-type form admits user-defined narrowing:
function isString(x: unknown): x is string {
return typeof x === "string";
}
function process(value: unknown) {
if (isString(value)) {
value.toUpperCase(); // narrowed to string
}
}
The compiler trusts the predicate’s return value: when isString(x) returns true, x is narrowed to string. The conventional uses are runtime validation:
interface User {
name: string;
email: string;
}
function isUser(x: unknown): x is User {
return (
typeof x === "object" &&
x !== null &&
"name" in x &&
"email" in x &&
typeof (x as User).name === "string" &&
typeof (x as User).email === "string"
);
}
const data: unknown = JSON.parse(input);
if (isUser(data)) {
console.log(data.name); // OK; narrowed to User
}
The conventional discipline is to use type predicates sparingly and verify the assertions; an incorrect predicate produces unsoundness.
Assertion functions
Since TS 3.7, assertion functions admit narrowing via assertion rather than predicate:
function assert(condition: unknown, message?: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertIsString(x: unknown): asserts x is string {
if (typeof x !== "string") throw new Error("not a string");
}
function process(value: unknown) {
assertIsString(value);
value.toUpperCase(); // OK; narrowed to string
}
The asserts condition and asserts x is T return-type forms admit narrowing in the containing scope after the function returns. Conventional for assert-style validation.
Narrowing through assignment
Assignments narrow:
let x: string | number = getInput();
if (typeof x === "string") {
x = x.toUpperCase(); // x is string here AND after
} else {
x = x.toFixed(2); // x is number here, becomes string
}
// x is string here (both branches narrowed it)
The compiler tracks the type of each binding through all assignments.
Narrowing through equality with literal
type Color = "red" | "green" | "blue" | "yellow";
function process(c: Color) {
if (c === "red") {
// c is "red" here
} else {
// c is "green" | "blue" | "yellow" here
}
}
The compiler narrows on equality with literal values, including string literals, number literals, boolean literals, and null/undefined.
Narrowing arrays
function first(items: string[] | null): string | undefined {
if (items && items.length > 0) {
return items[0]; // items is string[]
}
return undefined;
}
function processNumbers(items: (string | number)[]) {
items.forEach(x => {
if (typeof x === "number") {
console.log(x.toFixed(2)); // x is number
} else {
console.log(x.toUpperCase()); // x is string
}
});
}
The compiler does not narrow array elements based on a check of one element; each iteration’s narrowing is independent.
The noUncheckedIndexedAccess option
Under noUncheckedIndexedAccess: true, indexed access returns T | undefined:
const arr: string[] = ["a", "b", "c"];
const x = arr[0]; // type: string | undefined
if (x) {
console.log(x.toUpperCase()); // narrowed to string
}
// For tuple types, the indexed access is precise:
const tuple: [string, number] = ["a", 1];
const a = tuple[0]; // type: string (no undefined)
The conventional contemporary recommendation is to enable the option; it admits substantial safety against off-by-one and out-of-bounds errors.
Common patterns
Validate-and-use
function processAge(input: unknown): number | null {
if (typeof input !== "number" || isNaN(input)) {
return null;
}
if (input < 0 || input > 150) {
return null;
}
return input;
}
Discriminated union for actions
type Action =
| { type: "INCREMENT"; by: number }
| { type: "DECREMENT"; by: number }
| { type: "RESET" }
| { type: "SET"; value: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "INCREMENT": return state + action.by;
case "DECREMENT": return state - action.by;
case "RESET": return 0;
case "SET": return action.value;
default: return assertNever(action);
}
}
Result type
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return { ok: false, error: new Error("division by zero") };
}
return { ok: true, value: a / b };
}
const r = divide(10, 2);
if (r.ok) {
console.log(r.value); // narrowed to value branch
} else {
console.log(r.error); // narrowed to error branch
}
The pattern admits explicit error handling without exceptions; treated in Error handling.
State machine
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; error: string };
function render(state: State): string {
switch (state.status) {
case "idle": return "Click to load";
case "loading": return "Loading...";
case "success": return state.data;
case "error": return `Error: ${state.error}`;
}
}
JSON validation
function isPerson(x: unknown): x is Person {
if (typeof x !== "object" || x === null) return false;
const obj = x as Record<string, unknown>;
return (
typeof obj.name === "string" &&
typeof obj.age === "number"
);
}
const data: unknown = JSON.parse(input);
if (isPerson(data)) {
console.log(data.name);
}
For substantial validation, libraries like zod, valibot, and io-ts admit declarative schema definition and runtime checking with type inference.
Optional chaining narrowing
function getCity(user: User | null): string | undefined {
return user?.address?.city;
}
const city = getCity(currentUser);
if (city !== undefined) {
console.log(city.toUpperCase());
}
instanceof for error types
try {
riskyOperation();
} catch (e) {
if (e instanceof NetworkError) {
return retryAfter(e.retryAfter);
}
if (e instanceof ValidationError) {
return reportField(e.field);
}
throw e;
}
Generic narrowing
function findFirst<T>(arr: T[], pred: (x: T) => boolean): T | undefined {
for (const x of arr) {
if (pred(x)) return x;
}
return undefined;
}
const num = findFirst([1, 2, 3], x => x > 2);
if (num !== undefined) {
console.log(num.toFixed(2)); // narrowed to number
}
as const for narrow inference
const directions = ["up", "down", "left", "right"] as const;
type Direction = typeof directions[number]; // "up" | "down" | "left" | "right"
function move(d: Direction) { /* ... */ }
move("up"); // OK
The pattern admits enum-like behaviour without enum.
A note on the limits of narrowing
Narrowing is flow-sensitive but does not survive arbitrary control flow:
function process(x: string | undefined) {
if (x === undefined) return;
helper(); // does helper() reset x's narrowing?
x.toUpperCase(); // OK if helper() couldn't change x
}
function helper() {
// could mutate global state
}
For variables captured in closures or accessed from other code, the compiler may re-widen the type after function calls:
let x: string | undefined = getInput();
if (x !== undefined) {
fn(); // calling fn might widen x's type
x.toUpperCase(); // ERROR (x re-widened to string | undefined)
}
// Defence: narrow into a const:
if (x !== undefined) {
const safe = x;
fn();
safe.toUpperCase(); // OK
}
The pattern is conventional for substantial narrowing across function calls.
A note on the conventional discipline
The contemporary TypeScript narrowing advice:
- Use discriminated unions for closed sets of variants.
- Use
neverandassertNeverfor exhaustiveness checking. - Use
typeofandinstanceoffor primitive and class narrowing. - Use
infor object-property narrowing. - Use type predicates (
x is T) for runtime validation. - Use assertion functions (
asserts x is T) for assert-style narrowing. - Enable
noUncheckedIndexedAccess— array access returnsT | undefined. - Use
unknownoverany— narrows to specific types. - Use
==null(rare exception) for “null or undefined”. - Use libraries (
zod, etc.) for substantial validation.
The combination — flow-sensitive type analysis, discriminated unions, exhaustiveness checking, type predicates, the in/instanceof/typeof operators — is the substance of TypeScript’s narrowing surface. The discipline produces substantial type safety with relatively little annotation overhead.