Type Predicates in TypeScript
Type predicates with the is keyword let you write custom type guards that tell TypeScript exactly what type a value has after a check. Unlike basic conditionals that only handle simple cases, these predicates can wrap complex validation logic in reusable functions that the compiler understands and trusts.
TypeScript’s built-in narrowing (like typeof and instanceof) works for simple checks but struggles with complex objects, API responses, or unions. In those cases, you often repeat validation logic or lose type information when values move between functions.
Type predicates fix this by returning a boolean and type information at the same time. When a predicate returns true, TypeScript narrows the value’s type in the calling code, so you can safely use its properties and methods without extra assertions or casts.
In this guide, you’ll see how type predicates work, how to build custom type guards with is, and how to validate complex object shapes safely. You’ll also learn how to combine multiple predicates to cleanly narrow union types in real-world code.
Prerequisites
To follow this guide, you'll need Node.js 18+:
node --version
v22.19.0
Setting up the project
Create and configure a fresh TypeScript project with ES module support:
mkdir ts-type-predicates && cd ts-type-predicates
Initialize the project and enable ES modules:
npm init -y && npm pkg set type="module"
Install the required development dependencies:
npm install -D typescript @types/node tsx
Generate a TypeScript configuration file:
npx tsc --init
This creates a tsconfig.json file and sets up a modern TypeScript environment. With this in place, you’re ready to experiment with type predicates and run code instantly using tsx.
Understanding the type narrowing problem
TypeScript's automatic type narrowing works only within the function where checks occur. When you validate a value's type and pass it to another function, TypeScript forgets what you verified. The receiving function sees the original broad type, forcing you to repeat validation or use unsafe type assertions.
This creates maintainability problems where validation logic scatters across your codebase. Each function that needs a specific type either duplicates checks or trusts that callers validated correctly, neither of which scales well.
Let's examine a scenario where type information is lost across function boundaries:
interface User {
name: string;
email: string;
age: number;
}
function isValidUser(data: unknown): boolean {
return (
typeof data === "object" &&
data !== null &&
"name" in data &&
"email" in data &&
"age" in data &&
typeof (data as any).name === "string" &&
typeof (data as any).email === "string" &&
typeof (data as any).age === "number"
);
}
function processUser(user: User) {
console.log(`Processing ${user.name}, age ${user.age}`);
console.log(`Email: ${user.email}`);
}
const apiData: unknown = {
name: "Alice",
email: "alice@example.com",
age: 30
};
if (isValidUser(apiData)) {
// TypeScript still sees apiData as unknown here!
processUser(apiData); // Error: unknown not assignable to User
}
Check what TypeScript reports:
npx tsc --noEmit src/problem.ts
src/problem.ts:33:15 - error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'User'.
Type '{}' is missing the following properties from type 'User': name, email, age
33 processUser(apiData); // Error: unknown not assignable to User
~~~~~~~
Found 1 error in src/problem.ts:33
The isValidUser function performs thorough validation, checking every property's existence and type. But TypeScript doesn't understand that a true return value means apiData is a valid User. The type remains unknown after the check, forcing you to either add a type assertion or repeat the validation.
This pattern appears constantly when working with API responses, user input, and external data sources. You validate data shape but TypeScript can't track that validation across function calls, leading to either unsafe assertions or redundant checks.
Solving the problem with type predicates
Type predicates extend boolean-returning functions with type information that TypeScript's compiler understands. By changing the return type from boolean to value is Type, you tell the compiler "when this function returns true, the value is definitely this type."
TypeScript then narrows the type automatically in conditional blocks where the predicate returns true. The type narrowing works exactly like built-in checks but with your custom validation logic encapsulated in a reusable function.
Let's fix the previous example with a type predicate:
interface User {
name: string;
email: string;
age: number;
}
function isValidUser(data: unknown): data is User {
return (
typeof data === "object" &&
data !== null &&
"name" in data &&
"email" in data &&
"age" in data &&
typeof (data as any).name === "string" &&
typeof (data as any).email === "string" &&
typeof (data as any).age === "number"
);
}
...
Verify TypeScript accepts this:
npx tsc --noEmit src/problem.ts
TypeScript compiles successfully now. The type predicate data is User tells the compiler that when isValidUser returns true, the data parameter is definitely a User. Inside the if block, TypeScript narrows apiData from unknown to User, allowing the processUser call without assertions.
Run the code to verify it works:
npx tsx src/problem.ts
Processing Alice, age 30
Email: alice@example.com
The type predicate combines runtime validation with compile-time type narrowing. Your validation logic runs at runtime to verify the actual data, while TypeScript uses the predicate's type information to enable safe property access without assertions.
How type predicates work
Type predicates create a contract between your runtime validation and TypeScript's type system. When you write value is Type, you promise the compiler that the function's boolean return value indicates whether value has the shape of Type.
TypeScript enforces this contract through control flow analysis. Inside conditional blocks where the predicate returns true, the compiler narrows the type. In blocks where it returns false or the check didn't occur, the original type remains.
The syntax parameter is Type only works for parameters of the function declaring the predicate. You cannot write predicates about variables outside the function scope. This ensures the predicate directly relates to a value the function validates:
function isString(value: unknown): value is string {
return typeof value === "string";
}
const data: unknown = "hello";
if (isString(data)) {
// TypeScript knows data is string here
console.log(data.toUpperCase());
}
Type predicates enable type narrowing across function boundaries, turning complex validation into reusable type-safe utilities. This eliminates repeated checks while maintaining full type safety through the compiler's flow analysis.
Building complex object validators
Type predicates shine when validating complex object shapes that require multiple property checks, nested object validation, and type-specific logic. Instead of scattering validation throughout your codebase, you centralize it in predicate functions that provide both runtime safety and compile-time type narrowing.
Complex validators often check not just property existence but also property types, value constraints, and relationships between fields. Type predicates let you encode all this validation logic once and reuse it everywhere with full type safety.
Let's build validators for a data structure with nested objects:
interface Address {
street: string;
city: string;
zipCode: string;
}
interface Customer {
id: string;
name: string;
email: string;
address: Address;
verified: boolean;
}
function isAddress(value: unknown): value is Address {
return (
typeof value === "object" &&
value !== null &&
"street" in value &&
"city" in value &&
"zipCode" in value &&
typeof (value as any).street === "string" &&
typeof (value as any).city === "string" &&
typeof (value as any).zipCode === "string"
);
}
function isCustomer(value: unknown): value is Customer {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
"email" in value &&
"address" in value &&
"verified" in value &&
typeof (value as any).id === "string" &&
typeof (value as any).name === "string" &&
typeof (value as any).email === "string" &&
isAddress((value as any).address) &&
typeof (value as any).verified === "boolean"
);
}
function displayCustomer(customer: Customer) {
console.log(`Customer: ${customer.name} (${customer.email})`);
console.log(`Address: ${customer.address.street}, ${customer.address.city}`);
console.log(`Verified: ${customer.verified}`);
}
const apiResponse: unknown = {
id: "C123",
name: "Bob Smith",
email: "bob@example.com",
address: {
street: "123 Main St",
city: "New York",
zipCode: "10001"
},
verified: true
};
if (isCustomer(apiResponse)) {
displayCustomer(apiResponse);
} else {
console.log("Invalid customer data");
}
// Test with invalid data
const invalidData: unknown = {
id: "C456",
name: "Charlie"
};
if (isCustomer(invalidData)) {
displayCustomer(invalidData);
} else {
console.log("Invalid customer data detected");
}
Run this to see nested validation:
npx tsx src/complex.ts
Customer: Bob Smith (bob@example.com)
Address: 123 Main St, New York
Verified: true
The isCustomer predicate calls isAddress for nested validation, composing type predicates to handle complex data structures. This approach scales to arbitrary nesting levels while maintaining type safety throughout. Each predicate focuses on one type, making validation logic modular and testable.
Discriminating union types with predicates
Discriminated unions represent values that can be one of several types, commonly used for API responses, state machines, and event systems. Type predicates excel at discriminating between union variants, enabling type-safe handling of each case without type assertions.
Each variant in a discriminated union typically has a unique property or property value that identifies it. Type predicates check these discriminators and narrow the union to specific variants, unlocking variant-specific properties for safe access.
Let's build a result type system with type predicates:
interface Success<T> {
status: "success";
data: T;
}
interface Failure {
status: "error";
error: string;
code: number;
}
type Result<T> = Success<T> | Failure;
function isSuccess<T>(result: Result<T>): result is Success<T> {
return result.status === "success";
}
function isFailure<T>(result: Result<T>): result is Failure {
return result.status === "error";
}
function handleUserResult(result: Result<{ name: string; email: string }>) {
if (isSuccess(result)) {
// TypeScript knows result is Success here
console.log("User loaded:", result.data.name);
console.log("Email:", result.data.email);
} else if (isFailure(result)) {
// TypeScript knows result is Failure here
console.log(`Error ${result.code}: ${result.error}`);
}
}
const successResult: Result<{ name: string; email: string }> = {
status: "success",
data: { name: "Alice", email: "alice@example.com" }
};
const errorResult: Result<{ name: string; email: string }> = {
status: "error",
error: "User not found",
code: 404
};
console.log("--- Success case ---");
handleUserResult(successResult);
console.log("\n--- Error case ---");
handleUserResult(errorResult);
Run this to see union discrimination:
npx tsx src/unions.ts
--- Success case ---
User loaded: Alice
Email: alice@example.com
--- Error case ---
Error 404: User not found
The type predicates isSuccess and isFailure discriminate the Result union by checking the status property. TypeScript narrows the type in each conditional branch, enabling safe access to variant-specific properties like data or error without assertions.
Generic type predicates like isSuccess<T> preserve type parameters through narrowing, maintaining full type information about the success data. This pattern works with any discriminated union, from simple two-variant types to complex multi-variant state machines.
Type predicates vs type assertions
Type predicates and type assertions both help TypeScript understand types, but they work fundamentally differently. Type assertions are compile-time annotations that bypass validation, while type predicates combine runtime checks with compile-time narrowing for genuine type safety.
Assertions tell TypeScript "trust me, this is the type" without verification. If you're wrong, the code compiles but crashes at runtime. Type predicates actually check the type at runtime and only narrow when checks pass, preventing the crashes assertions allow.
Here's how each approach handles type narrowing:
interface Product {
id: string;
name: string;
price: number;
}
function processWithAssertion(data: unknown) {
// Type assertion - no runtime safety
const product = data as Product;
console.log(`Product: ${product.name} costs $${product.price}`);
}
function isProduct(data: unknown): data is Product {
return (
typeof data === "object" &&
data !== null &&
"id" in data &&
"name" in data &&
"price" in data &&
typeof (data as any).id === "string" &&
typeof (data as any).name === "string" &&
typeof (data as any).price === "number"
);
}
function processWithPredicate(data: unknown) {
// Type predicate - runtime verification
if (isProduct(data)) {
console.log(`Product: ${data.name} costs $${data.price}`);
} else {
console.log("Invalid product data");
}
}
const validProduct = {
id: "P123",
name: "Laptop",
price: 999
};
const invalidProduct = {
id: "P456",
name: "Mouse"
// Missing price
};
console.log("--- Valid product with assertion ---");
processWithAssertion(validProduct);
console.log("\n--- Valid product with predicate ---");
processWithPredicate(validProduct);
console.log("\n--- Invalid product with assertion ---");
processWithAssertion(invalidProduct);
console.log("\n--- Invalid product with predicate ---");
processWithPredicate(invalidProduct);
Run this to see the safety difference:
npx tsx src/comparison.ts
--- Valid product with assertion ---
Product: Laptop costs $999
--- Valid product with predicate ---
Product: Laptop costs $999
--- Invalid product with assertion ---
Product: Mouse costs $undefined
--- Invalid product with predicate ---
Invalid product data
The assertion approach accepts invalid data because assertions skip runtime validation. TypeScript compiles the code trusting your assertion, but the missing price field causes wrong output. The predicate approach validates at runtime, detecting the invalid data and handling it safely without crashes or incorrect output.
Type predicates provide real type safety by combining validation with narrowing. Assertions only create the illusion of safety through compile-time trust without runtime verification.
Final thoughts
Type predicates turn runtime checks into compile-time type narrowing, connecting JavaScript’s dynamic behavior with TypeScript’s static type system. They let you write reusable validation functions that both verify values at runtime and teach the compiler what those checks mean.
With the is keyword, your validation logic defines how TypeScript narrows types, removing the need for repeated checks and unsafe assertions. This works for simple checks and also scales to deeply nested, complex object validation while keeping types fully safe.
Unlike type assertions, which only “pretend” something is safe, type predicates provide real safety by combining runtime validation with compile-time narrowing. This makes them ideal for working with external data, API responses, and any values whose runtime shape might not match what the types suggest.
To explore more, read the TypeScript handbook section on type predicates. It shows advanced patterns and how they work with discriminated unions, generics, and utility types to build strong, type-safe validation systems.