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 ordinarybooleanorvalue is string— is what makes this an assertion function; TypeScript recognizes theassertskeyword and narrows the checked parameter for the rest of the enclosing scope after a call that doesn't throw. parseUsernamecallsassertIsString(input, "username")with noifaround it. After that line,input's type isstring, notunknown—.trim()on the next line just works.- The thrown
TypeErroris real, not a formality — ifvaluegenuinely 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
assertsclause 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.