Assertion Functions

Difficulty: Advanced

Assertion functions are a specialized form of type narrowing in TypeScript. Unlike type guards that return a boolean, assertion functions throw an error if a condition is not met. They use the `asserts` keyword in their return type annotation, telling TypeScript that if the function returns normally (without throwing), the asserted condition is true.

There are two forms of assertion functions. The first is `asserts condition`, where condition is a boolean expression. If the function returns, TypeScript knows the condition was true. The second is `asserts param is Type`, which narrows a specific parameter to a specific type. Both forms throw an error when the assertion fails, acting as runtime validators with compile-time type narrowing.

Assertion functions are ideal for fail-fast validation at the boundaries of your application. Instead of returning a boolean and making the caller handle both cases, an assertion function eliminates the failure case entirely - after the assertion, the rest of the code can proceed with the narrowed type. This reduces nesting and makes code more linear and readable.

The `asserts` return type annotation is a contract with TypeScript: the function promises that if it returns normally, the asserted condition holds. If you write an assertion function that does not actually throw on invalid input, TypeScript will still trust the assertion, leading to potential runtime errors. Always ensure your assertion functions actually validate and throw as promised.

Code examples

Basic Assertion Function

function assertDefined<T>(value: T | null | undefined, name: string): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error(`${name} must be defined`);
  }
}

function processUser(name: string | null, age: number | undefined): string {
  assertDefined(name, "name");
  assertDefined(age, "age");
  // After assertions, name is string and age is number
  return `${name.toUpperCase()} is ${age} years old`;
}

console.log(processUser("Alice", 30));

try {
  processUser(null, 25);
} catch (e: any) {
  console.log(e.message);
}

After assertDefined returns, TypeScript narrows the value from `T | null | undefined` to just `T`. If the assertion fails, an error is thrown.

Assert Condition Form

function assert(condition: boolean, message: string): asserts condition {
  if (!condition) {
    throw new Error(message);
  }
}

function divide(a: number, b: number): number {
  assert(b !== 0, "Cannot divide by zero");
  return a / b;
}

console.log(divide(10, 3).toFixed(2));

try {
  divide(10, 0);
} catch (e: any) {
  console.log(e.message);
}

The assert function with `asserts condition` narrows based on a boolean condition. After it returns, TypeScript knows the condition was true.

Assertion for Type Validation

interface ApiData {
  id: number;
  name: string;
}

function assertIsApiData(data: unknown): asserts data is ApiData {
  if (typeof data !== "object" || data === null) {
    throw new Error("Expected an object");
  }
  if (!("id" in data) || typeof (data as any).id !== "number") {
    throw new Error("Expected numeric id");
  }
  if (!("name" in data) || typeof (data as any).name !== "string") {
    throw new Error("Expected string name");
  }
}

function processApiResponse(raw: unknown): string {
  assertIsApiData(raw);
  // raw is now ApiData
  return `#${raw.id}: ${raw.name}`;
}

console.log(processApiResponse({ id: 1, name: "Widget" }));

try {
  processApiResponse({ id: "bad" });
} catch (e: any) {
  console.log(e.message);
}

The assertion function validates unknown data and throws if invalid. After it returns, TypeScript treats the data as ApiData with full type safety.

Key points

Concepts covered

asserts keyword, assertion functions, assert condition, asserts param is Type