skip to content

Assertion Functions (asserts x is T)

Assertion signatures narrow everything after the call rather than inside a branch — the invariant-check style of guard. Interviewers probe the surprising rule that such a function must be called through an explicitly typed binding to work at all.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

4

In TypeScript, how does a user-defined guard declared `asserts value is User` differ from one declared `value is User`, and when would you reach for each?

level: middleimportance: must knowfreq 55%

answer

  1. boolean to test versus call that throws
  2. two typed paths versus one
  3. branch scope versus rest of flow
  4. no else branch after an assertion
  5. assertion can delegate to the predicate

basics

~20 s

A value is User guard returns a boolean and narrows only where that boolean is tested; an asserts value is User guard returns nothing, throws on failure, and narrows the value for all code after the call.

solid answer

~50 s

Both are user-defined guards, but they hand you the narrowing in different shapes. The predicate form returns a real `boolean` whose type is `value is User`, so the compiler narrows wherever you test it — the `if` branch, the `else` branch, a ternary, a callback — which makes it composable. The assertion form has no boolean at all: its declared return type is `asserts value is User`, and the compiler reasons that if the call returned, the claim holds, so narrowing continues for the rest of that control-flow path with no branch around it. Reach for the predicate when both outcomes are ordinary program states you want to branch on. Reach for the assertion when the failing case is not something the caller handles inline and you want the code after the call to read as straight-line. You cannot have both in one signature, but an assertion can call a predicate internally.

code

typescript · 21 lines
typescript
type User = { id: string };

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

function assertIsUser(value: unknown): asserts value is User {
  if (!isUser(value)) throw new TypeError("not a User");
}

function withPredicate(value: unknown): string {
  if (isUser(value)) return value.id; // narrowed inside the branch only
  return "anonymous";
}

function withAssertion(value: unknown): string {
  assertIsUser(value);
  return value.id;                    // narrowed for the rest of the flow
}

console.log(withPredicate({}), withAssertion({ id: "1" }));

go deeper

for a junior

Know that one form returns a boolean you test and the other throws, and that after an assertion call you can use the value directly without an if.

for a middle

Explain the control-flow difference precisely: branch-scoped narrowing from a boolean predicate versus narrowing of the whole continuation from an assertion, and why an assertion has no testable result.

for a senior

Show the design call — predicate when both outcomes are legitimate states the caller handles, assertion when the bad value is a defect — and keep one shape check by having the assertion delegate to the predicate.

for a principal

Set the convention for where each form is allowed in a codebase, since assertions concentrate failure handling at boundaries while predicates push branching outward, and mixed use makes error paths hard to reason about.

