Zod And Validation
TypeScript's type system disappears at runtime. A variable typed as string might arrive from an API as null. A number field might be a string that happens to look numeric. TypeScript trusts you — but data from the outside world deserves no trust at all.
Zod is a TypeScript-first schema validation library that bridges this gap. You define a schema once, use it to validate and parse data at runtime, and get TypeScript types inferred automatically — no duplication, no drift between your types and your validation logic.
Why Runtime Validation Matters
Consider this common mistake:
// TypeScript is happy — but this blows up at runtime
interface User {
id: number;
name: string;
email: string;
}
const rawData = JSON.parse('{"id": "not-a-number", "name": null}');
const user = rawData as User;
// TypeScript thinks this is fine...
console.log(user.id.toFixed(2)); // TypeError at runtime!
console.log(user.name.toUpperCase()); // TypeError at runtime!
The as cast tells TypeScript to trust you — but the data doesn't match. This is where Zod comes in: it validates the shape and types of your data before you use it.
Defining Schemas and Parsing Data
A Zod schema describes the structure and types your data must match. The .parse() method throws if validation fails; .safeParse() returns a result object instead.
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0).max(120).optional(),
});
// Successful parse
const goodData = { id: 1, name: "Alice", email: "alice@example.com" };
const user = UserSchema.parse(goodData);
console.log("Parsed user:", user.name, user.email);
// Safe parse — no exception on failure
const badData = { id: "oops", name: "", email: "not-an-email" };
const result = UserSchema.safeParse(badData);
if (!result.success) {
result.error.issues.forEach((issue) => {
console.log(`${issue.path.join(".")}: ${issue.message}`);
});
} else {
console.log("Valid:", result.data);
}
Zod primitives like z.string(), z.number(), and z.boolean() can be chained with validators: .min(), .max(), .email(), .url(), .int(), and many more. Each returns a new schema, making them composable.
Inferring TypeScript Types
One of Zod's best features: you define the schema once and derive the TypeScript type from it automatically using z.infer. No more keeping an interface and a validator in sync.
import { z } from "zod";
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
price: z.number().positive(),
category: z.enum(["electronics", "clothing", "food"]),
tags: z.array(z.string()).default([]),
createdAt: z.string().datetime(),
});
// Infer the TypeScript type directly from the schema
type Product = z.infer<typeof ProductSchema>;
function displayProduct(product: Product): void {
console.log(`${product.name} — $${product.price.toFixed(2)}`);
console.log(`Category: ${product.category}`);
console.log(`Tags: ${product.tags.join(", ") || "none"}`);
}
const raw = {
id: "123e4567-e89b-12d3-a456-426614174000",
name: "Wireless Headphones",
price: 79.99,
category: "electronics",
createdAt: "2024-01-15T10:30:00Z",
};
const product = ProductSchema.parse(raw);
displayProduct(product);
Notice tags has .default([]) — Zod fills in the default when the field is absent, and the inferred type reflects this correctly as string[] rather than string[] | undefined.
Transformations and Refinements
Schemas can transform data during parsing, and refinements add custom validation logic that Zod's built-in methods don't cover.
import { z } from "zod";
// Transform: coerce and clean input data
const SearchParamsSchema = z.object({
query: z.string().trim().toLowerCase(),
page: z
.string()
.transform((val) => parseInt(val, 10))
.pipe(z.number().int().positive()),
limit: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : 20))
.pipe(z.number().int().min(1).max(100)),
});
const raw = { query: " TypeScript ", page: "3", limit: "50" };
const params = SearchParamsSchema.parse(raw);
console.log(params);
// { query: "typescript", page: 3, limit: 50 }
// Refinement: custom business logic validation
const PasswordSchema = z
.string()
.min(8, "Must be at least 8 characters")
.refine((val) => /[A-Z]/.test(val), "Must contain an uppercase letter")
.refine((val) => /[0-9]/.test(val), "Must contain a number")
.refine((val) => /[^A-Za-z0-9]/.test(val), "Must contain a special character");
const passwords = ["short", "alllowercase1!", "NoNumbers!", "Valid1@Pass"];
passwords.forEach((pwd) => {
const result = PasswordSchema.safeParse(pwd);
if (result.success) {
console.log(`"${pwd}" — valid`);
} else {
console.log(`"${pwd}" — ${result.error.issues[0].message}`);
}
});
Transformations run during parsing, converting raw input into the shape your application actually needs. This is particularly useful when handling query strings, form data, or API responses where numbers arrive as strings.
Composing Schemas
Real data structures are nested. Zod schemas compose naturally — any schema can be used as a field inside another.
import { z } from "zod";
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string().length(2, "Use ISO 3166-1 alpha-2 country codes"),
postalCode: z.string().regex(/^\d{4,10}$/, "Invalid postal code"),
});
const OrderSchema = z.object({
orderId: z.string(),
customer: z.object({
name: z.string(),
email: z.string().email(),
}),
shippingAddress: AddressSchema,
items: z
.array(
z.object({
productId: z.string(),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
})
)
.min(1, "Order must have at least one item"),
total: z.number().positive(),
});
type Order = z.infer<typeof OrderSchema>;
const order = OrderSchema.safeParse({
orderId: "ORD-001",
customer: { name: "Bob", email: "bob@example.com" },
shippingAddress: {
street: "123 Main St",
city: "Amsterdam",
country: "NL",
postalCode: "1011",
},
items: [{ productId: "P-42", quantity: 2, unitPrice: 29.99 }],
total: 59.98,
});
if (order.success) {
const { customer, items, total } = order.data;
console.log(`Order for ${customer.name}: ${items.length} item(s), €${total}`);
}
Reusing AddressSchema in multiple places means validation logic is defined once and enforced everywhere it's referenced.
Try It Yourself
Build a schema for a blog post API response. Parse a raw object and log the result. Try breaking the input to see Zod's error messages.
import { z } from "zod";
const AuthorSchema = z.object({
username: z.string().min(3).max(30),
avatarUrl: z.string().url().optional(),
});
const PostSchema = z.object({
id: z.number().int().positive(),
title: z.string().min(5).max(200),
body: z.string().min(10),
author: AuthorSchema,
tags: z.array(z.string()).max(5, "Too many tags"),
publishedAt: z.string().datetime().nullable(),
viewCount: z.number().int().min(0).default(0),
});
type Post = z.infer<typeof Post>;
// Try modifying this data to trigger validation errors
const rawPost = {
id: 101,
title: "Getting Started with Zod",
body: "Zod makes runtime validation in TypeScript a breeze.",
author: {
username: "alice_dev",
avatarUrl: "https://example.com/avatar.png",
},
tags: ["typescript", "validation", "zod"],
publishedAt: "2024-06-01T09:00:00Z",
};
const result = PostSchema.safeParse(rawPost);
if (result.success) {
const post = result.data;
console.log(`"${post.title}" by @${post.author.username}`);
console.log(`Tags: ${post.tags.join(", ")}`);
console.log(`Views: ${post.viewCount}`);
console.log(`Published: ${post.publishedAt ?? "Draft"}`);
} else {
console.log("Validation errors:");
result.error.issues.forEach((issue) => {
console.log(` - ${issue.path.join(".")}: ${issue.message}`);
});
}
Key Takeaways
- TypeScript types are erased at runtime — Zod validates actual data shapes when it matters
z.parse()throws on invalid data;z.safeParse()returns a typed result object, safer for user-facing inputs- Use
z.infer<typeof Schema>to derive TypeScript types from schemas, eliminating duplication - Zod validators chain:
.string().email().min(5)reads like a sentence and composes cleanly .transform()converts raw input (strings from query params, form fields) into the types your app needs.refine()adds custom business logic that built-in validators can't express- Schemas are composable — build complex structures from small, reusable schema pieces
.default()provides fallback values and removes| undefinedfrom the inferred type
Pro Tip: Place your Zod schemas in a dedicated
schemas/orvalidators/directory and export both the schema and its inferred type from the same file —export { UserSchema }andexport type { User }. This single source of truth means your runtime validation and compile-time types can never drift apart, and every part of your codebase imports from one authoritative location.
Next Steps
Zod uses z.infer to derive types from schemas — but how does that actually work under the hood? The answer is mapped types. Next, you'll learn how to iterate over type keys, add or remove modifiers, remap keys, and build your own type transformations.
Next lesson
Mapped Types
Learn TypeScript mapped types to transform, filter, and reshape types programmatically. Build your own Partial, Required, and Readonly.
25 min