TypeScript `asserts` and Type Predicates in 2026: Writing Guards That Actually Narrow Correctly
Most TypeScript runtime validation fails because developers misunderstand when to use type predicates versus assertion functions. Learn the patterns that actually narrow types correctly in production codebases.
Most TypeScript runtime validation breaks down because engineers write guards that compile but don't actually narrow types where it matters. The pattern that teams overlook is the distinction between type predicates that return boolean values and assertion functions that throw on failure—and choosing the wrong one creates silent bugs that surface in production.
The problem starts when developers write a function like isUser(value: unknown): boolean and expect TypeScript to understand what that boolean means. The compiler sees the function return true but has no idea that value is now safe to treat as a User type. Code that looks validated crashes at runtime because the type system never learned what the validation actually proved.
flowchart LR
A("Runtime value") --> B("isUser returns true")
B --> C("value still typed as unknown")
C --> D("Accessing value.email crashes")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
The fix is adding the type predicate syntax value is User to the return signature. This tells TypeScript that when the function returns true, the narrowed type holds in the calling scope. For throwing guards that never return on failure, the asserts keyword encodes that guarantee into the signature itself.
flowchart LR
A("Runtime value") --> B("isUser(value): value is User returns true")
B --> C("value narrowed to User in scope")
C --> E("Accessing value.email is safe")
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
That distinction is critical. Type predicates return booleans and enable conditional narrowing. Assertion functions throw errors and narrow the remainder of the scope unconditionally. Mixing them up or using neither creates validation theater—code that runs checks but provides zero type safety.
Key Takeaways
- Type predicates (
value is Type) narrow types conditionally when the guard returnstrue, while assertion functions (asserts value is Type) narrow unconditionally by throwing on failure. - Most guard functions fail to narrow because they return
booleaninstead of using predicate syntax—the compiler cannot infer type information from a plain boolean. - Assertion functions are superior for null checks and invariants that should never fail, while type predicates fit user input validation where failure is expected.
- Generic guards with
value is Twork with utility types likeNonNullable<T>, enabling reusable patterns across unknown data structures. - Production API guards must validate structure depth-first (check parent existence before child properties) to avoid runtime crashes even when types appear narrowed.
Type Predicates: The Foundation of Runtime Type Narrowing
Type predicates are functions whose return type encodes a relationship between the input parameter and a specific type. When the function returns true, TypeScript narrows the parameter to that type in the scope where the check passed. When it returns false, the type remains unchanged or is narrowed to an exclusion.
interface User {
id: string;
email: string;
role: "admin" | "member";
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"email" in value &&
typeof (value as Record<string, unknown>).id === "string" &&
typeof (value as Record<string, unknown>).email === "string"
);
}
function processUserData(input: unknown) {
if (isUser(input)) {
// input is now typed as User
console.log(input.email.toLowerCase());
} else {
// input remains unknown
console.log("Invalid user data");
}
}The guard checks runtime properties one by one. The predicate syntax value is User tells the compiler that a true return guarantees the value matches the User shape. Without that syntax, the function would return boolean and TypeScript would learn nothing about the validated value.
flowchart TD
A("unknown input") --> B{"isUser check"}
B -->|true| C("input narrowed to User")
B -->|false| D("input remains unknown")
C --> E("Access typed properties safely")
D --> F("Handle invalid case")
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This matters because runtime data from APIs, user input, or localStorage arrives as unknown or any. Type predicates bridge the gap between compile-time types and runtime reality. They do not magically validate data—the function body must perform actual checks. The predicate merely communicates the result to the type system.
The function must return a boolean. If the implementation is wrong—if it returns true for values that don't match the type—the predicate creates a lie that the compiler believes. That lie surfaces as runtime crashes when the code accesses properties that don't exist.
Assertion Functions with asserts: When to Throw Instead of Return
Assertion functions use the asserts keyword to tell TypeScript that the function either throws an error or narrows the parameter's type for the rest of the scope. Unlike type predicates, they have no boolean return—if execution continues past the function call, the type is guaranteed.
function assertNonNull<T>(
value: T,
message: string
): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(message);
}
}
function getUserEmail(user: User | null): string {
assertNonNull(user, "User cannot be null");
// user is now typed as User (not User | null)
return user.email;
}The signature asserts value is NonNullable<T> means the function either throws or proves value is not null or undefined. After the assertion, the type system removes null and undefined from the union. The caller does not need an if statement—the assertion enforces the invariant unconditionally.
This pattern fits invariants that should never fail in correct code. If a user is null at a point where the application logic guarantees it exists, that indicates a bug upstream. The assertion makes the bug visible immediately instead of allowing it to propagate through the call stack.
function assertIsUser(value: unknown): asserts value is User {
if (
typeof value !== "object" ||
value === null ||
!("id" in value) ||
!("email" in value)
) {
throw new Error("Value is not a User");
}
}
function handleUserAction(data: unknown) {
assertIsUser(data);
// data is now typed as User
console.log(`Processing action for ${data.email}`);
}The assertion throws if the check fails. If it does not throw, TypeScript knows data is a User for the remainder of the function. The implication here is that the caller expects the data to be a User—if it is not, the application is in an invalid state and should fail fast.
Assertion functions work well for internal boundaries where types should already be correct. They are poor fits for user input validation where invalid data is expected and should be handled gracefully. For those cases, type predicates with conditional logic provide better control flow.
Common Mistakes: Why Your Guards Don't Narrow Correctly
The failure mode here is subtle but expensive. Developers write guards that perform runtime checks but forget the predicate syntax, resulting in functions that return boolean without teaching the type system anything. The compiler accepts the code, but the narrowing never happens.
// BROKEN: returns boolean, no narrowing
function isString(value: unknown): boolean {
return typeof value === "string";
}
function processValue(input: unknown) {
if (isString(input)) {
// input is still unknown, not string
console.log(input.toUpperCase()); // TypeScript error
}
}The guard checks the type at runtime, but the return type boolean does not encode the relationship. The compiler sees isString(input) as a boolean expression with no type implications. The fix is changing the signature to value is string.
flowchart LR
A("unknown input") --> B("Guard returns boolean")
B --> C("No type narrowing occurs")
C --> D("Runtime checks pass but types fail")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Another common mistake is checking properties without verifying the parent object is non-null. The guard might check "email" in value before ensuring value is an object, causing a runtime crash when value is a primitive.
// BROKEN: crashes if value is null or a primitive
function isUser(value: unknown): value is User {
return (
"id" in value && // throws if value is null/undefined
typeof value.email === "string"
);
}
// CORRECT: check object type first
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof (value as Record<string, unknown>).email === "string"
);
}The correct guard validates the type structure depth-first. It checks that value is an object and not null before using in or accessing properties. The assertion value as Record<string, unknown> is safe because the preceding checks guarantee value is an object.
flowchart LR
A("unknown value") --> B("Check typeof object and not null")
B --> C("Check properties with in operator")
C --> D("Type assertions are safe")
D --> E("Guard narrows correctly")
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style D stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
Teams also write assertion functions that do not throw, breaking the contract that asserts implies. If an assertion function returns normally after a failed check, the type system believes a lie and runtime crashes follow.
// BROKEN: does not throw, creates false narrowing
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
console.log("Not a string"); // should throw
}
}
// CORRECT: always throw on failure
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}Assertion functions must throw or process must exit. If they return after a failed check, the asserts keyword becomes a lie. The type system narrows the type based on the assumption that the function only returns when the assertion holds.
Type Predicates vs Assertion Functions: When to Use Each
The choice between predicates and assertions depends on whether failure is expected and how the code should handle it. Type predicates return boolean and enable conditional logic—use them when validation might fail and the application should branch. Assertion functions throw errors and narrow unconditionally—use them when failure indicates a bug or unrecoverable state.
flowchart LR
A("Validation point") --> B{"Is failure expected?"}
B -->|Yes, user input| C("Type predicate with boolean return")
B -->|No, internal invariant| D("Assertion function that throws")
C --> E("Handle valid and invalid cases separately")
D --> F("Narrow type unconditionally or crash")
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style F stroke:#7c9cf0,fill:#142544,color:#eaf2ff
For API responses where the data might not match the expected shape, type predicates let the code handle invalid responses gracefully. The predicate returns false, the condition branch executes, and the application logs an error or shows a message to the user.
async function fetchUser(id: string): Promise<User | null> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
if (isUser(data)) {
return data;
}
console.error("API returned invalid user data");
return null;
}For function arguments that should always be non-null because the caller guarantees it, assertions make the invariant explicit. If the assertion fails, the code throws immediately and the stack trace points to the violation.
function calculateDiscount(user: User | null, amount: number): number {
assertNonNull(user, "User is required for discount calculation");
if (user.role === "admin") {
return amount * 0.5;
}
return amount * 0.9;
}The assertion communicates that null is not a valid state. If the caller passes null, the function does not attempt to recover—it fails fast with a clear message. This distinction is critical in large codebases where silent failures create debugging nightmares.
Another key difference: type predicates work in if statements and ternaries, allowing inline narrowing. Assertions work at statement level and narrow the remainder of the function or block scope. Choose based on the control flow the validation requires.
// Predicate: conditional narrowing
function handleData(input: unknown) {
const message = isUser(input)
? `User: ${input.email}`
: "Invalid data";
console.log(message);
}
// Assertion: unconditional narrowing
function requireUser(input: unknown): User {
assertIsUser(input);
return input; // input is User after assertion
}Teams sometimes cargo-cult assertions because they look "strict," but overusing them in scenarios where predicates fit creates brittle code. If user input should be validated and rejected gracefully, throwing an error is the wrong pattern. The application should return an error response, not crash the process.
Advanced Patterns: Generic Guards, Discriminated Unions, and NonNullable Assertions
Generic type guards let a single function narrow multiple types by accepting a type parameter. This works with utility types like NonNullable<T> to create reusable validation logic that adapts to the input type.
function assertNonNull<T>(
value: T,
fieldName: string
): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(`${fieldName} cannot be null or undefined`);
}
}
interface Config {
apiKey: string | null;
endpoint: string | null;
}
function initializeAPI(config: Config) {
assertNonNull(config.apiKey, "apiKey");
assertNonNull(config.endpoint, "endpoint");
// config.apiKey and config.endpoint are now string (not string | null)
return fetch(config.endpoint, {
headers: { Authorization: config.apiKey },
});
}The generic parameter T captures the input type. The assertion asserts value is NonNullable<T> tells TypeScript to remove null and undefined from whatever type T is. This pattern works across different types without duplicating the null-check logic.
Discriminated unions benefit from type predicates that check the discriminant property. Once the discriminant is verified, TypeScript narrows the union to the specific variant.
interface SuccessResponse {
status: "success";
data: User;
}
interface ErrorResponse {
status: "error";
message: string;
}
type APIResponse = SuccessResponse | ErrorResponse;
function isSuccessResponse(
response: APIResponse
): response is SuccessResponse {
return response.status === "success";
}
function handleResponse(response: APIResponse) {
if (isSuccessResponse(response)) {
console.log(response.data.email); // response is SuccessResponse
} else {
console.error(response.message); // response is ErrorResponse
}
}The predicate checks the literal type of status. TypeScript knows that if status is "success", the union narrows to SuccessResponse. The check is minimal because the discriminant property defines the union structure.
For nested validation, guards can compose by calling other guards. This keeps each function focused on one level of the type structure.
interface Address {
street: string;
city: string;
}
interface UserWithAddress {
id: string;
email: string;
address: Address;
}
function isAddress(value: unknown): value is Address {
return (
typeof value === "object" &&
value !== null &&
"street" in value &&
"city" in value &&
typeof (value as Record<string, unknown>).street === "string" &&
typeof (value as Record<string, unknown>).city === "string"
);
}
function isUserWithAddress(value: unknown): value is UserWithAddress {
return (
isUser(value) &&
"address" in value &&
isAddress((value as { address: unknown }).address)
);
}The composed guard calls isUser to validate the base structure, then checks for the address property and validates it with isAddress. Each guard remains simple and testable. The type predicate on isUserWithAddress captures the full validation chain.
This composition pattern scales to complex nested types without requiring monolithic validation functions. Each guard validates one layer, and higher-level guards combine them. The implication here is that runtime validation should mirror the type structure itself.
Production-Ready Guard Patterns for APIs and Form Validation
Production APIs return data in shapes that drift from the TypeScript definitions over time. Guards that validate response structure catch breaking changes before they crash the frontend. The pattern is to write a guard for each API response type and use it in every fetch call.
interface PaginatedUsers {
users: User[];
total: number;
page: number;
}
function isPaginatedUsers(value: unknown): value is PaginatedUsers {
return (
typeof value === "object" &&
value !== null &&
"users" in value &&
"total" in value &&
"page" in value &&
Array.isArray((value as Record<string, unknown>).users) &&
(value as Record<string, unknown>).users.every(isUser) &&
typeof (value as Record<string, unknown>).total === "number" &&
typeof (value as Record<string, unknown>).page === "number"
);
}
async function fetchUsers(page: number): Promise<PaginatedUsers> {
const response = await fetch(`/api/users?page=${page}`);
const data = await response.json();
if (!isPaginatedUsers(data)) {
throw new Error("API returned invalid paginated users structure");
}
return data;
}The guard validates the array and calls isUser on each element. If any item fails, the guard returns false and the fetch function throws. This catches schema changes immediately instead of allowing partial data to reach components.
flowchart LR
A("API response received") --> B("Parse JSON to unknown")
B --> C("Run type guard validation")
C --> D{"All checks pass?"}
D -->|Yes| E("Return typed data")
D -->|No| F("Throw validation error")
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style F stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
Form validation combines predicates for field-level checks and assertions for submit-time invariants. Predicates validate individual fields as the user types, showing errors inline. Assertions enforce that required fields are present before submission.
interface FormData {
email: string;
password: string;
confirmPassword: string;
}
function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function isValidPassword(value: string): boolean {
return value.length >= 8;
}
function assertPasswordsMatch(
password: string,
confirm: string
): asserts confirm is string {
if (password !== confirm) {
throw new Error("Passwords do not match");
}
}
function handleSubmit(data: unknown) {
if (!isFormData(data)) {
throw new Error("Invalid form data");
}
if (!isValidEmail(data.email)) {
throw new Error("Invalid email format");
}
if (!isValidPassword(data.password)) {
throw new Error("Password must be at least 8 characters");
}
assertPasswordsMatch(data.password, data.confirmPassword);
// All validations passed, data is fully validated
submitToAPI(data);
}
function isFormData(value: unknown): value is FormData {
return (
typeof value === "object" &&
value !== null &&
"email" in value &&
"password" in value &&
"confirmPassword" in value &&
typeof (value as Record<string, unknown>).email === "string" &&
typeof (value as Record<string, unknown>).password === "string" &&
typeof (value as Record<string, unknown>).confirmPassword === "string"
);
}The isFormData predicate verifies the shape. The email and password predicates check format rules. The assertion enforces the password-match invariant—if it fails, the form should not submit. Each validation has a single responsibility and clear failure behavior.
For libraries or shared code, export guards alongside types so consumers can validate external data themselves. This creates a contract: the library provides both the type definition and the runtime validator.
// user.types.ts
export interface User {
id: string;
email: string;
role: "admin" | "member";
}
export function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"email" in value &&
"role" in value &&
typeof (value as Record<string, unknown>).id === "string" &&
typeof (value as Record<string, unknown>).email === "string" &&
((value as Record<string, unknown>).role === "admin" ||
(value as Record<string, unknown>).role === "member")
);
}Consumers import both the type and the guard. They use the type for static type checking and the guard for runtime validation. This prevents the common failure where a library exports types but no way to validate that external data matches them.
Frequently Asked Questions
When should I use a type predicate instead of an assertion function?
Use type predicates when validation failure is expected and the code should handle both valid and invalid cases with conditional logic, such as API responses or user input. Use assertion functions when failure indicates a programming error or unrecoverable state, such as null checks on values that should always exist at a given point in the call stack.
Why doesn't TypeScript narrow types automatically without predicates?
TypeScript cannot infer what a boolean return value means about the input parameter—returning true or false does not encode type information. The predicate syntax value is Type explicitly tells the compiler which type the parameter narrows to when the function returns true, enabling control flow analysis to update types in conditional branches.
Can type guards validate deeply nested objects without becoming unreadable?
Yes, by composing smaller guards that each validate one level of nesting. Write a guard for each nested type, then higher-level guards call the lower-level ones. This keeps each function focused and testable while allowing complex validation chains that mirror the type structure itself.
What happens if an assertion function does not throw on failure?
The type system believes the assertion and narrows the type anyway, creating a false guarantee. When the code later accesses properties that do not exist, it crashes at runtime. Assertion functions must always throw or terminate when the check fails—any other behavior violates the contract that asserts encodes.
Should I validate every API response with guards in production?
For external APIs or any data source outside your control, yes—guards catch breaking changes immediately instead of allowing invalid data to propagate. For internal APIs within a monorepo where types are shared and versioned together, the value is lower but guards still provide defense against deployment mismatches and runtime errors during migrations.
Conclusion: Writing Guards That Actually Protect Your Runtime
Type predicates and assertion functions give TypeScript the information it needs to narrow types based on runtime checks. Predicates enable conditional narrowing when validation might fail and the code should branch. Assertions enforce invariants that should never fail in correct code, making bugs visible immediately.
The distinction between returning a boolean and throwing an error maps directly to expected versus unexpected failures. User input validation demands predicates with graceful error handling. Internal null checks and schema assumptions fit assertions that fail fast and point to bugs. Choosing the wrong pattern creates code that compiles but crashes in production.
For related patterns on leveraging TypeScript's type system effectively, see 10 TypeScript Utility Types for Bulletproof Code. For handling large-scale validation in modern applications, 2 Million Token Context Windows in Real Web Apps covers architectural considerations. For integrating guards into automated refactoring workflows, AI-Powered TypeScript Refactoring Workflows demonstrates how to maintain type safety during migrations.
That covers the essential patterns for type guards and assertion functions in 2026. Apply these in production and the difference will be immediate—fewer runtime crashes, clearer error messages, and type safety that actually reflects what the code validates at runtime.