10LOC
TypeScriptintermediate

Assertion functions (asserts x is T) for runtime-checked narrowing

Published February 23, 2027

export function assertIsString(value: unknown, name: string): asserts value is string {
  if (typeof value !== "string") {
    throw new TypeError(`${name} must be a string, got ${typeof value}`);
  }
}

export const parseUsername = (input: unknown): string => {
  assertIsString(input, "username");
  return input.trim().toLowerCase();
};

What

assertIsString either throws or returns nothing — but its return type, asserts value is string, tells TypeScript that if the function doesn't throw, every reference to value after the call is narrowed to string for the rest of that scope.

Why it matters

A type predicate (value is string) only narrows inside the branch where you checked it — if (isString(value)) { ... }. That's the right shape when there's a real fallback path. But a lot of narrowing is really validation: "this must be a string, or the caller made a mistake and we should stop now." Writing that as a type predicate forces an artificial if (!isString(value)) throw ...; at every call site just to get the narrowing outside the if. An assertion function does that same "narrow or throw" in one call, with the narrowing applying to everything after the call rather than inside a block.

How it works

  • The return type asserts value is string — rather than an ordinary boolean or value is string — is what makes this an assertion function; TypeScript recognizes the asserts keyword and narrows the checked parameter for the rest of the enclosing scope after a call that doesn't throw.
  • parseUsername calls assertIsString(input, "username") with no if around it. After that line, input's type is string, not unknown.trim() on the next line just works.
  • The thrown TypeError is real, not a formality — if value genuinely isn't a string, the function throws before returning control, so the "narrowing" is never observed to be wrong.

Gotchas

  • Assertion functions can't have their asserts clause inferred — it has to be written explicitly on the function's return type, because TypeScript can't infer "this function asserts a type" the way it infers an ordinary return type.
  • Calling an assertion function through an alias loses the narrowing — TypeScript only applies the special control-flow effect when it can see the call directly, not through an indirection like assigning the function to a differently-typed variable first.

Related

  • unknown + a type guard — same validation goal, but branches instead of throwing; better when there's a legitimate non-matching case to handle rather than reject.
  • Discriminated unions + a never check — a different flavor of "make the compiler enforce a runtime invariant," applied to exhaustiveness rather than input validation.