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?
answer
- no is clause after asserts
- the argument expression does the narrowing
- parameter typed unknown, not boolean
- same narrowing an if would give
- stripped throw leaves a lying type
basics
~20 sDeclare 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.
solid answer
~50 sUse the bare form of an assertion signature — `function invariant(condition: unknown, message?: string): asserts condition` — with no `is T` after it. It says: if this call returned, the argument expression was truthy. The compiler then replays the narrowing that expression would have caused inside an `if`, so `invariant(typeof id === "string")` narrows `id` to `string` for the rest of the flow, `invariant(user)` strips `null` and `undefined` from `user`, and `invariant(a && b)` narrows both operands. Type the parameter `unknown` rather than `boolean` so any expression is accepted without a cast. Two mechanics keep it working: it must be a function declaration or an explicitly annotated binding, and the call target must be a plain or dotted name. And the guarantee is only as good as the body — if a build strips the throw, the types keep claiming something the runtime no longer checks.
code
typescript · 14 linesfunction invariant(condition: unknown, message?: string): asserts condition {
if (!condition) throw new Error(message ?? "invariant failed");
}
function load(raw: unknown, cache: Map<string, string>): string {
invariant(typeof raw === "string", "raw must be a string");
// raw: string
const hit = cache.get(raw);
invariant(hit, `no cache entry for ${raw}`);
// hit: string, not string | undefined
return hit.toUpperCase();
}
console.log(load("k", new Map([["k", "v"]])));go deeper
Know that the bare form is asserts condition with no type after it, and that the code following the call sees whatever the condition proved.
Explain that the narrowing comes from the argument expression, so the helper inherits typeof, truthiness and compound-condition narrowing, and that an is clause would break it.
Demonstrate the operational judgment: the type claim lasts only while the body throws on every falsy path, so stripped or logging-only variants leave narrowed code with no check behind it.
Set where a generic invariant is acceptable versus named domain assertions, and treat any build transform that removes checks as removing the type guarantees those checks justified.
## The bare assertion form Assertion signatures come in two shapes. `asserts value is T` claims a specific type for a parameter. The bare form, `asserts condition`, claims only that the named parameter was *truthy* if the call returned. That second shape is what a general-purpose invariant helper needs, because the caller supplies the condition and the helper has no idea what type is being established. ```ts function invariant(condition: unknown, message?: string): asserts condition { if (!condition) throw new Error(message ?? "invariant failed"); } ``` The parameter type matters. Declaring `condition: unknown` lets any expression be passed — a possibly-null object, a string, the result of a `typeof` comparison — without a cast at every call site. Declaring it `boolean` would force callers to coerce, and callers who write `invariant(user)` to mean "user is not null" would be rejected. ## What actually gets narrowed The key insight is that the compiler does not narrow the *parameter*; it narrows whatever references the *argument expression* mentions, exactly as if that expression had been an `if` condition. Three common cases: ```ts declare const id: unknown; invariant(typeof id === "string"); id.toUpperCase(); // id: string declare const user: { name: string } | null; invariant(user); user.name; // user: { name: string } declare const p: string | null; declare const q: number | null; invariant(p && q); p.length; // p: string q.toFixed(1); // q: number ``` This is why the bare form is so much more useful than it first looks: one helper covers null checks, `typeof` checks, discriminant comparisons and compound conditions, because it inherits the whole narrowing machinery from ordinary conditionals. Contrast it with a signature that looks similar and does nothing useful: `asserts condition is boolean` narrows the *condition value* to `boolean`, which nobody cares about, and leaves `id` untouched. If you write `is` after the parameter in an invariant helper, you have broken it. ## The mechanics that trip people up The helper must be a function declaration, or a `const` annotated with an explicit function type. An un-annotated arrow assigned to a `const` is rejected at every call site, because the checker will not infer a callee's signature while applying a call's control-flow effect. The call target must also be a plain identifier or a dotted name. `invariant(...)` and `assertions.invariant(...)` work; a callee pulled out of a map or produced by a conditional expression does not, since there is no name to resolve. The optional `message` parameter is ordinary — it plays no part in the assertion. Keep it lazy if building the message is expensive: pass a string only when cheap, or accept a `string | (() => string)` and call it in the failing branch. ## Judgment: what the type claim is worth An assertion signature is a promise the compiler accepts without inspection. For an invariant helper that promise has an unusual dependency: it holds only as long as the body genuinely throws for every falsy condition. Two ways teams break it in production and are then surprised by the crash site. The first is stripping. If the helper is compiled away or turned into a no-op in production builds — a common trick for assertion libraries, aiming to shed bytes — then the runtime check disappears while every downstream line keeps its narrowed type. The failure no longer surfaces at the invariant with its message; it surfaces later, as a property access on `undefined`, in code the checker had certified. If a build ever removes the check, the narrowing it justified should be considered removed with it. The second is a body that reports instead of throwing — logging, incrementing a metric, returning early behind a feature flag. Any path that returns normally tells the checker the condition held. There is also a modelling cost to weigh. A single `invariant` is a blunt instrument: it narrows, but the *reason* lives only in the message string. Where a specific shape is being established repeatedly, a named assertion with `asserts value is T` documents intent better and gives you one place to change the check. A reasonable convention is `invariant` for local, one-off preconditions, and named assertion functions for domain types crossing a boundary. ## Erasure, as always Nothing here exists at runtime. The emitted JavaScript contains your `if (!condition) throw ...` and nothing else; `asserts condition` vanishes with every other type annotation. All the narrowing is knowledge inside the checker, which is exactly why the runtime behaviour of the body has to be kept honest by hand.
- Why type the condition parameter as `unknown` rather than `boolean`?So callers can pass any expression directly. `invariant(user)` and `invariant(list.length)` are the natural spellings, and a `boolean` parameter would reject them or force `!!` at every site. The assertion effect does not need a boolean — the bare `asserts condition` form only claims the argument was truthy.
- What goes wrong if the signature is written `asserts condition is boolean`?It narrows the condition value itself to `boolean` and nothing else, so a call like `invariant(typeof id === "string")` leaves `id` at its declared type. The bare form is what replays the argument expression's narrowing onto the bindings it mentions; adding an `is` clause silently defeats the helper.
- Your build replaces invariant with a no-op in production. What have you actually changed?You removed the runtime check while keeping every type conclusion that depended on it. The code after each call is still typed as if the condition held, so a bad value now surfaces far from the invariant, as an ordinary property access failure with no message. Either keep the throw in production, or stop relying on the narrowing.
- When would you prefer a named assertion over the generic invariant?When the same shape is established in several places. `assertIsOrder(payload)` names the domain concept, keeps the check in one place, and survives refactoring better than a scattered condition plus message string. Reserve `invariant` for local preconditions that are obvious in context.
saying these in an interview costs you the question
- Writes asserts condition is boolean and expects narrowing
- Types the condition parameter as boolean
- Assigns the helper to an un-annotated const arrow
- Assumes the compiler verifies the body throws
- Strips the check in production but keeps the narrowing