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.
| 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 |

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[].
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));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.
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 }));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.