Last updated: Apr 29, 2026
Control Flow Analysis
TypeScript tracks the type of every variable through branches, assignments, and guards — narrowing broad types like string | number down to something specific. This is control flow analysis (CFA).
typeof Guards
function double(value: string | number) {
if (typeof value === "string") {
return value.repeat(2); // string
}
return value * 2; // number
}
// works with: "string" | "number" | "boolean" | "symbol" | "bigint" | "undefined" | "object" | "function"
Truthiness Narrowing
Filters out null, undefined, 0, "", NaN, and false.
function greet(name?: string) {
if (name) {
console.log(name.toUpperCase()); // string (not undefined)
}
}
// double-bang for boolean coercion without narrowing the variable
function hasItems<T>(arr: T[] | null): arr is T[] {
return !!arr && arr.length > 0;
}
Equality Narrowing
===, !==, ==, and != narrow both sides of the comparison. != null is especially useful — it rules out both null and undefined in one check.
function handle(a: string | number, b: string | boolean) {
if (a === b) {
a.toUpperCase(); // string — only overlap
b.toUpperCase(); // string
}
}
function normalize(value: string | null | undefined) {
if (value != null) {
return value.trim(); // string
}
}
The in Operator
Narrows based on whether a property exists on an object.
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim(); // Fish
} else {
animal.fly(); // Bird
}
}
instanceof Narrowing
Works with any constructor function — useful for class hierarchies and built-in objects.
function formatError(err: Error | string) {
if (err instanceof RangeError) {
return err.message; // RangeError
}
if (err instanceof Error) {
return err.stack; // Error
}
return err; // string
}
Type Predicates (is)
User-defined type guards return paramName is Type to tell the compiler what a truthy return means.
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function isCat(pet: Cat | Dog): pet is Cat {
return "meow" in pet;
}
function interact(pet: Cat | Dog) {
if (isCat(pet)) {
pet.meow(); // Cat
} else {
pet.bark(); // Dog
}
}
// also useful for filtering
const pets: (Cat | Dog)[] = [
/* ... */
];
const cats: Cat[] = pets.filter(isCat);
Discriminated Unions
A shared literal field (the discriminant) lets TypeScript narrow the entire union in switch or if blocks.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2; // circle
case "rect":
return s.width * s.height; // rect
case "triangle":
return (s.base * s.height) / 2; // triangle
}
}
Assignment Narrowing
TypeScript narrows declared types after assignment. Subsequent assignments widen back to the declared type.
let value: string | number;
value = "hello";
value.toUpperCase(); // string
value = 42;
value.toFixed(2); // number
satisfies Operator
Validates that an expression matches a type without widening the inferred type. With a type annotation (const x: T), TypeScript widens to the declared type and you lose literal information. With satisfies, you get compile-time validation and keep the narrower type.
type Palette = Record<string, string | string[]>;
// annotation — widens values to string | string[]
const colorsAnnotated: Palette = {
primary: "#0ff",
secondary: ["#0f0", "#f00"],
};
colorsAnnotated.primary.toUpperCase(); // error: property doesn't exist on string | string[]
// satisfies — validates against Palette but keeps literal types
const colors = {
primary: "#0ff",
secondary: ["#0f0", "#f00"],
} satisfies Palette;
colors.primary.toUpperCase(); // ok — TS knows it's string
colors.secondary.join(", "); // ok — TS knows it's string[]
Exhaustiveness Checking
Assign the narrowed variable to never in a default branch — if a union member is unhandled, the compiler errors.
type Status = "active" | "inactive" | "pending";
function label(s: Status): string {
switch (s) {
case "active":
return "Active";
case "inactive":
return "Inactive";
case "pending":
return "Pending";
default: {
const _exhaustive: never = s; // compile error if a case is missing
return _exhaustive;
}
}
}
Narrowing Gotchas
Closures don’t preserve narrowing. TypeScript resets narrowed types inside callbacks because the callback may run after the variable is reassigned.
let value: string | number = "hello";
// TS can't guarantee `value` is still a string when the callback runs
setTimeout(() => {
// value is string | number here, not string
console.log(value);
}, 0);
// fix: capture in a const
const captured = value;
setTimeout(() => {
captured.toUpperCase(); // string — const can't be reassigned
}, 0);
typeof null returns "object". A typeof x === "object" check does not rule out null.
function stringify(value: string | object | null) {
if (typeof value === "object") {
// value is object | null — not just object
value?.toString();
}
}