intermediate Step 8 of 18

Type Guards and Narrowing

TypeScript Programming

Type Guards and Narrowing

Type narrowing is the process by which TypeScript refines a broad type to a more specific one within a conditional block. When you check whether a value is a string using typeof, TypeScript understands that inside the if block the value is definitely a string, not a number or object. This mechanism is called control flow analysis, and it is one of the most sophisticated features of the TypeScript compiler. Type guards are the expressions and functions that trigger this narrowing, enabling you to write safe, precise code when working with union types and unknown values.

typeof Guards

The typeof operator works as a type guard for primitive types. TypeScript recognizes typeof checks and automatically narrows the variable's type inside the conditional block.

function padLeft(value: string | number, padding: string | number): string {
  // TypeScript narrows 'padding' to 'number' here
  if (typeof padding === "number") {
    return " ".repeat(padding) + value;
  }
  // TypeScript narrows 'padding' to 'string' here
  return padding + value;
}

console.log(padLeft("Hello", 4));     // "    Hello"
console.log(padLeft("Hello", ">> ")); // ">> Hello"

// typeof works for: "string", "number", "boolean",
// "undefined", "object", "function", "bigint", "symbol"

instanceof Guards

The instanceof operator narrows types to specific classes. This is useful when working with class hierarchies or built-in objects like Date, Error, or RegExp.

class ApiError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
  }
}

class ValidationError extends Error {
  constructor(public field: string, message: string) {
    super(message);
  }
}

function handleError(error: Error): string {
  if (error instanceof ApiError) {
    // Narrowed to ApiError — statusCode is accessible
    return `API Error ${error.statusCode}: ${error.message}`;
  }
  if (error instanceof ValidationError) {
    // Narrowed to ValidationError — field is accessible
    return `Validation Error on "${error.field}": ${error.message}`;
  }
  return `Unknown Error: ${error.message}`;
}

console.log(handleError(new ApiError(404, "Not Found")));
console.log(handleError(new ValidationError("email", "Invalid format")));

The in Operator

The in operator checks whether a property exists on an object, and TypeScript uses this to narrow discriminated unions and object types.

interface Bird {
  fly(): void;
  layEggs(): void;
}

interface Fish {
  swim(): void;
  layEggs(): void;
}

function move(animal: Bird | Fish): void {
  if ("fly" in animal) {
    // Narrowed to Bird
    animal.fly();
  } else {
    // Narrowed to Fish
    animal.swim();
  }
}

Custom Type Guard Functions

For complex narrowing logic, you can write custom type guard functions that return a type predicate. The return type value is Type tells TypeScript to narrow the type when the function returns true.

interface Car {
  type: "car";
  make: string;
  model: string;
  doors: number;
}

interface Truck {
  type: "truck";
  make: string;
  model: string;
  payload: number;
}

type Vehicle = Car | Truck;

// Custom type guard with type predicate
function isCar(vehicle: Vehicle): vehicle is Car {
  return vehicle.type === "car";
}

function describeVehicle(vehicle: Vehicle): string {
  if (isCar(vehicle)) {
    // Narrowed to Car — doors is accessible
    return `${vehicle.make} ${vehicle.model} (${vehicle.doors}-door)`;
  }
  // Narrowed to Truck — payload is accessible
  return `${vehicle.make} ${vehicle.model} (${vehicle.payload}kg payload)`;
}

// Assertion function — throws if condition is not met
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}

function processInput(input: unknown): string {
  assertIsString(input);
  // After assertion, input is narrowed to string
  return input.toUpperCase();
}

Exhaustive Checking with never

Use the never type to ensure all variants of a union are handled. If you add a new variant and forget to handle it, the compiler reports an error.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      // If all cases are handled, shape is 'never' here
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}
Tip: Always handle the default case in switch statements over discriminated unions with a never assignment. This gives you a compile-time error whenever you add a new variant without updating the switch.

Key Takeaways

  • typeof narrows primitive types; instanceof narrows class types.
  • The in operator narrows based on property existence in union types.
  • Custom type guard functions use the value is Type return type predicate.
  • Assertion functions (asserts value is Type) narrow types by throwing on failure.
  • Exhaustive checking with never ensures all union variants are handled.