DEV Community

Cover image for `asserts cond` vs `is X`: When the Assertion Function Beats the Type Guard
Gabriel Anhaia
Gabriel Anhaia

Posted on

`asserts cond` vs `is X`: When the Assertion Function Beats the Type Guard


You've seen it. A queue worker silently drops a chunk of
webhook deliveries. The worker reads JSON off a redis stream,
runs it through a hand-rolled isWebhook(x): x is Webhook
predicate, and forwards the ones that pass. The ones that do
not pass return false, the worker logs a debug line, and
the message gets acked. For weeks the integration team files
tickets saying "we sent the event, you say you never got it."
The predicate is strict in the right places and loose in the
wrong ones. A missing eventId field returns false, and
false means "skip and ack" instead of "stop and yell."

The fix is a four-character change. The predicate is the
wrong primitive. What the worker actually wants is an
assertion. It should refuse to continue if the message is not
a webhook, let the queue's poison-message handler park it,
page somebody. A function assertWebhook(x): asserts x is Webhook
would throw on the first malformed payload, the dead-letter
queue would catch it, and the integration team would have a
real error to read instead of a silence.

TypeScript has two narrowing primitives that look almost the
same. They are not the same. Picking the wrong one is the
kind of bug that ships through code review. Both versions
compile, both narrow at the call site. The only difference is
what happens when the input is bad.

The two primitives, side by side

A type guard returns a boolean and the compiler narrows on
the true branch:

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

const payload: unknown = await readJson();
if (isUser(payload)) {
  payload.id; // narrowed to User here
}
// outside the if, payload is still unknown
Enter fullscreen mode Exit fullscreen mode

An assertion function returns void (or throws) and the
compiler narrows after the call:

function assertUser(x: unknown): asserts x is User {
  if (
    typeof x !== "object" ||
    x === null ||
    !("id" in x) ||
    typeof (x as { id: unknown }).id !== "string"
  ) {
    throw new TypeError("expected User");
  }
}

const payload: unknown = await readJson();
assertUser(payload);
payload.id; // narrowed to User from this line down
Enter fullscreen mode Exit fullscreen mode

A type guard gives the caller a branch to take. The caller
decides what happens on a non-match. An assertion gives the
caller a wall they can't walk past. After the call, the value
is User or the function has already thrown.

Picking between them is a question about the shape of your
control flow, not the shape of your validator.

When the type guard is the right reach

Type guards are for the cases where a non-match is part of
the design. A few patterns where they're the cleanest tool:

Filtering a heterogeneous collection. You have an array
of Notification | Error and you want the notifications:

type Item = Notification | ErrorEvent;

function isNotification(x: Item): x is Notification {
  return x.kind === "notification";
}

const inbox: Item[] = await readInbox();
const notifications = inbox.filter(isNotification);
//      ^? Notification[]
Enter fullscreen mode Exit fullscreen mode

filter calls the predicate per element and routes the
trues into the result. There's no concept of failure here.
Elements that are errors belong to a different list; they
don't throw. The type guard is the language feature that
makes the resulting array's type drop the ErrorEvent branch.

Branching on shape inside a switch. You have a
discriminated union and you want to dispatch:

type Cmd =
  | { kind: "move"; dx: number; dy: number }
  | { kind: "rotate"; degrees: number };

function isMove(c: Cmd): c is Extract<Cmd, { kind: "move" }> {
  return c.kind === "move";
}

function handle(c: Cmd): void {
  if (isMove(c)) {
    moveBy(c.dx, c.dy);
  } else {
    rotateBy(c.degrees);
  }
}
Enter fullscreen mode Exit fullscreen mode

For a closed union, the bare discriminant check usually
narrows on its own. The named guard earns its keep when the
predicate is non-trivial: a check that depends on multiple
fields, or a constraint that is too long to inline at the
call site.

Optional features behind capability checks. A renderer
that asks whether a runtime supports a method:

type Maybe<T> = T | undefined;

function hasResize(
  ctx: unknown,
): ctx is { resize(w: number, h: number): void } {
  return (
    typeof ctx === "object" &&
    ctx !== null &&
    "resize" in ctx &&
    typeof (ctx as { resize: unknown }).resize === "function"
  );
}

function adapt(ctx: unknown, w: number, h: number): void {
  if (hasResize(ctx)) {
    ctx.resize(w, h);
  } else {
    redraw(w, h);
  }
}
Enter fullscreen mode Exit fullscreen mode

The branch is real. Some runtimes have resize, some don't.
That's a feature of the runtime surface, not a bug. The type
guard says "decide which path applies."

When the assertion is the right reach

Assertion functions are for the cases where a non-match is a
bug. The patterns where they earn their keep:

Schema parsing at a trust boundary. You have raw input
from outside the program (HTTP body, queue message, file on
disk) and you refuse to continue if it does not match:

type Webhook = {
  eventId: string;
  payload: Record<string, unknown>;
};

function assertWebhook(x: unknown): asserts x is Webhook {
  if (typeof x !== "object" || x === null) {
    throw new TypeError("webhook must be an object");
  }
  const o = x as Record<string, unknown>;
  if (typeof o.eventId !== "string") {
    throw new TypeError("webhook.eventId must be string");
  }
  if (typeof o.payload !== "object" || o.payload === null) {
    throw new TypeError("webhook.payload must be object");
  }
}

async function handleDelivery(raw: unknown): Promise<void> {
  assertWebhook(raw);
  await dispatch(raw.eventId, raw.payload);
}
Enter fullscreen mode Exit fullscreen mode