## Two shapes for the same idea A user-defined guard is a function whose signature tells the checker something it cannot work out on its own — that a value with a wide type is really a narrower one. TypeScript offers two signatures for this, and interviewers use the contrast to see whether a candidate reasons about control flow or just memorises syntax. ```ts type User = { id: string }; function isUser(value: unknown): value is User { return typeof value === "object" && value !== null && "id" in value; } function assertIsUser(value: unknown): asserts value is User { if (!isUser(value)) throw new TypeError("not a User"); } ``` ## What the compiler does with each The predicate form returns a genuine `boolean` at runtime. Its declared type, `value is User`, tells the checker: wherever this boolean is `true`, treat the argument as `User`; wherever it is `false`, remove `User` from the argument's type. Narrowing is therefore *scoped to the test*. ```ts function withPredicate(value: unknown): string { if (isUser(value)) { return value.id; // narrowed only inside this branch } return "anonymous"; // here it is still unknown } ``` The assertion form returns nothing. Its signature says the call throws or the claim holds, so the checker applies the narrowing to the *continuation* of the flow: ```ts function withAssertion(value: unknown): string { assertIsUser(value); return value.id; // narrowed for the rest of the path } ``` That difference cascades into everything else. The predicate gives you two typed paths; the assertion gives you one, because the other path left the function. The predicate produces a value you can store, negate, or pass on; the assertion produces nothing testable — `if (assertIsUser(x))` is a compile error, TS1345, because a `void` expression cannot be tested for truthiness. ## Composability Because the predicate is an ordinary boolean-returning function, it can be handed to anything that expects a boolean callback, and the compiler carries the narrowing claim through. An assertion function cannot be used that way: it returns nothing, and its effect is expressed through control flow at a direct call site rather than through a value. If you need a guard that other code can call as a plain function and act on, it must be the predicate. ## One signature, one shape A function's signature carries either `value is User` or `asserts value is User`, never both. In practice teams write the predicate as the single source of shape-checking truth and let the assertion delegate to it, exactly as `assertIsUser` does above. That gives two call styles over one implementation and keeps the two guards from drifting apart. ## Choosing between them Ask what the failing case *is*. If a non-`User` is a normal state your caller has an answer for — render a placeholder, skip the item, take a different branch — the predicate is right, because you want both paths typed and you want a value you can compose with. If a non-`User` means the caller was handed something it was never supposed to receive, the assertion is right. The call reads as a precondition, the rest of the function is written against the narrow type without an extra level of nesting, and you avoid the awkward pattern of testing a predicate only to throw in the `else`. A readability signal that shows up in real reviews: a function whose body is one long `if (isUser(value)) { ...forty lines... } else { throw ... }` is usually asking to be an assertion call on line one. ## Consequences to keep straight Both forms are compile-time only. Types are erased, so neither signature emits a check; the runtime behaviour comes entirely from the code you wrote in the body — a `return` of a boolean expression in one case, a `throw` in the other. Both narrow references the checker can track, including property paths: `assertIsUser(payload.user)` narrows `payload.user` on the following lines, until something the compiler can see reassigns it. And neither gives you narrowing in a `catch`. Wrapping an assertion call in `try`/`catch` does not produce the predicate's `else` branch — inside the `catch` the checker assumes the `try` block may have failed anywhere, so the original binding is back to its declared type.

  • Can one function be both a predicate and an assertion?
    No — a signature carries either `value is T` or `asserts value is T`. The usual composition is to write the boolean predicate once and have the assertion call it: inside a function declared `asserts value is User`, write `if (!isUser(value)) throw new TypeError(...)`. One shape check, two call styles, no drift between them.
  • Can you recover an else branch by wrapping the assertion call in try/catch?
    Not usefully. The `catch` clause gets control when anything in the `try` threw, so the checker gives the original binding back its declared type rather than the negated one. If you genuinely need both outcomes typed, use the predicate — that is precisely what its false branch is for.
  • Does an assertion call narrow a property access such as payload.user?
    Yes. Narrowing applies to any reference the checker can track, so `assertIsUser(payload.user)` types `payload.user` as `User` on the following lines, exactly as a `typeof` test on it would. The narrowing is dropped once code the compiler can see reassigns that property.
  • Why does `if (assertIsUser(x))` fail to compile?
    Because the call's type is effectively `void` — an assertion signature leaves no value behind — and TypeScript rejects testing a `void` expression for truthiness with error TS1345. The narrowing needs no branch anyway: just call it, then use the value on the next line.

saying these in an interview costs you the question

  • Says an assertion function returns true or false
  • Wraps the assertion call in an if to get narrowing
  • Expects narrowing in an else branch after an assertion
  • Treats the two forms as interchangeable spellings
  • Writes both a predicate and asserts in one signature

context

open as a page

In TypeScript, a helper is declared `function assertIsString(value: unknown): asserts value is string`. What does that return type tell the compiler, and what must the body do when value is not a string?

level: juniorimportance: should knowfreq 35%

basics

~10 s

It declares an assertion function: returning normally is the compiler's proof, so from the call onward TypeScript treats value as a string. The body must throw when value is not a string.

open as a page

In TypeScript, calling this helper reports "Assertions require every name in the call target to be declared with an explicit type annotation": `const assertIsString = (v: unknown): asserts v is string => { if (typeof v !== "string") throw new TypeError(); };`. Why is the call rejected, and how do you fix it?

level: middleimportance: should knowfreq 42%

basics

~20 s

An assertion call only works when every name in the call target has a written-out type, and this const's type is inferred from its arrow initializer. Fix it by annotating the binding with a function type, or by using a function declaration.

open as a page

You want one TypeScript helper, invariant(condition, message), that throws when the condition is falsy and also narrows types for the code that follows. What signature makes that work, and what does a call like `invariant(typeof id === "string")` narrow?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Declare it with the bare assertion form: function invariant(condition: unknown, message?: string): asserts condition. After the call the compiler applies whatever narrowing the condition expression would have produced inside an if, so the id binding becomes string.

open as a page