Skip to content
Codeloom
TypeScript

TypeScript Assertion Functions for Runtime Validation

Use assertion functions to narrow types at runtime, replacing manual throw-and-cast patterns with compiler-understood safety checks.

·6 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • How assertion functions differ from type guards
  • Writing asserts condition and asserts value is Type signatures
  • Practical patterns for validating API responses and config
  • Combining assertion functions with discriminated unions

Prerequisites

None — this post is self-contained.

TypeScript has type guards that return a boolean to narrow types in conditional branches. Assertion functions take a different approach: they throw if a condition is not met, and if they return at all, the compiler knows the assertion holds for the rest of the scope. This matches how runtime validation actually works in most codebases, where invalid data should crash immediately rather than flow through an else branch.

The asserts Keyword

An assertion function uses the asserts keyword in its return type. There are two forms.

The first asserts that a condition is truthy:

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

After calling assert(x !== null, 'x is null'), TypeScript knows x is not null for the rest of the block.

The second form asserts that a value is a specific type:

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}

After calling assertIsString(input), the compiler treats input as string.

Assertion Functions vs Type Guards

A type guard returns boolean and narrows inside an if block:

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

function process(input: unknown) {
  if (isString(input)) {
    // input is string here
    console.log(input.toUpperCase());
  }
  // input is still unknown here
}

An assertion function throws or narrows for the entire remaining scope:

function process(input: unknown) {
  assertIsString(input);
  // input is string from here onward
  console.log(input.toUpperCase());
}

Use type guards when both branches are valid. Use assertion functions when the invalid case should halt execution.

Asserting Non-Null Values

The most common assertion function in practice validates that a value is neither null nor undefined:

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

This replaces the awkward pattern of checking and casting:

// Before: manual check with no narrowing benefit
const el = document.getElementById('app');
if (!el) throw new Error('Missing #app');
// el is still HTMLElement | null without assertion function

// After: assertion function narrows automatically
const el = document.getElementById('app');
assertDefined(el, '#app element');
// el is HTMLElement from here
el.classList.add('loaded');

Validating API Responses

Assertion functions are powerful for validating data from external sources like API responses:

interface User {
  id: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
}

function assertIsUser(data: unknown): asserts data is User {
  if (typeof data !== 'object' || data === null) {
    throw new Error('Expected an object');
  }

  const obj = data as Record<string, unknown>;

  if (typeof obj.id !== 'string') {
    throw new Error('Expected id to be a string');
  }
  if (typeof obj.email !== 'string') {
    throw new Error('Expected email to be a string');
  }
  if (!['admin', 'editor', 'viewer'].includes(obj.role as string)) {
    throw new Error(`Invalid role: ${obj.role}`);
  }
}

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json();
  assertIsUser(data);
  return data; // data is User
}

The assertion function acts as a validation boundary. Everything after the call benefits from full type safety.

Asserting Discriminated Unions

When working with discriminated unions, assertion functions can narrow to a specific variant:

type Result =
  | { status: 'success'; data: string }
  | { status: 'error'; message: string }
  | { status: 'loading' };

function assertSuccess(
  result: Result
): asserts result is { status: 'success'; data: string } {
  if (result.status !== 'success') {
    throw new Error(`Expected success, got ${result.status}`);
  }
}

function processResult(result: Result) {
  assertSuccess(result);
  // result.data is available, result.message is not
  console.log(result.data.toUpperCase());
}

Chaining Multiple Assertions

Assertion functions compose naturally because each one narrows the type for subsequent code:

interface Config {
  port?: number;
  host?: string;
  database?: {
    url?: string;
    poolSize?: number;
  };
}

function assertValidConfig(
  config: Config
): asserts config is Required<Config> & {
  database: Required<Config['database']>;
} {
  assertDefined(config.port, 'config.port');
  assertDefined(config.host, 'config.host');
  assertDefined(config.database, 'config.database');
  assertDefined(config.database.url, 'config.database.url');
  assertDefined(config.database.poolSize, 'config.database.poolSize');
}

function startServer(config: Config) {
  assertValidConfig(config);
  // All properties are guaranteed to exist
  console.log(`Starting on ${config.host}:${config.port}`);
  console.log(`DB pool size: ${config.database.poolSize}`);
}

Generic Assertion Functions

You can write generic assertion functions that work with any type predicate:

function assertInstanceOf<T>(
  value: unknown,
  constructor: new (...args: any[]) => T,
  message?: string
): asserts value is T {
  if (!(value instanceof constructor)) {
    throw new Error(
      message ?? `Expected instance of ${constructor.name}`
    );
  }
}

function handleError(err: unknown) {
  assertInstanceOf(err, Error);
  // err is Error
  console.error(err.message);
  console.error(err.stack);
}

A generic array assertion:

function assertNonEmpty<T>(
  arr: T[],
  name: string
): asserts arr is [T, ...T[]] {
  if (arr.length === 0) {
    throw new Error(`Expected ${name} to be non-empty`);
  }
}

function getFirst(items: string[]) {
  assertNonEmpty(items, 'items');
  // items is [string, ...string[]]
  return items[0]; // string, not string | undefined
}

Rules and Limitations

Assertion functions must be declared with explicit function declarations or function expressions. Arrow functions do not support the asserts return type:

// This works
function assertTruthy(val: unknown): asserts val {
  if (!val) throw new Error('Falsy');
}

// This does NOT work
const assertTruthy = (val: unknown): asserts val => {
  if (!val) throw new Error('Falsy');
};

Assertion functions must either throw or return void. They cannot return a value. The compiler treats a normal return as proof that the assertion holds.

The function must be called directly. Passing an assertion function as a callback or storing it in a variable before calling it may prevent narrowing from working correctly.

When to Use Assertion Functions

Assertion functions are the right tool at boundaries where invalid data should halt execution: parsing API responses, loading configuration, processing CLI arguments, and validating environment variables at startup. They replace the pattern of checking a condition, throwing, and then relying on the compiler to understand that code after the throw is unreachable.

For situations where both outcomes are valid and you need to branch on the result, stick with type guards. Assertion functions are for the “this must be true or we cannot continue” case.

Wrap Up

Assertion functions give TypeScript a way to express runtime validation as type narrowing. The asserts return type tells the compiler that if the function returns normally, a condition holds for the rest of the scope. They eliminate the gap between runtime checks and compile-time types, making validation code both safe and concise.