TypeScript and the runtime · 3 / 7
lesson 3

Assertion functions

Throw at the boundary so the rest of the code never has to ask whether the value is right.

~ 13 min read·lesson 3 of 7
0 / 7

A predicate gives you an if. An assertion gives you a sentence. After the assertion runs without throwing, the value is whatever you said it is — for the rest of the function. No nested branches, no early returns to remember. This lesson is about that flavor of check, when to reach for it, and the small bit of TypeScript syntax that makes it work.

The asserts keyword

A regular function can throw. TypeScript doesn't normally read anything into that — after the call returns, the type of your value is the same as before. The asserts keyword changes that.

assert-string.ts
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
  throw new TypeError("expected a string, got " + typeof value);
}
}

function loudUpper(value: unknown) {
assertIsString(value);
return value.toUpperCase();   /* value is string here */
}

The signature reads "asserts value is string." That phrase tells the compiler two things: the function might throw, and if it returns normally, the argument is a string from that line on. You don't wrap the call in an if. You don't store the result. The narrowing flows out of the call site.

The body is your job. The compiler trusts the signature; it never checks whether the body actually verifies the claim. Same trust contract as a predicate.

Tip

An assertion function with a return value is fine, but the body can never use return to short-circuit — it must throw on failure. A function that quietly returns when the value is wrong defeats the contract.

check your understanding
You write function assertIsNumber(v: unknown): asserts v is number and the body returns undefined when v is a string instead of throwing. What happens when a string is passed?

Assertion vs predicate

Both check shape. Both teach the compiler. They differ in flow.

A predicate gives you a branch. You decide what to do with false — throw, return a fallback, log and skip. The flexibility costs you a level of indentation everywhere.

An assertion is a fence. The function either succeeds and lets you through, or it throws. There is no "what to do on failure" question — the call site doesn't get to decide.

predicate-style.ts
function loadUser(raw: unknown) {
if (!isUser(raw)) {
  throw new Error("bad user");
}
return raw.name;   /* User */
}
assertion-style.ts
function loadUser(raw: unknown) {
assertIsUser(raw);
return raw.name;   /* User */
}

Same outcome, less code in the assertion version. The win is bigger when you have several values to check at the top of a function — three predicate if blocks become three flat assertion calls, and the rest of the function reads as if all three values were already typed.

Use a predicate when failure is a real path you want to handle gracefully (a fallback render, a "skip this row," a "ask again"). Use an assertion when failure is a bug the caller can't recover from — typically right at the trust boundary, where bad data means the operation simply cannot continue.

predicateisUser(v)true false use handle assertionassertUser(v)usethrow
A predicate hands you a branch. An assertion hands you a fence — either through, or a thrown error.
check your understanding
A function reads a config from disk, parses JSON, then uses every field. The config is wrong on disk — the app cannot run. Predicate or assertion?

Asserting truthiness

There's a shorter form: asserts value — no is X after it. This narrows away the falsy possibilities (null, undefined, 0, "", false).

assert-truthy.ts
function assertDefined<T>(value: T): asserts value {
if (!value) {
  throw new Error("expected a defined value");
}
}

function findUser(id: string) {
const user = cache.get(id);   /* User | undefined */
assertDefined(user);
return user.name;             /* User */
}

The signature asserts value is the truthiness flavor: after the call, value cannot be falsy. This is handy for the common case where a lookup returns T | undefined and you've already convinced yourself the value will be there. It's also dangerous for the same reason — if the assumption is wrong, your assertion is a louder bug than the original undefined would have been.

A small upgrade: take a label and bake it into the error message so the throw is debuggable.

assert-defined-label.ts
function assertDefined<T>(
value: T,
label: string,
): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
  throw new Error(label + " was null or undefined");
}
}

assertDefined(user, "user from cache");

The variant uses asserts value is NonNullable<T> — a slightly stronger contract. NonNullable<T> is TypeScript's built-in utility type that strips null and undefined from a type. After the call, the value is T minus the nullish parts. The label makes the eventual Error.message actually useful in a stack trace.

Watch out

Don't use assertDefined as a silent bypass for values that legitimately could be missing. It's for cases where missing means "bug in our code." Real "could be missing" should be handled with a check, not asserted away.

check your understanding
Which signature is correct for an assertion that x is non-null after the call?

Patterns at the boundary

A common shape: read raw data, assert its shape, return the typed value. The assertion lives at the gate, and every caller after it gets a real type.

boundary-assert.ts
type User = { name: string; age: number };

function assertIsUser(value: unknown): asserts value is User {
if (
  typeof value !== "object" ||
  value === null ||
  typeof (value as User).name !== "string" ||
  typeof (value as User).age !== "number"
) {
  throw new Error("not a User: " + JSON.stringify(value));
}
}

async function loadUser(id: string): Promise<User> {
const res = await fetch("/api/users/" + id);
const data: unknown = await res.json();
assertIsUser(data);
return data;   /* User */
}

loadUser returns Promise<User> honestly. There is no as User lie anywhere. If the server response shape changes, the assertion throws a useful message — not a TypeError: cannot read property 'name' of undefined 30 lines later.

The same pattern works for arguments at a public API surface — a library function that takes unknown and asserts the shape before doing anything else. From the inside, the function reads as if the value were always typed.

public-api-assert.ts
export function calculateTax(input: unknown): number {
assertIsOrder(input);
return input.items.reduce((sum, item) => sum + item.price * 0.08, 0);
}

The user of the library passes whatever they have. Inside the function, after the assertion, input.items is fully typed. The boundary lives in one place; the rest of the function never has to wonder.

check your understanding
You wrap every server response in assertIsX(data) at the fetch site. Which problem does this solve?
check your understanding
Two assertion functions: A throws a plain "invalid" message, B throws with the offending JSON included. Which is better and why?
check your understanding
You have assertIsUser and need to use it on an array of unknowns. Which approach scales best?
← prevnext lesson →
KeepLearningcertificate
for completing
TypeScript and the runtime
0 of 7 read