Type Guards & Narrowing

Difficulty: Intermediate

Question

What are type guards? How does TypeScript narrow types based on runtime checks?

Answer

Type guards are runtime checks that TypeScript uses to narrow a type within a conditional block. When you check a condition, TypeScript automatically narrows the type in that branch.

Built-in narrowing: - typeof: for primitives (string, number, boolean) - instanceof: for class instances - in: for checking property existence - Equality checks: === null, !== undefined

Custom type guards: functions that return 'value is Type'

Code examples

Built-in Type Guards

function process(value: string | number | null) {
  // typeof narrowing
  if (typeof value === 'string') {
    console.log(value.toUpperCase()); // OK: string
  } else if (typeof value === 'number') {
    console.log(value.toFixed(2)); // OK: number
  } else {
    console.log(value); // OK: null
  }
}

// instanceof narrowing
function handleError(err: Error | string) {
  if (err instanceof Error) {
    console.log(err.message); // OK: Error
    console.log(err.stack);   // OK: Error
  } else {
    console.log(err.toUpperCase()); // OK: string
  }
}

// 'in' operator
type Dog = { bark(): void };
type Cat = { meow(): void };
function speak(pet: Dog | Cat) {
  if ('bark' in pet) {
    pet.bark(); // OK: Dog
  } else {
    pet.meow(); // OK: Cat
  }
}

TypeScript's control flow analysis automatically narrows types based on these checks. Each branch has a more specific type.

Custom Type Guard Functions

interface Fish { swim(): void; name: string }
interface Bird { fly(): void; name: string }

// Custom type guard with 'is' return type
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

function move(pet: Fish | Bird) {
  if (isFish(pet)) {
    pet.swim(); // OK: narrowed to Fish
  } else {
    pet.fly();  // OK: narrowed to Bird
  }
}

// Assertion function (throws if not valid)
function assertIsString(val: unknown): asserts val is string {
  if (typeof val !== 'string') {
    throw new Error(`Expected string, got ${typeof val}`);
  }
}

function greet(name: unknown) {
  assertIsString(name);
  console.log(name.toUpperCase()); // OK: narrowed to string
}

Custom type guards return 'value is Type'. Assertion functions use 'asserts value is Type' and throw if the assertion fails.

Exhaustive Check with never

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 (shape.base * shape.height) / 2;
    default:
      // Exhaustive check - if we add a new shape
      // and forget to handle it, this line errors
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Assigning to 'never' in the default case ensures all union members are handled. If you add a new shape variant, TypeScript will error here until you add a case for it.

Key points

Concepts covered

Type Guards, Narrowing, typeof, instanceof, in operator, Custom Guards