Skip to content
Skip to content
DocsTypeScript ExpertexpertAdvanced Type Guards
Chapter 7 of 10·expert·9 min read

Advanced Type Guards

Type Guard Nâng Cao

Narrow union types precisely with user-defined type predicates

Hover or tap any paragraph to see Vietnamese translation

What Are Type Guards?

TypeScript narrows types through control-flow analysis: whenever you test a value, the compiler updates its type within each branch. A type guard is any expression that both performs a runtime check and narrows the type at compile time.

narrowing-intro.ts
1type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };23function area(shape: Shape): number {4  // TypeScript knows shape is the circle variant here…5  if (shape.kind === "circle") {6    return Math.PI * shape.radius ** 2;7  }8  // …and the square variant here9  return shape.side ** 2;10}

typeof and instanceof (Quick Recap)

typeof and instanceof are built-in type guards. typeof works well with primitives; instanceof is useful for classes but cannot guard interface types because interfaces have no runtime representation.

typeof-instanceof.ts
1// typeof — works for "string" | "number" | "boolean" | "bigint" | "symbol" | "undefined" | "function" | "object"2function pad(value: string | number, width: number): string {3  if (typeof value === "string") {4    return value.padStart(width);  // value: string5  }6  return String(value).padStart(width);  // value: number7}89// instanceof — narrows to a class type10class NetworkError extends Error {11  constructor(public statusCode: number, message: string) {12    super(message);

User-Defined Type Predicates

When typeof and instanceof fall short, write your own guard function with a return type of val is T. This is a type predicate — it tells TypeScript that if the function returns true, the parameter is of type T.

type-predicates.ts
1interface Cat { meow(): void }2interface Dog { bark(): void }34// Without a predicate — TypeScript doesn't narrow the union5function isCatPlain(pet: Cat | Dog): boolean {6  return "meow" in pet;7}89// With a predicate — TypeScript narrows correctly10function isCat(pet: Cat | Dog): pet is Cat {11  return "meow" in pet;12}
Info
Correctness responsibility is yours — TypeScript unconditionally trusts a type predicate. A wrong guard silently produces runtime errors the compiler cannot catch. Test every guard written against unknown thoroughly.

Assertion Functions

An assertion function is a type guard variant that does not return a boolean — it throws if the condition is false. The return type is asserts val is T. After the call, TypeScript treats the value as narrowed for the rest of the enclosing scope.

assertion-functions.ts
1// Assertion function — throws on failure, narrows on success2function assertIsString(val: unknown): asserts val is string {3  if (typeof val !== "string") {4    throw new TypeError(`Expected string, got ${typeof val}`);5  }6}78// asserts condition — truthy assertion without a type9function assert(condition: unknown, msg?: string): asserts condition {10  if (!condition) throw new Error(msg ?? "Assertion failed");11}12

Discriminated Unions as Structural Guards

A discriminated union — also called a tagged union — is the most powerful narrowing pattern. Each variant carries a shared literal-type field; TypeScript uses that field to narrow the union without any external guard function.

discriminated-unions.ts
1type Result<T, E = Error> =2  | { ok: true;  value: T }3  | { ok: false; error: E };45function divide(a: number, b: number): Result<number, string> {6  if (b === 0) return { ok: false, error: "Division by zero" };7  return { ok: true, value: a / b };8}910const result = divide(10, 2);1112if (result.ok) {

The in Operator as a Guard

The in operator checks property existence at runtime and simultaneously narrows the union at compile time. It is especially useful when interfaces have no shared discriminant field.

in-operator-guard.ts
1interface Fish  { swim(): void }2interface Bird  { fly(): void }3interface Amphibian { swim(): void; breatheAir(): void }45type Animal = Fish | Bird | Amphibian;67function move(animal: Animal): void {8  if ("fly" in animal) {9    animal.fly();     // animal: Bird10  } else if ("breatheAir" in animal) {11    animal.breatheAir();  // animal: Amphibian12    animal.swim();

Control Flow Analysis

TypeScript tracks the type of each variable across every branch of control flow — reassignments, early returns, throws, and guards are all analysed. The type at any given point is the union of all possible types from every code path leading there.

control-flow-analysis.ts
1function processValue(value: string | number | null | undefined): string {2  // After these guards value: string | number (null and undefined eliminated)3  if (value == null) throw new Error("value is required");45  // value: string | number6  if (typeof value === "number") {7    return value.toFixed(2);  // value: number8  }910  // value: string (number branch returned above)11  return value.trim();12}
Info
TypeScript CFA is based on lattice theory and dominance analysis. It works well for normal patterns but can fail with aliased functions — the compiler does not re-narrow after calling an arbitrary function because that function might mutate the value.

Exhaustiveness Checking with never

When you narrow a union until all variants are handled, TypeScript infers the remaining type as never. You can leverage this for compile-time exhaustiveness checks — add a new union variant and forget to handle it, and TypeScript reports an error immediately.

exhaustiveness-checking.ts
1type Shape =2  | { kind: "circle";    radius: number }3  | { kind: "rectangle"; width: number; height: number }4  | { kind: "triangle";  base: number; height: number };56// Pattern 1: throw — catches unhandled cases at runtime7function area(shape: Shape): number {8  switch (shape.kind) {9    case "circle":10      return Math.PI * shape.radius ** 2;11    case "rectangle":12      return shape.width * shape.height;

Summary

  • typeof and instanceof are built-in guards for primitives and classes
  • Type predicates (val is T) enable custom guards; the compiler trusts them unconditionally — test them carefully
  • Assertion functions (asserts val is T) throw instead of returning false and narrow for the rest of the scope
  • Discriminated unions with a literal-type tag are the most powerful and maintainable narrowing pattern
  • The in operator narrows by property existence — no shared discriminant field needed
  • Control flow analysis tracks types across branches, reassignments, and early returns
  • Exhaustiveness checking with never gives compile-time assurance that every union variant is handled
  • Built: 6/25/2026, 3:03:36 PM