This is the shape Zod's .parse(...) and Valibot's
v.parse(...) are both built on. The function doesn't return
the value. It asserts the value. From the next line on, the
type system trusts what the runtime already checked. Wrap
the assertion in a top-level handler that parks the message
on a dead-letter queue and pages when the error rate spikes,
and the bad-input case stops being silent.

Invariant checks that are bugs if they fail. Inside the
program, where you know the value should be T and you want
the type system to agree:

function assertDefined<T>(
  v: T | undefined | null,
  name: string,
): asserts v is T {
  if (v === undefined || v === null) {
    throw new Error(`${name} was unexpectedly nullish`);
  }
}

function processRow(row: Row): void {
  assertDefined(row.userId, "row.userId");
  // row.userId is string from here
  upsertUser(row.userId);
}
Enter fullscreen mode Exit fullscreen mode

If row.userId is null, that is not a runtime branch you
want to take. The row should never have made it this far.
The assertion turns a programmer error into a thrown error
on the right line, and the type system rewards the check by
narrowing the rest of the function.

Real bugs from picking wrong

The webhook story above is one of them: a type guard used
where an assertion was needed turned a parse failure into a
silent skip. The opposite mistake is just as common.

Type guard where assertion was needed (silent skip).
Anywhere the predicate's false branch leads to ack-and-drop,
the type guard masks bad input as good-but-empty:

// Bad: false branch silently acks malformed messages
async function consume(msg: unknown): Promise<void> {
  if (isWebhook(msg)) {
    await handle(msg);
  }
  // implicit ack — malformed messages disappear here
}
Enter fullscreen mode Exit fullscreen mode

The fix is the assertion form. The malformed branch becomes
a thrown error, the queue's retry/DLQ logic catches it, the
integration team gets a real signal:

async function consume(msg: unknown): Promise<void> {
  assertWebhook(msg);
  await handle(msg);
}
Enter fullscreen mode Exit fullscreen mode

Assertion where type guard was needed (throwing in a
loop).
A search ranker that takes a list of candidate
results and filters out the ones missing required fields:

// Bad: assertion thrown per item collapses the whole batch
async function rank(items: unknown[]): Promise<Result[]> {
  const out: Result[] = [];
  for (const item of items) {
    assertResult(item); // throws on the first malformed item
    out.push(score(item));
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

One bad row in a thousand-row batch terminates the whole
ranking. The fix is the type guard form, where missing fields
are a per-item branch:

async function rank(items: unknown[]): Promise<Result[]> {
  return items
    .filter(isResult)
    .map(score);
}
Enter fullscreen mode Exit fullscreen mode

The decision is whether not-a-Result is a bug or a feature
of the input shape. A heterogeneous queue is a feature, and
untyped input from outside the program is a bug. The
primitive you reach for has to match.

Pitfalls the compiler does not warn about

The first one is signature drift. An assertion function whose
return type is anything other than void (or whose body has
a path that returns without throwing) silently degrades to a
plain function as far as narrowing is concerned. TypeScript
does not enforce that the body of an asserts function
actually throws on the negative branch — it trusts you. A
buggy assertion that returns instead of throwing tells the
type checker "the value is now User" while the runtime is
holding something else:

function assertUser(x: unknown): asserts x is User {
  if (!looksLikeUser(x)) {
    return; // bug: should throw, narrows incorrectly
  }
}
Enter fullscreen mode Exit fullscreen mode

The second one is the
function assertX(x): asserts x is X arrow-function form:
TypeScript does not let you express assertion signatures on
arrow functions stored in
variables
.
This compiles but the assertion is not honored:

const assertUser = (x: unknown): asserts x is User => {
  if (!looksLikeUser(x)) throw new Error("not user");
};

const v: unknown = read();
assertUser(v);
v.id; // error: 'v' is of type 'unknown'
Enter fullscreen mode Exit fullscreen mode

The compiler accepts the syntax but does not propagate the
narrowing through the variable form. The fix is to write the
assertion as a function declaration, not a const arrow.
This trap shows up most often when somebody refactors a class
method into a top-level helper and reaches for the arrow form
out of habit.

The decision rule

When you find yourself writing a check on the boundary
between unknown and a domain type, ask one question: what
should happen if the value does not match?

If the answer is "take a different branch" (filter, route
to another handler, render an alternative UI), write a type
guard. The boolean return gives the caller a branch decision,
and the success path sees the narrowed type on the true
side.

If the answer is "refuse to continue" (throw, dead-letter
the message, fail the request), write an assertion function.
After the call returns, every line below it can trust the
shape.

There's an accidental third option that bites: a type guard
whose false branch falls through to a silent return. This
is the bug that shipped in the queue worker above. The
compiler won't catch it, because both versions of the
predicate type-check. On those PRs, ask: "what happens when
this returns false?"

If the answer is "we ack and forget," the predicate is the
wrong primitive. Reach for asserts.


If this was useful

TypeScript Essentials
in The TypeScript Library covers narrowing as the
day-to-day machinery: type guards, assertion functions, the
in operator, discriminant checks, and the boundary patterns
that separate a hardened input layer from the typed core of
an application. It's one of five books in the collection:

  1. TypeScript Essentials — entry point. Types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, the browser.
  2. The TypeScript Type System — the deep dive. Generics, mapped and conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — bridge for JVM developers. Variance, null safety, sealed-to-unions, coroutines to async/await.
  4. PHP to TypeScript — bridge for PHP 8+ developers. Sync to async, generics, discriminated unions.
  5. TypeScript in Production — the production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

Books 1 and 2 are the core path. Book 1 is where narrowing
sits as a daily-driver tool; Book 2 is where the type-system
patterns those narrowing primitives compose with — branded
types, discriminated unions, conditional inference — get the
deep treatment. Book 5 is for anyone shipping TypeScript at
work.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)