Assertion Functions in TypeScript: Custom Type Checks with asserts
AI generated
type
TypeScript · Type Narrowing
Assertion Functions: Custom Type Checks with asserts
How asserts tells the compiler a check has already happened

Assertion functions let you declare runtime checks so the TypeScript compiler folds them into its control flow analysis. This article covers the asserts syntax, how it differs from classic type guards using is, and when writing your own assertion functions actually pays off in practice.

10 min read TypeScript 3.7+ Type Narrowing Control Flow Analysis

1. What assertion functions are

An assertion function is a function that either returns normally or throws, with a return type annotated using the asserts keyword. If the function returns without throwing, the compiler assumes the condition described in the asserts clause holds true from that point onward. That is the key difference from a plain function returning boolean: an assertion function permanently narrows the known type of a variable for the rest of the enclosing scope, not just inside an if block.

Since TypeScript 3.7 there are two forms: asserts value is Type for narrowing an existing variable, and the simpler asserts condition form, which only states that a condition must be true without narrowing any specific variable. Both forms are used exclusively in the return type position of a function declaration and do not work as a general expression type elsewhere.

2. Basic syntax and a simple example

The simplest form of an assertion function checks that a condition holds and throws otherwise. It suits preconditions that repeat across several call sites, where writing out an if block plus throw every time would be tedious.

The compiler only recognizes a function as an assertion function if the signature begins exactly with asserts. A function that throws internally but is declared with a plain void return type gives the control flow analyzer nothing to work with.


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

function processOrder(order: { total: number | null }) {
  assert(order.total !== null, "Order total is missing");
  // From here on the compiler knows order.total is number, not number | null
  console.log(order.total.toFixed(2));
}

3. asserts value is Type: targeted narrowing

The second form combines asserts with is and explicitly names which type a specific variable is narrowed to. This is especially useful when a check is more than a single condition, such as a multi-field validation that as a whole confirms a particular object shape.

In the following example an unknown object from an API response gets validated. After calling assertIsUser, the compiler treats the variable as User for the rest of the function, without requiring a re-check at every usage site.


interface User {
  id: number;
  email: string;
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as User).id === "number" &&
    typeof (value as User).email === "string"
  );
}

function assertIsUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new TypeError("Response does not match the User schema");
  }
}

function handleApiResponse(payload: unknown) {
  assertIsUser(payload);
  console.log(payload.email.toLowerCase()); // payload is User here
}

4. Difference from classic type guards

A classic type guard returning value is Type yields a boolean and is typically evaluated inside a condition. The narrowing then applies only within the corresponding if branch, because the compiler treats the two control flow paths, true and false, separately.

An assertion function instead narrows the remaining, linear code that follows, without requiring a branch. That is useful when a precondition should be checked early in a function and the rest of the function body should not be nested inside a deep if. In practice the two approaches complement each other: type guards for branching logic, assertion functions for preconditions and guard clauses.


// Type guard: narrowing only inside the if branch
function isString(value: unknown): value is string {
  return typeof value === "string";
}

function withGuard(value: unknown) {
  if (isString(value)) {
    console.log(value.toUpperCase()); // narrowed only here
  }
  // value is unknown again here
}

// Assertion function: narrowing for the rest of the function
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new TypeError("Expected a string");
  }
}

function withAssertion(value: unknown) {
  assertIsString(value);
  console.log(value.toUpperCase()); // narrowed for the rest of the function
}

5. Practical case: non-null checks without the non-null assertion operator

A common use case is replacing the non-null assertion operator !. That operator only suppresses the compiler error without actually checking at runtime whether a value is truly not null or undefined. A small assertion function such as assertDefined performs the same job, but actually checks at runtime and throws a descriptive error when needed.

This approach is particularly useful in DOM-heavy code or when reading optional configuration values, where a silent undefined access can lead to hard-to-trace downstream bugs.


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

function readConfigValue(config: Record<string, string | undefined>) {
  const apiKey = config.apiKey;
  assertDefined(apiKey, "config.apiKey");
  return apiKey.trim(); // apiKey is string here, not string | undefined
}

6. Generic assertion functions for reusable validation

Assertion functions can be combined with generics to build reusable validation building blocks that adapt to different types. A generic assertion function for array elements is a good example: it checks that every element of a list matches a given predicate and narrows the element type of the entire array.

Note that the generic type parameter must be expressed explicitly via the return type as asserts value is T[]. Without that annotation, TypeScript would not correctly use the generic parameter for control flow analysis.


function assertAllMatch<T>(
  values: unknown[],
  predicate: (v: unknown) => v is T,
  label: string
): asserts values is T[] {
  const invalidIndex = values.findIndex((v) => !predicate(v));
  if (invalidIndex !== -1) {
    throw new TypeError(`${label}: invalid element at index ${invalidIndex}`);
  }
}

function isNumber(value: unknown): value is number {
  return typeof value === "number";
}

function sumScores(raw: unknown[]) {
  assertAllMatch(raw, isNumber, "scores");
  return raw.reduce((sum, n) => sum + n, 0); // raw is number[] here
}

