open atlas
↑ Back to track
TypeScript type system, deep TS · 06 · 03

Assertion functions

Assertion functions (`asserts x is T`, `asserts cond`) narrow the rest of the scope by throwing — how they differ from type predicates, why the return-type annotation is mandatory, and the unsoundness traps.

TS Senior ◷ 14 min
Level
FoundationsJuniorMiddleSenior

A service reads process.env.DATABASE_URL, which TypeScript types as string | undefined. Every consumer guards it with if (!url) throw ... or sprinkles url! non-null assertions. Both are noise. What you want is one assertDefined(url) call after which url is simply string for the rest of the function — no branch, no !. That is an assertion function, and it changes control-flow analysis in a way type predicates cannot.

Two signature shapes, both asserts

Before reaching for if (!x) throw or scattering ! across a file, ask yourself whether the false case is a bug or a legitimate variant. If it is a bug, an assertion function expresses that intent — and the compiler enforces it for the rest of the scope.

TypeScript 3.7 introduced assertion signatures: a void-returning function whose return type is asserts …. There are two forms.

// Form 1 — asserts a condition is true
function assert(condition: unknown, msg?: string): asserts condition {
  if (!condition) throw new Error(msg ?? "Assertion failed");
}

// Form 2 — asserts a value has a specific type
function assertIsString(x: unknown): asserts x is string {
  if (typeof x !== "string") throw new TypeError("not a string");
}

The contract is a promise to the compiler: if this function returns normally, the assertion held. The only way to “not return normally” is to throw. So after the call, the compiler can assume the narrowed fact for every subsequent statement in the scope — because if the fact were false, control would never have reached them.

function load(raw: unknown) {
  assertIsString(raw);
  raw;
  // ^? string   — narrowed for the rest of the scope, no branch needed
  return raw.toUpperCase();
}

Predicate vs assertion: branch-narrowing vs scope-narrowing

This is the senior distinction. A type predicate (x is T) returns a boolean and narrows only inside the branch where the boolean is truthy. An assertion function (asserts x is T) returns void and narrows the rest of the scope after the call, because the false case has already thrown.

// Predicate: narrows inside the if-branch only
function isStr(x: unknown): x is string {
  return typeof x === "string";
}
function a(x: unknown) {
  if (isStr(x)) {
    x; // ^? string  (only here)
  }
  x;   // ^? unknown (back to wide outside the branch)
}

// Assertion: narrows everything after the call
function b(x: unknown) {
  assertIsString(x);
  x; // ^? string  (for the whole remaining scope)
}

Choose a predicate when you want to handle both cases; choose an assertion when the false case is a bug you want to fail loudly on.

The annotation is mandatory — and unchecked

An assertion signature is never inferred. If you omit the explicit : asserts … return type, TypeScript types the function as returning void and the call narrows nothing — silently.

// Missing the asserts annotation:
function assertNum(x: unknown) {     // inferred return: void
  if (typeof x !== "number") throw new Error();
}
function c(x: unknown) {
  assertNum(x);
  x; // ^? unknown   — NOT narrowed; the assert effect was lost silently
}

There is no error here — just a missing narrowing, which is the dangerous kind of bug. Worse, the body is not checked against the claim. TypeScript trusts the signature; it does not verify that the function actually throws when the assertion is false.

function brokenAssert(x: unknown): asserts x is string {
  // forgot to throw! does nothing
}
function d(x: unknown) {
  brokenAssert(x);
  x.toUpperCase(); // x is `string` to the compiler, but at runtime x may be anything → crash
}

This is the central unsoundness: an assertion function is a load-bearing claim the type checker takes on faith. The body must genuinely throw on the false case, or you have lied to the compiler. Keep assertion functions tiny and obviously-throwing, and let validation libraries (Zod et al.) generate them so the throw can’t drift from the type.

Why this works

Why doesn’t TypeScript verify the body throws? Because proving “this function throws on all inputs where the predicate is false” is, in general, undecidable — it would require the compiler to evaluate arbitrary runtime logic. Predicates have the same gap: function isStr(x): x is string { return true; } compiles and lies just as freely. The type system delegates the truth of narrowing claims to you; it only checks their shape. That’s why assertion/predicate functions belong to the small, audited core of a codebase.

Assertions and definite assignment

Assertion functions also interact with definite-assignment analysis. A common pattern is asserting that a value is assigned before use, which lets the compiler treat a let as definitely-assigned afterward — but note this only works through the asserts mechanism, not by ordinary control flow, so the same “must actually throw” discipline applies. Used soundly, assertDefined(config) replaces a forest of ! non-null assertions with one auditable choke point.

Quiz

What is the core difference between a type predicate `x is T` and an assertion signature `asserts x is T`?

Order the steps

Order how control-flow analysis treats a value across an `asserts x is string` call.

  1. 1 Before the call, `x` has its wide declared type (e.g. unknown)
  2. 2 The assertion function runs; if its claim is false it throws and control leaves the scope
  3. 3 If the call returns normally, the false case did not happen
  4. 4 The compiler narrows `x` to `string` from the statement after the call onward
  5. 5 Every subsequent statement in the scope sees the narrowed type with no enclosing branch
Recall before you leave
  1. 01
    Explain exactly how an assertion function changes control-flow analysis, and how that differs from a type predicate.
  2. 02
    What are the two main unsoundness traps with assertion functions, and how do you mitigate them?
Recap

Assertion functions (TS 3.7) are void-returning functions with an asserts x is T or asserts condition signature. They narrow the rest of the scope after the call by throwing on the false case, which is the opposite shape from a type predicate: predicates return a boolean and narrow only inside the truthy branch, while assertions narrow scope-wide because the false case already exited. Two unsoundness traps define correct use: the asserts annotation is mandatory and never inferred (omit it and narrowing silently disappears), and the body is never checked against the claim (an assertion that fails to throw lies to the compiler and crashes at runtime). Keep them tiny, obviously-throwing, and ideally library-generated. Next we close the unit with satisfies — the operator that validates a value against a type without widening it, the complement to the casts and annotations we have used so far. Now when you see a forest of ! or repeated if (!x) throw guards on the same variable, you know to collapse them into one assertDefined call and let the assertion do the narrowing for the rest of the function.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 8 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources1
expand
  1. 01

Trademarks belong to their respective owners. Editorial reference only.