Type guard and assertion function in TypeScript
1. Overview
TypeScript narrows types well with typeof, instanceof, or ===. When the checking logic gets more complex and you extract it into a separate function, the compiler no longer “sees” the result of that check.
This post introduces two tools to solve that problem: type guard (value is T) and assertion function (asserts value is T). After reading, you’ll know how to write them, validate unknown data from APIs, test them, and spot cases where they silently lie to the compiler.
2. Built-in narrowing and its limitations
We’ll use a simple order domain as our running example:
type PendingOrder = { status: "pending"; id: string; total: number };
type PaidOrder = { status: "paid"; id: string; total: number; paidAt: Date };
type CancelledOrder = { status: "cancelled"; id: string; reason: string };
type Order = PendingOrder | PaidOrder | CancelledOrder;
function describe(order: Order): string {
if (order.status === "paid") {
return `Order ${order.id} paid at ${order.paidAt.toISOString()}`;
}
return `Order ${order.id} is in status ${order.status}`;
}
Order is a discriminated union with status as the discriminator. Inside the if branch, TypeScript knows order is PaidOrder, so accessing paidAt is valid.
Now you want to reuse that condition by extracting it into a function:
function isPaid(order: Order): boolean {
return order.status === "paid";
}
function describe(order: Order): string {
if (isPaid(order)) {
return `Paid at ${order.paidAt.toISOString()}`;
// ^^^^^^ error
}
return "Not paid";
}
error TS2339: Property 'paidAt' does not exist on type 'Order'.
Property 'paidAt' does not exist on type 'PendingOrder'.
The function isPaid returns boolean. The boolean type carries no information about order, so the compiler can’t narrow the type. Type guard exists precisely to solve this.
3. User-defined type guard
3.1. The value is T syntax
You just need to change the return type from boolean to a type predicate:
function isPaidOrder(order: Order): order is PaidOrder {
return order.status === "paid";
}
function describe(order: Order): string {
if (isPaidOrder(order)) {
return `Paid at ${order.paidAt.toISOString()}`;
}
// here order: PendingOrder | CancelledOrder
return `Status: ${order.status}`;
}
console.log(describe({ status: "paid", id: "A1", total: 120, paidAt: new Date("2026-10-01") }));
console.log(describe({ status: "cancelled", id: "A2", reason: "Out of stock" }));
Paid at 2026-10-01T00:00:00.000Z
Status: cancelled
Explanation:
order is PaidOrdermeans: if the function returnstrue, thenorderhas typePaidOrder.- At runtime, the function still only returns
true/false. Theispart exists only at compile time. - In the
falsebranch, TypeScript removesPaidOrderfrom the union.orderbecomesPendingOrder | CancelledOrder. - The parameter name in the predicate (
order) must match the name of a parameter in the function.
3.2. Filtering arrays with type guard
Type guard is especially useful with Array.prototype.filter, since filter has an overload that accepts a predicate:
const orders: Order[] = [
{ status: "pending", id: "A1", total: 50 },
{ status: "paid", id: "A2", total: 120, paidAt: new Date() },
{ status: "paid", id: "A3", total: 80, paidAt: new Date() },
];
const paidOrders = orders.filter(isPaidOrder); // PaidOrder[]
const revenue = paidOrders.reduce((sum, o) => sum + o.total, 0);
console.log(revenue);
200
paidOrders has type PaidOrder[], not Order[]. As a result, any subsequent operation can access paidAt without type casting.
Starting from TypeScript 5.5, the compiler automatically infers the type predicate for simple arrow functions. For example, orders.filter(o => o.status === "paid") also returns PaidOrder[]. This inference only works when the expression is simple enough and the “true ⇔ belongs to type” relationship is exact. For more complex logic, you still need to write an explicit type guard.
3.3. Checking unknown data
The most practical use case for type guard is validating external data, such as JSON from an API:
interface Customer {
id: string;
name: string;
email: string;
}
function isCustomer(value: unknown): value is Customer {
return (
typeof value === "object" &&
value !== null &&
"id" in value && typeof value.id === "string" &&
"name" in value && typeof value.name === "string" &&
"email" in value && typeof value.email === "string"
);
}
const raw: unknown = JSON.parse('{"id":"C1","name":"Lan","email":"lan@example.com"}');
if (isCustomer(raw)) {
console.log(raw.email.toUpperCase());
}
LAN@EXAMPLE.COM
Starting from TypeScript 4.9, the in operator narrows value to object & Record<"id", unknown>. This allows you to write value.id without needing as any. Each field is checked for both existence and type.
3.4. Trap: type guard lying
The compiler trusts completely the type predicate. It doesn’t check whether the function body actually matches the promise:
function isCustomerBroken(value: unknown): value is Customer {
return typeof value === "object" && value !== null; // forgot to check the fields
}
const data: unknown = { id: 1 };
if (isCustomerBroken(data)) {
console.log(data.email.toUpperCase()); // compile OK, runtime error
}
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
There’s a more subtle trap in the false branch:
function isLargeAmount(value: string | number): value is number {
return typeof value === "number" && value > 1000;
}
function format(value: string | number) {
if (isLargeAmount(value)) return `Large: ${value}`;
return value.toUpperCase(); // TS thinks value: string
}
format(5); // TypeError: value.toUpperCase is not a function
The function returns false for the number 5. TypeScript then infers that in the remaining branch value is not a number. This is wrong.
Note: A type predicate is a bidirectional relationship. Returning
truemust mean “belongs to type T”. Returningfalsemust mean “doesn’t belong to type T”. Don’t embed extra business logic (like> 1000) in a type guard. Separate them into a distinctbooleanfunction.
4. Assertion function
4.1. asserts value is T
Type guard forces you to wrap code in if. Sometimes you want behavior like “if wrong, throw an error, otherwise keep running”. That’s what assertion function does:
function assertIsCustomer(value: unknown): asserts value is Customer {
if (!isCustomer(value)) {
throw new TypeError("Data is not a valid Customer");
}
}
function greet(payload: unknown): string {
assertIsCustomer(payload);
// from this line onwards, payload: Customer
return `Hello ${payload.name}`;
}
console.log(greet({ id: "C1", name: "Minh", email: "minh@example.com" }));
console.log(greet({ id: "C2" }));
Hello Minh
TypeError: Data is not a valid Customer
Differences from type guard:
- The return type is
asserts value is Customer. The function body doesn’treturnany value, meaning the function returnsvoid. - The function throws an error when the condition is false. If the function returns normally, TypeScript assumes the condition was true.
- The type is narrowed for all code following the function call, not just within an
ifblock.
4.2. asserts condition
The second form is not tied to a specific type. It asserts that an expression is truthy, similar to Node.js’s assert:
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function applyDiscount(order: Order, percent: number): number {
assert(order.status !== "cancelled", `Order ${order.id} is cancelled`);
// order: PendingOrder | PaidOrder → has total
assert(percent > 0 && percent <= 50, "Discount must be in (0, 50]");
return order.total * (1 - percent / 100);
}
console.log(applyDiscount({ status: "pending", id: "A1", total: 200 }, 10));
180
After the line assert(order.status !== "cancelled", ...), TypeScript narrows order as if you had written if. Therefore order.total is valid, even though CancelledOrder doesn’t have a total field.
4.3. Error TS2775: missing explicit type annotation
This is the most common error when writing assertion functions as arrow functions:
const assertPositive = (n: number): asserts n => {
if (n <= 0) throw new Error("Must be positive");
};
assertPositive(5);
error TS2775: Assertions require every name in the call target
to be declared with an explicit type annotation.
The compiler needs to know the type of assertPositive at the point of declaration, not based on inference. You have two ways to fix this. The first is to use function declaration. The second is to give the variable a type:
type AssertPositive = (n: number) => asserts n;
const assertPositive: AssertPositive = (n) => {
if (n <= 0) throw new Error("Must be positive");
};
This rule also applies to methods called through an object, for example validator.assertCustomer(x). The variable validator must have an explicitly declared type, such as const validator: CustomerValidator = new CustomerValidator().
Note: Assertion function cannot be
async. The typePromise<asserts x is T>doesn’t exist. If you need async validation,awaitto fetch the data first, then call the synchronous assertion function on the result.
5. Writing tests for type guard and assertion function
This is runtime logic, so you need to test it like any other function. The example below uses Vitest:
npm install -D vitest typescript
// customer.test.ts
import { describe, it, expect } from "vitest";
import { isCustomer, assertIsCustomer } from "./customer";
describe("isCustomer", () => {
it("returns true for valid object", () => {
expect(isCustomer({ id: "C1", name: "Lan", email: "lan@example.com" })).toBe(true);
});
it.each([
null,
"C1",
{ id: "C1", name: "Lan" },
{ id: 1, name: "Lan", email: "lan@example.com" },
])("returns false for %j", (input) => {
expect(isCustomer(input)).toBe(false);
});
});
describe("assertIsCustomer", () => {
it("throws TypeError when data is invalid", () => {
expect(() => assertIsCustomer({ id: "C1" })).toThrow(TypeError);
});
it("narrows type when data is valid", () => {
const payload: unknown = { id: "C1", name: "Lan", email: "lan@example.com" };
assertIsCustomer(payload);
expect(payload.email).toBe("lan@example.com"); // compiles thanks to narrowing
});
});
npx vitest run
✓ customer.test.ts (7 tests)
Test Files 1 passed (1)
Tests 7 passed (7)
Pay attention to negative cases: missing fields, wrong types, null, primitive values. Most type guard bugs occur when it returns true when it should return false, like the example in section 3.4. The last test also serves as a compile-time test. If someone changes the function signature to void, the test file won’t compile.
6. Choosing type guard or assertion function?
| Scenario | Use |
|---|---|
| Invalid data is normal, need to branch handling | Type guard |
Filtering arrays, find, combining with filter |
Type guard |
| Invalid data is a programming error or contract violation, should stop immediately | Assertion function |
Check function precondition, avoid nested if |
Assertion function |
The two tools often work together. You write the type guard as the core, then wrap it in an assertion function, like how assertIsCustomer calls isCustomer. This way, the validation logic lives in one place.
When NOT to write your own:
- Large or deeply nested schemas. Writing
isCustomerby hand for an object with 30 fields and nested arrays is easy to miss. A validation library like Zod or Valibot generates types and checks from the same schema, so they can’t diverge. - As a replacement for
as. A type guard with an empty function body (return true) is no safer thanvalue as Customer. It just hides the problem more elegantly. - Simple discriminated unions. If
if (order.status === "paid")is already clear enough, don’t extract it into a function just for the sake of it.
7. Conclusion
Type guard (value is T) lets you encapsulate type-checking logic in a function while still preserving narrowing at the call site. It works especially well with if and filter. Assertion function (asserts value is T or asserts condition) narrows types for all code following the call by throwing an error when the condition is false. It’s suited for checking preconditions and validating input data.
Both are promises the compiler doesn’t verify. So remember three things:
- Maintain the “true ⇔ belongs to type” relationship.
- Declare types explicitly for assertion functions.
- Test negative cases thoroughly.