7. Pitfalls and limitations

Assertion functions cannot be used as class methods declared via arrow function fields when relying on implicit this binding for the signature, and they do not work as the return value of a higher-order function expression: TypeScript only recognizes asserts on a direct function declaration or a clearly named function expression, not on anonymous callbacks passed as parameters.

A second pitfall involves strict mode settings: without strictNullChecks, many assertion functions targeting null or undefined lose much of their value, because the compiler already permits null everywhere. A third, often overlooked point: the compiler fully trusts the assertion function. If the implementation itself has a logic bug, such as an inverted condition, incorrect type assumptions propagate that can cause real runtime failures without any compiler warning.


class Validator {
  // Works: regular method
  assertPositive(value: number): asserts value {
    if (value <= 0) throw new Error("Value must be positive");
  }
}

// Does NOT work reliably: assertion function passed as a callback parameter
function runCheck(fn: (v: unknown) => asserts v is string) {
  // TypeScript does not treat fn as an assertion signature here
}

8. Best practices for team use

Assertion functions should carry descriptive names prefixed with assert and throw a precise error message on failure that names the context, such as the field name or the calling function. That greatly eases debugging compared to a generic message like invalid value.

It also helps to concentrate assertion functions at module boundaries, such as right where external data from an API or configuration file is read. Inside the application, where types are already guaranteed by the type checker, they are usually superfluous and only add unnecessary runtime cost. For unit tests, it is worth explicitly testing that the respective exception is thrown, so refactors don't silently weaken the precondition.

9. Assertion functions compared to related mechanisms

Besides assertion functions, TypeScript offers other tools for type narrowing that fit better or worse depending on the situation. The table below compares the main mechanisms and shows when each approach is the better choice.

As a rule of thumb: the more critical the runtime consequences of a wrong type are, such as for payment data or authentication, the more a real, checking assertion function pays off over a silent type claim.

Mechanism Runtime check Scope of effect Typical use
asserts condition Yes Rest of the function Preconditions, guard clauses
asserts value is T Yes Rest of the function Validating external data
value is T (type guard) Yes Only inside the if branch Branching logic
value as T No From that point, unchecked Known but unproven types
value! No From that point, unchecked Short-lived, risky assumption

Mironsoft

TypeScript migration, type safety, and team onboarding

A JavaScript codebase without type safety, but no time for a full migration?

We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.

Migration Roadmap

Plan and execute a gradual JS-to-TS migration without big-bang risk.

Strict Mode Rollout

Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.

Team Onboarding

Bring developers up to speed on TypeScript best practices with workshops and reviews.

10. Summary

Assertion Functions

Available since

TypeScript 3.7

Keyword

asserts

Behavior on failure

Must throw an exception

Scope of effect

Rest of the code path, not just the if branch

11. FAQ: Assertion Functions

1What happens if an assertion function does not throw even though the condition was false?
TypeScript fully trusts the signature. If the implementation is buggy and fails to throw despite a false condition, the rest of the code ends up with an incorrect type assumption that can cause runtime errors without any compiler warning.
2Can an assertion function also have a return value?
No. The return type must consist entirely of asserts condition or asserts value is Type. An additional value cannot be returned at the same time, the function signals purely through returning normally or throwing.
3Do assertion functions work with async/await?
No, an async function always returns a Promise that only resolves at runtime. TypeScript's control flow analysis operates synchronously with the source code, which is why asserts is only supported on synchronous functions.
4How is asserts condition different from a plain if with throw written inline?
Functionally identical, but asserts condition can be encapsulated in its own function and reused. The main benefit lies in reusability across multiple call sites, not in fundamentally different runtime behavior.
5Are assertion functions faster than classic type guards?
No, both perform a comparable runtime check. The difference lies purely in the static type analysis performed by the compiler, not in the actual execution speed at runtime.
6Can I use asserts in an arrow function?
Yes, as long as the arrow function is assigned to a named variable with an explicit type. Anonymous arrow functions passed directly as parameters are currently not reliably recognized by TypeScript as assertion functions.
7What is the difference between assert and the Node.js assert library?
The Node.js assert library behaves similarly at runtime but has no built-in TypeScript assertion signature by default. Only with a custom type declaration using asserts condition can the compiler recognize it as an assertion function.
8Does an assertion function always have to throw an Error instance?
Technically any throw statement works regardless of the thrown value. In practice, for proper error handling and stack traces, a real Error instance or a subclass of it should always be thrown.
9Do many assertion functions noticeably slow an application down?
For checks placed sensibly, such as at system boundaries, the overhead is negligible. If used inside hot loops with very many iterations, however, the repeated checking can become measurable and should be profiled specifically.
10Can I simply convert an existing type guard function into an assertion function?
Yes, usually it is enough to invert the condition and throw an exception instead of returning a boolean, while changing the signature from value is Type to asserts value is Type. Call sites that expect the boolean return value inside an if condition need to be adjusted accordingly.