skip to content

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%

answer

  1. a guard with no if statement
  2. narrowing continues past the call
  3. returning normally is the claim
  4. every failing path must throw
  5. asserts is never inferred

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.

solid answer

~50 s

`asserts value is string` is an assertion signature, not an ordinary return type. It tells the checker the call has only two outcomes: it throws, or it returns and the argument really is a `string`. There is no boolean to test, so after `assertIsString(input)` the compiler narrows `input` to `string` for the rest of that control-flow path, and the next line can call `input.toUpperCase()` with no surrounding `if`. The obligation sits in the body: it must throw whenever the check fails, typically `if (typeof value !== "string") throw new TypeError(...)`. Returning normally is the entire signal — the checker reads nothing else. The signature also has to be written out by hand; TypeScript never infers an `asserts` return type from a function body, so a helper that throws but is typed `void` narrows nothing at its call sites.

code

typescript · 13 lines
typescript
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new TypeError(`Expected a string, got ${typeof value}`);
  }
}

function shout(input: unknown): string {
  assertIsString(input);
  // no if-block needed: input is string from here on
  return input.toUpperCase();
}

console.log(shout("hello"));

go deeper

for a junior

Be able to read the signature aloud: after this call the argument is a string, otherwise the call threw. Know the body must throw rather than return anything.

for a middle

Explain that the assertion is a control-flow effect applied to the rest of the path, that the call has no testable value, and that the signature must be written by hand.

for a senior

Be ready to place the helper: at a boundary where a bad value is a defect rather than a case to handle, and to note that the type claim holds only while every failing path really throws.

for a principal

Own how many hand-written assertions a codebase should carry versus checks derived from one source of truth, since each assertion is a claim the compiler will never re-verify.

## What the signature says Most functions describe what they give back. An assertion function describes what must be true if it gave anything back at all. Writing `asserts value is string` in the return-type position declares an *assertion signature*: a claim that the call either throws, or returns normally and the parameter named `value` is a `string`. The parameter name in the signature is load-bearing. `asserts value is string` refers to the function's own parameter called `value`; the compiler then maps that onto whatever reference you passed at the call site. ```ts function assertIsString(value: unknown): asserts value is string { if (typeof value !== "string") { throw new TypeError(`Expected a string, got ${typeof value}`); } } ``` ## Where the narrowing lands Narrowing is the compiler's process of taking a broad declared type — here `unknown` — and treating it as something more specific along a particular path through the code. Most narrowing comes from a condition: inside `if (typeof input === "string")`, `input` is a `string`. An assertion function narrows without a condition, because the call itself acts as the condition. Everything *after* the call, on that same control-flow path, sees the narrowed type: ```ts function shout(input: unknown): string { assertIsString(input); return input.toUpperCase(); // input is string here } ``` Lines executed before the call still see the declared type; there is no branch, no `else`, and no nesting. That straight-line shape is the whole ergonomic point of the form. ## Why the body must throw The compiler reads the signature, not the body. Its reasoning is simply: control reached the line after the call, therefore the call did not throw, therefore the claim holds. That makes throwing the only way to communicate failure. A body that logs a warning and returns tells the checker the value passed, and every subsequent line is typed on a false premise. Equally, the call expression carries no value you can test. Its type is effectively `void`, and TypeScript rejects `if (assertIsString(x))` outright with error TS1345, "An expression of type 'void' cannot be tested for truthiness". If you find yourself wanting to branch on the result, you wanted a boolean-returning guard instead. One consequence worth internalising early: the checker takes the signature at face value and never inspects whether the body really throws for every bad input. The narrowing is a promise you are making to the compiler. ## The signature is never inferred TypeScript will happily infer that a function returns `void`, `string`, or a union. It will never infer `asserts`. If you write a perfectly good validator that throws on bad input but leave its return type to inference, callers get no narrowing at all — the code compiles, and the value stays `unknown`. Assertion signatures are opt-in declarations, which is also why they read as deliberate documentation at a boundary. ## It is a compile-time device only Types are erased when TypeScript emits JavaScript. The assertion signature contributes nothing to the output: your `if`/`throw` survives because you wrote it as ordinary JavaScript, and the `asserts value is string` annotation simply disappears. There is no generated runtime check, no reflection, no cost. Everything the signature buys you is checking-time knowledge. ```ts // emitted JavaScript keeps only the runtime code you wrote function assertIsString(value) { if (typeof value !== "string") { throw new TypeError(`Expected a string, got ${typeof value}`); } } ``` ## When to reach for it The shape fits a value arriving with a wide type — `unknown`, a union, something possibly null — where continuing with a bad value is not a case you want to handle inline. Assert once near the top of the function, then write the rest of the body against the narrow type. Two mechanical rules keep it working: write the signature explicitly, and make sure every failing path in the body ends in a `throw`. Assertion signatures have existed since TypeScript 3.7; the examples here assume the 5.x line or later.

  • Can an assertion function also return a value?
    No. The assertion signature occupies the return-type position, so the call expression has no useful value — TypeScript treats it like `void`, and testing it for truthiness is an error. If you want the checked value handed back, write a function that returns the parsed value and assign the result to a new binding instead.
  • Does TypeScript ever infer an `asserts` signature from a body that throws?
    Never. Both `asserts x is T` and the bare `asserts x` form must be written out explicitly. A validator that throws on bad input but has an inferred or `void` return type compiles fine and narrows nothing at its call sites — a common reason a team's guard "does not work".
  • What does the emitted JavaScript contain for this helper?
    Only the code you wrote by hand: the `typeof` test and the `throw`. The `asserts value is string` annotation is erased along with the parameter type, so there is no generated check and no runtime cost. The narrowing exists purely inside the checker.

saying these in an interview costs you the question

  • Says the function returns a boolean the caller checks
  • Thinks the narrowing only applies inside an if block
  • Expects the compiler to emit a runtime type check
  • Believes TypeScript infers asserts from a throwing body
  • Has the body return early instead of throwing

context