Narrowing

A union type says what a value might be; before you call toFixed or trim you must prove which member you have. TypeScript follows every if, return, switch, && and assignment in a function (control flow analysis) and gives a variable a narrower type in each branch that ordinary JavaScript checks make safe. Nothing is added at runtime: the checks are the ones Conditional Branching already describes, and the compiler reads them.

Narrowing guards
Guard Example In the true branch
typeof typeof x === "string" string (also number, bigint, boolean, symbol, undefined, object, function)
Truthiness if (x) Removes null, undefined and falsy literals
Equality x == null, x === "a" null | undefined, or the literal
in "swim" in pet Members that have (or may have) swim
instanceof x instanceof Date Date
Type predicate (x: unknown): x is Cat Whatever the predicate names
Control flow analysis narrows value at each check in describe()
Control flow analysis narrows value at each check in describe()
Guards in practice

Each return removes a member from the rest of the function, so by the last line of describe only number is left. A type predicate such as x is string lets your own function act as a guard; since TypeScript 5.5 the compiler also infers one for simple arrow functions, so filter((n) => n !== undefined) returns number[].

typeof, ==, instanceof, a type predicate and a truthiness trapJavaScript
function describe(value: string | number | Date | null | undefined): string {
  if (value == null) return "nothing";                  // null | undefined
  if (typeof value === "string") return `text "${value.trim()}"`;
  if (value instanceof Date) return `year ${value.getFullYear()}`;
  return `number ${value.toFixed(1)}`;                  // only number is left
}
const isText = (x: unknown): x is string => typeof x === "string";
const mixed: unknown[] = ["a", 1, "b", null];
const texts = mixed.filter(isText);                     // string[]
const counts = [3, undefined, 0].filter((n) => n !== undefined);  // number[]
const label = (width?: number) => (width ? `${width}px` : "auto");  // 0 is falsy!
console.log(describe(null), describe(" hi "), describe(new Date(2026, 8)), describe(Math.PI));
console.log(texts, counts);
console.log(label(120), label(undefined), label(0));
Output
nothing text "hi" year 2026 number 3.1
[ 'a', 'b' ] [ 3, 0 ]
120px auto auto

The last line shows the classic truthiness bug: label(0) prints auto because 0 is falsy. The narrowing is sound, so the checker accepts it; test width === undefined instead. Likewise typeof x === "object" leaves null in the type, because typeof null is "object" (Types).

Discriminated unions and exhaustiveness

The most useful pattern models state as a union of object types sharing a literal discriminant property. Checking it narrows the whole object, so each branch sees only the fields of that state: you cannot read data while loading. Reducers, API results and UI state machines are typed this way.

A discriminated union with an exhaustive switchCSS
type FetchState =
  | { status: "loading"; startedAt: number }
  | { status: "success"; data: string[] }
  | { status: "error"; error: Error; retries: number };
function render(state: FetchState): string {
  switch (state.status) {                 // status is the discriminant
    case "loading":
      return `Loading since ${state.startedAt}`;
    case "success":
      return state.data.join(", ");       // state: { status: "success"; data: string[] }
    case "error":
      return `${state.error.message} (retry ${state.retries})`;
    default:
      return assertNever(state);          // state: never
  }
}
function assertNever(value: never): never {
  throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
console.log(render({ status: "success", data: ["ts", "js"] }));
console.log(render({ status: "error", error: new Error("Timeout"), retries: 2 }));
Output
ts, js
Timeout (retry 2)

After three cases nothing is left, so state has type never, and never accepts no value but itself. Add | { status: "cancelled" } to the union and forget the case, and the build fails at the default line with error TS2345: Argument of type '{ status: "cancelled"; }' is not assignable to parameter of type 'never'. The assertNever call also throws if untyped data slips through at runtime.