You have learned the basics of TypeScript- interfaces, types, unions, and generics. Your code compiles cleanly, and your editor feels like magic.
Then, reality hits. You fetch data from an external API, read from localStorage, or handle user input. To TypeScript, all of that external data is any or unknown. The type checker has no idea what shape the data actually has. If you try to access response.data.user.id, TypeScript panics—and rightly so.
How do you safely bridge the gap between runtime uncertainty and compile-time safety? Type Guards.
1. What is a Type Guard?
At its core, a type guard is some expression or function that performs a runtime check and guarantees the type of a variable within a specific scope.
TypeScript provides built-in type guards out of the box:
typeof x === "string"x instanceof Date"prop" in obj
When TypeScript sees these checks inside an if block, it automatically narrows the type. But what happens when you have a custom business object, like a User, an Admin, or a nested API payload? Built-in guards aren't enough. That’s where user-defined (custom) type guards come in.
2. Writing Custom Type Guards: From Basic to Complex
A custom type guard is just a regular function with a very special return type: a type predicate using the is keyword (e.g., parameter is Type).
Level 1: The Basic Primitive Guard
Let’s start simple. Imagine you receive a value that could be a string or a number, but you want to ensure it's a specific format (like a non-empty trimmed string).
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function processInput(input: unknown) {
if (isNonEmptyString(input)) {
// TypeScript now knows `input` is strictly a `string` here!
console.log(input.toUpperCase());
} else {
console.log("Invalid or empty input.");
}
}
-
The Magic Word:
value is stringis the type predicate. If the function returnstrue, TypeScript treatsvalueas astringfor the remainder of that scope.
Level 2: The Object Shape Guard
Real-world applications deal with objects. Let's say you have a User interface and want to verify that an incoming unknown object matches it.
interface User {
id: number;
name: string;
email?: string; // Optional field
}
function isUser(candidate: unknown): candidate is User {
return (
typeof candidate === "object" &&
candidate !== null &&
"id" in candidate &&
typeof (candidate as User).id === "number" &&
"name" in candidate &&
typeof (candidate as User).name === "string"
);
}
// Usage in an API response handler
function handleApiResponse(response: unknown) {
if (isUser(response)) {
// Safe to access user properties!
console.log(`Welcome back, ${response.name} (ID: ${response.id})`);
} else {
throw new Error("Malformed user payload!");
}
}
-
Key takeaway: Notice how we first ensure
candidateis anobjectand notnull, check for property existence within, and then validate individual primitive types.
Level 3: Complex & Nested Guards (Arrays & Unions)
As your applications grow, you might need to validate arrays of objects or complex union types. Here is how you can write a guard that validates an array of User objects:
function isUserArray(data: unknown): data is User[] {
// First, check if it's an array
if (!Array.isArray(data)) {
return false;
}
// Every item in the array must pass our isUser guard
return data.every(item => isUser(item));
}
// Usage
function renderUserList(payload: unknown) {
if (isUserArray(payload)) {
payload.forEach(user => console.log(user.name));
}
}
3. When Custom Guards Get Heavy: Use a Library
Writing manual type guards for dozens of complex, deeply nested models can become repetitive and error-prone. If you misspell a property name or forget a check in your manual guard, runtime bugs can still slip through.
When your application scales up and managing manual type guards feels like writing boilerplate, reach for a runtime validation library like Zod.
Meet Zod: Schema Declaration and Validation
Zod allows you to define schemas once, and it automatically generates both the TypeScript type and the runtime validation logic for you.
import { z } from "zod";
// 1. Define the schema
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email().optional(),
});
// 2. Automatically infer the TypeScript type!
type User = z.infer<typeof UserSchema>;
// 3. Validate at runtime with total safety
function handleApiResponse(response: unknown) {
const result = UserSchema.safeParse(response);
if (result.success) {
// result.data is fully typed as User!
console.log(result.data.name);
} else {
console.error("Validation failed:", result.error.format());
}
}
Summary
-
Use manual type guards (
value is Type) when you want zero external dependencies, lightweight checks, or when learning the mechanics of runtime narrowing. - Use Zod when your project grows complex, involves deep nesting, or requires strict parsing of untrusted payloads from APIs and forms.
Top comments (0)