Throw First, Narrow Forever
A boolean type predicate (value is Type) only narrows inside the block where you checked it. The moment you leave that if, TypeScript forgets. Assertion functions solve a different problem: you want to validate once, near the top of a function, and have the narrowing apply to everything after it โ not just one conditional branch. That's the entire pitch of asserts, and it's why fail-fast validation at API boundaries is the pattern's natural home.
- You write
if (!isUser(data)) throw new Error()followed by moreifchecks just to get TypeScript to trustdatafor the rest of the function - You copied an assertion helper as an arrow function from a blog post and it silently stopped narrowing after you refactored it
- Your
assertConfig(x)call is wrapped inisDev && assertConfig(x), and TypeScript still complainsxis possiblyundefinedtwo lines later - You want an exhaustiveness check that actually throws in production, not just a type-level guarantee that disappears at build time
Assertion functions trade a boolean return for a control-flow guarantee: if the function returns at all, the condition held โ for the rest of the scope, not just one branch.
asserts value is Type vs Plain asserts condition
There are two assertion signature shapes. Use asserts value is Typewhen you're narrowing a specific parameter to a specific type. Use plain asserts condition โ no isโ when you're asserting an arbitrary boolean expression, exactly like Node's built-in assert().
// Narrows a specific value to a specific type
function assertIsString(val: unknown): asserts val is string {
if (typeof val !== 'string') {
throw new TypeError(`Expected string, got ${typeof val}`);
}
}
// Narrows nothing in particular โ just asserts a condition is true
function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message ?? 'Assertion failed');
}
}
function handler(input: unknown) {
assertIsString(input);
input.toUpperCase(); // โ
input: string for the rest of the function
const user = getUser(); // User | null
assert(user !== null, 'user must be loaded before this point');
user.id; // โ
TypeScript narrows 'user' from User | null to User
}Compare that to a type predicate, which only narrows inside the branch that called it:
function isString(val: unknown): val is string {
return typeof val === 'string';
}
function handler(input: unknown) {
if (isString(input)) {
input.toUpperCase(); // โ
narrowed here
}
input.toUpperCase(); // โ back to 'unknown' โ the predicate's scope ended with the if
}
flowchart TD
A["value: unknown"] --> B{"isString(value)<br/>type predicate"}
B -- "true, inside if" --> C["narrowed: string<br/>(only in this block)"]
B -- "after the if" --> D["back to unknown"]
A --> E["assertIsString(value)<br/>assertion function"]
E -- "returns normally" --> F["narrowed: string<br/>(rest of the scope)"]
E -- "condition false" --> G["throws โ code below never runs"]
style C fill:#0078D4,color:#fff,stroke:#005a9e
style F fill:#16a34a,color:#fff,stroke:#15803d
style D fill:#6b7280,color:#fff,stroke:#4b5563
style G fill:#dc2626,color:#fff,stroke:#b91c1c
Assertion Signatures Are Never Inferred
Arrow functions can be assertion functions โ the syntax works fine when the assertsclause is written directly on the arrow function's own return position:
const assertIsDefined = <T,>(val: T, name = 'value'): asserts val is NonNullable<T> => {
if (val === undefined || val === null) {
throw new Error(`Expected '${name}' to be defined, got ${val}`);
}
};
const config = loadConfig(); // Config | undefined
assertIsDefined(config, 'config');
config.apiUrl; // โ
narrowed to ConfigThe real quirk isn't "arrow functions can't do this." It's that TypeScript never infers an assertion signature โ you must write assertsexplicitly on the function's own declaration. That bites in two very common situations: wrapping an assertion function, and annotating a variable with a wider type than the function actually has.
// โ Wrapping loses the signature โ the wrapper's return type is inferred as a plain
// function, because TypeScript doesn't propagate 'asserts' through composition
function withLogging(fn: (val: unknown) => void) {
return (val: unknown) => {
console.log('checking', val);
fn(val);
};
}
const loggedAssert = withLogging(assertIsString); // (val: unknown) => void โ NOT an assertion function
loggedAssert(input);
input.toUpperCase(); // โ Error: input is still 'unknown'
// โ An explicit wider annotation erases the assertion signature too,
// even though the implementation still throws correctly at runtime
const assertIsStringWeak: (val: unknown) => void = assertIsString;
assertIsStringWeak(input);
input.toUpperCase(); // โ Error: input is still 'unknown' โ the TYPE says nothing about asserting
// โ
Fix: redeclare the assertion clause on anything that wraps or re-types it
function withLoggingTyped<T>(fn: (val: unknown) => asserts val is T) {
return (val: unknown): asserts val is T => {
console.log('checking', val);
fn(val);
};
}Fail-Fast at Function and API Boundaries
The best home for assertion functions is the edge of your system โ request handlers, constructors, anywhere untyped data crosses into typed code. Validate once, throw with a useful message, and every line after that point is fully typed.
interface CreateOrderRequest {
customerId: string;
items: { sku: string; quantity: number }[];
}
function assertCreateOrderRequest(body: unknown): asserts body is CreateOrderRequest {
if (typeof body !== 'object' || body === null) {
throw new HttpError(400, 'Request body must be an object');
}
const b = body as Record<string, unknown>;
if (typeof b.customerId !== 'string') {
throw new HttpError(400, 'customerId must be a string');
}
if (!Array.isArray(b.items) || b.items.length === 0) {
throw new HttpError(400, 'items must be a non-empty array');
}
}
app.post('/orders', (req, res) => {
assertCreateOrderRequest(req.body);
// req.body is CreateOrderRequest from here on โ no casts, no optional chaining
createOrder(req.body.customerId, req.body.items);
res.status(201).end();
});Don't hand-roll this past three or four fields. Once you're nesting typeofchecks two levels deep you're reinventing Zod badly โ pull in a real schema library and derive the assertion from schema.parse()instead. The mechanism is identical; you're just outsourcing the tedious part.
Combining With never for Exhaustiveness
neveris a compile-time-only guarantee โ it catches missing cases when you add a new union member, but says nothing at runtime. Pairing it with an assertion function gives you both: a compiler error today, and a hard failure in production if something slips through anyway (a bad cast, a JSON payload with an unexpected value, a library returning something the type system didn't predict).
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'rectangle'; width: number; height: number };
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unhandled case: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.side ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// If Shape gains a new member, this line fails to compile โ
// AND throws a real error if it somehow runs anyway
return assertNever(shape);
}
}Common Pitfalls
Conditional calls swallow the narrowing
This is the one that actually bites people in production. Someone writes a "quick" debug assertion, gates it behind an env flag so it doesn't run in prod, and then can't figure out why the type error won't go away. It's not a compiler bug.
function processIfDev(input: unknown, isDev: boolean) {
isDev && assertIsString(input);
input.toUpperCase(); // โ Error: input is possibly not a string
// Why TypeScript is correct: if isDev is false, assertIsString never ran.
// There's a real code path where 'input' was never checked โ narrowing
// it anyway would be unsound, not just overly cautious.
}
// Same trap with a ternary โ only one branch calls the assertion
function processTernary(input: unknown, strict: boolean) {
strict ? assertIsString(input) : void 0;
input.toUpperCase(); // โ still not narrowed โ the 'else' branch skipped the check
}
// โ
Fix: make the assertion unconditional, or move the conditional logic inside it
function assertIsStringIfDev(input: unknown, isDev: boolean): asserts input is string {
if (isDev) assertIsString(input);
}Relying on a class field arrow function for "this" assertions
A method declared with an assertion signature narrows this reliably because it lives on the prototype. An arrow function stored as a class field is a per-instance value assigned in the constructor โ TypeScript can still type it with asserts this is ..., but because it's reassignable per instance, leaning on it for narrowing across the class is fragile. Prefer a real method for anything that asserts about this.
Key Takeaways
- Assertion functions narrow for the rest of the scope; type predicates only narrow inside the
ifthat called them โ pick based on how long you need the narrowing to live. - Arrow functions work fine as assertion functions โ the real constraint is that
assertsis never inferred. Wrapping, composing, or annotating with a wider type silently erases it, with no compiler error. isDev && assertFn(x)is a genuine footgun, not a TypeScript limitation โ there's a real code path where the assertion never ran, so narrowing afterward would be unsound.- Combine
assertNeverwith exhaustive switches to get both a compile-time exhaustiveness check and a runtime guardrail for when reality doesn't match the type.

