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();
  }
}