Narrowing
Control-flow analysis, type guards, assertion functions, and exhaustive checks - how TypeScript proves a value's type at each point in the program.
TypeScript tracks the type of every value at every point in your control flow. When you check a type, TypeScript "narrows" the union inside that branch so you can safely access variant-specific properties without a cast.
typeof narrows primitive types. instanceof narrows class instances. The in operator narrows by property presence. Equality checks narrow by value. TypeScript applies each to the right branch automatically.
function process(value: string | number | Date): string {
if (typeof value === "string") {
return value.toUpperCase(); // value: string
}
if (value instanceof Date) {
return value.toISOString(); // value: Date
}
return value.toFixed(2); // value: number
}
type Circle = { kind: "circle"; radius: number };
type Rect = { kind: "rect"; width: number; height: number };
type Shape = Circle | Rect;
function describe(shape: Shape): string {
if ("radius" in shape) {
return `circle r=${shape.radius}`; // shape: Circle
}
return `rect ${shape.width}×${shape.height}`; // shape: Rect
}Truthiness narrowing removes null, undefined, 0, and "" from the type. Assignment narrowing refines the type each time a variable is assigned - TypeScript tracks all assignment sites in the function.
function greet(name: string | null): string {
if (!name) {
return "Hello, stranger"; // name is falsy here
}
return `Hello, ${name}`; // name: string (null removed)
}
// Assignment narrowing
let id: string | number = Math.random() > 0.5 ? "abc" : 42;
if (typeof id === "string") {
id = id.toUpperCase(); // id: string
} else {
id = id * 2; // id: number
}
// Nullable field narrowing
type Profile = { avatar: string | undefined };
function renderAvatar(profile: Profile): string {
const { avatar } = profile;
if (!avatar) return "/default-avatar.png"; // avatar is falsy: type stays string | undefined
return avatar; // avatar: string
}A user-defined type guard is a function whose return type is a type predicate (arg is Type). When the function returns true, TypeScript narrows the argument to the specified type in the calling scope.
type Cat = { kind: "cat"; meow(): void };
type Dog = { kind: "dog"; bark(): void };
type Animal = Cat | Dog;
function isCat(animal: Animal): animal is Cat {
return animal.kind === "cat";
}
function makeSound(animal: Animal): void {
if (isCat(animal)) {
animal.meow(); // animal: Cat
} else {
animal.bark(); // animal: Dog
}
}
// Type guard for unknown API responses
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) && value.every((item) => typeof item === "string")
);
}
const raw: unknown = JSON.parse('["a", "b", "c"]');
if (isStringArray(raw)) {
console.log(raw.join(", ")); // raw: string[]
}An assertion function (asserts arg is Type) throws if the assertion fails and narrows the type for all code that follows. The exhaustive never check turns a switch on a discriminated union into a compile-time registry - adding a new variant without handling it is a type error.
function assertDefined<T>(value: T | null | undefined, msg: string): asserts value is T {
if (value == null) throw new Error(msg);
}
let maybeUser: { name: string } | null = null;
// assertDefined(maybeUser, "user required"); // throws at runtime if null
// After the call, maybeUser is narrowed to { name: string }
// Exhaustive switch - adding a new variant without a case is a type error
type Action =
| { type: "INCREMENT"; by: number }
| { type: "RESET" }
| { type: "SET"; value: number };
function reduce(count: number, action: Action): number {
switch (action.type) {
case "INCREMENT": return count + action.by;
case "RESET": return 0;
case "SET": return action.value;
default: {
// If Action gains a new variant and this case is missing, _: never is a type error
const _: never = action;
throw new Error(`Unhandled action: ${JSON.stringify(_)}`);
}
}
}In production
A discriminated union + switch (action.type) with default: const _: never = action is a compile-time registry - adding a new variant without handling it is a type error everywhere the switch appears. This pattern replaces the Visitor pattern in most TypeScript codebases: it's simpler, serializes to JSON without adapters, and the compiler enforces exhaustiveness. Assertion functions (asserts value is T) are the right tool for validating data at trust boundaries (API responses, config loading) - they narrow the type for all downstream code without needing an if at every call site. Prefer unknown over any for unvalidated input: unknown forces you to narrow before use; any bypasses the type checker silently.
Enjoyed this? Get more essays on software craft delivered to your inbox.
Subscribe free