A type guard returns a boolean — "is this a User?". An assertion function is the partner technique: it throws if the value isn't the right shape, and TypeScript narrows the type for the rest of the scope on the assumption that the function returned normally. This is exactly how expect(...) calls in test frameworks work under the hood — and it's the cleanest pattern for "I refuse to continue if this isn't a User" inside a test helper.
What an assertion function looks like
The signature returns nothing visible — but its return type is a special predicate of the form asserts <value> is <type>:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}After calling assertIsString(x), TypeScript treats x as string for the rest of the block:
const data: unknown = JSON.parse(rawText);
assertIsString(data);
console.log(data.toUpperCase()); // ✅ TypeScript now knows data is stringIf the assertion is wrong, the function throws and the rest of the code doesn't run. If it returns normally, the type is guaranteed to be what was asserted. The pattern shifts the runtime check up front and lets the compiler trust everything after it.
How asserts differs from is
The two related-but-different return types from the previous lesson:
- Type predicate —
value is User— the function returnsboolean. Callers wrap with anif. - Assertion predicate —
asserts value is User— the function returns nothing useful. It throws on failure; on return, the value is narrowed.
// Type guard — caller branches
function isUser(value: unknown): value is User { return /* ... */ true; }
if (isUser(data)) {
data.email; // ✅ narrowed in this branch only
}
// Assertion — caller continues
function assertUser(value: unknown): asserts value is User {
if (!isUser(value)) throw new Error("Not a User");
}
assertUser(data);
data.email; // ✅ narrowed for the rest of the blockBoth have their place. Use a type guard when the failure path is recoverable (handle the non-User case in an else). Use an assertion when failure means "give up and throw" — exactly how test assertions work.
Generic assertion helpers
The most useful assertion in QA code: "this might be undefined or null, but I refuse to continue if it is." Combined with a generic, you get a perfect typed assertDefined:
function assertDefined<T>(value: T | undefined | null, name: string): asserts value is T {
if (value === undefined || value === null) {
throw new Error(`Expected ${name} to be defined, got ${value}`);
}
}
const user = users.find((u) => u.email === "alice@test.com");
// user is User | undefined
assertDefined(user, "user");
console.log(user.email); // ✅ user is User — undefined ruled outThis single helper replaces three lines of "if (!x) throw" boilerplate at every find-call site. Once you've got assertDefined, you'll start using it on every API response, every array search, every test fixture lookup — anywhere T | undefined shows up.
Assertion flow
Step 1 of 4
Wide value arrives
data: unknown — could be anything. JSON.parse, an API response body, a fixture file. The type is opaque.
Building custom assertions for an API contract
Real test code calls assertions in clusters — verify the response is okay, verify a field exists, verify the field's type. Each is a one-line helper:
function assertStatusOk(response: { status: number }): asserts response is { status: 200 } {
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
}
function assertHasField<T, K extends string>(
obj: T,
key: K,
): asserts obj is T & Record<K, unknown> {
if (!(typeof obj === "object" && obj !== null && key in obj)) {
throw new Error(`Expected field "${key}" on ${JSON.stringify(obj)}`);
}
}
function assertDefined<T>(value: T | undefined | null, name: string): asserts value is T {
if (value === undefined || value === null) {
throw new Error(`Expected ${name} to be defined`);
}
}
// In a test:
const response = await api.get("/users/1");
assertStatusOk(response); // throws on non-200
assertHasField(response.body, "email"); // throws if no email field
assertDefined(response.body.email, "user.email"); // throws if email is null
console.log(response.body.email); // ✅ all narrowings stickThree runtime checks at the top of the test, followed by code that trusts the data. Compare to the alternative — if (!response) throw; if (response.status !== 200) throw; if (!response.body) throw; ... — and the readability win is obvious.
Where you'll see assertions in QA code
- Test fixtures.
assertDefined(fixture.users[0], "first user")at the top of every test prevents misleading downstream failures. - API contract validation.
assertHasField,assertStatusOk,assertIsArrayproduce surgical error messages instead of a generic "Cannot read property of undefined." - Type-safe wrappers around
expect. Some teams writeassertEqual<T>(actual: unknown, expected: T): asserts actual is Tso the rest of the test can useactualasTwithout re-checking. Most just stick with the framework's built-in matchers — but the option exists.
⚠️ Common mistakes
- Lying assertions.
function assertIsUser(v: unknown): asserts v is User { /* nothing */ }compiles. The compiler trusts you that the assertion holds. The test then runs against bad data and fails in confusing places. Always implement the runtime check; the compiler takes your word for it. - Forgetting that assertion functions need the explicit return-type annotation. TypeScript will not infer
asserts value is X— you must write it on the function signature. Without it, the function works at runtime but TypeScript doesn't narrow afterward. - Mixing up
isandasserts. Returning a boolean from anassertsfunction is a compile error; throwing inside avalue is Xfunction compiles but the caller still has to wrap withif. The simple rule: if the function throws, useasserts. If it returns true/false, useis.
🎯 Practice task
Build a small assertion library. 25-35 minutes.
- In your
ts-for-qa/srcfolder, createassertions.ts. - Implement these three helpers:
function assertDefined<T>(value: T | undefined | null, name: string): asserts value is Tfunction assertIsString(value: unknown): asserts value is stringfunction assertIsArray<T>(value: unknown): asserts value is T[](loose array check — accept any non-empty array of any element type)
- In a
mainfunction, fetch a fake API response (mock it with a hardcodedunknownvalue), parse it, and use the assertions to type the result. Confirm the rest of the function has full autocomplete on the narrowed value. - Run with
npx ts-node src/assertions.ts. - Trigger the runtime errors. Pass each helper a value of the wrong type and confirm a clear error message is thrown. Read the message and revert.
- Now write the lying-assertion mistake: declare
function assertIsUser(v: unknown): asserts v is User {}with an empty body. Use it on a non-User value. Watch the test compile and crash later when you try to read a property — that's the cost of dishonest assertions. - Stretch: write
assertHasField<T, K extends string>(obj: T, key: K): asserts obj is T & Record<K, unknown>. Use it to assert that a parsed JSON object has a specific field, then access the field. Confirm the compiler narrows correctly.
The chapter wraps up next with the part of TypeScript you'll mostly read rather than write — mapped and conditional types, the patterns that power the utility types you've already been using.