Zod — Runtime Type Validation for TypeScript
How to use Zod for schema validation in TypeScript — API inputs, form data, environment variables, and config files with complete type safety.
TypeScript checks types at compile time. That's useful until your app receives data from the outside world — API requests, form submissions, environment variables, JSON files, third-party APIs. None of that data has been type-checked. Your User type says email is a string, but the actual request body might have email: 42 or no email at all.
Zod bridges this gap. You define a schema, Zod validates the data at runtime, and then TypeScript infers the type from the schema. One schema, used for both validation and typing. No duplication. No drift.
The Basics
bun add zod
import { z } from "zod";
// Define a schema
const userSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().positive().max(150),
role: z.enum(["admin", "user", "moderator"]),
});
// Infer the TypeScript type from the schema
type User = z.infer<typeof userSchema>;
// type User = {
// name: string;
// email: string;
// age: number;
// role: "admin" | "user" | "moderator";
// }
// Validate data
const result = userSchema.safeParse({
name: "Alice",
email: "alice@example.com",
age: 30,
role: "admin",
});
if (result.success) {
console.log(result.data); // Typed as User
} else {
console.log(result.error.issues);
// [{ code: "...", message: "...", path: ["fieldName"] }]
}
The safeParse method never throws — it returns a discriminated union. Check result.success and TypeScript narrows the type. If you prefer exceptions, use parse:
try {
const user = userSchema.parse(untrustedData); // Throws ZodError on failure
// user is typed as User
} catch (e) {
if (e instanceof z.ZodError) {
console.log(e.issues);
}
}
Schema Types
Zod covers every data type you'll encounter:
// Primitives
z.string()
z.number()
z.boolean()
z.bigint()
z.date()
z.undefined()
z.null()
z.void()
// String validations
z.string().email()
z.string().url()
z.string().uuid()
z.string().regex(/^[a-z]+$/)
z.string().min(5)
z.string().max(100)
z.string().startsWith("https://")
z.string().includes("@")
z.string().trim()
z.string().toLowerCase()
// Number validations
z.number().int()
z.number().positive()
z.number().nonnegative()
z.number().min(0).max(100)
z.number().multipleOf(5)
z.number().finite()
// Arrays
z.array(z.string()) // string[]
z.array(z.number()).min(1) // At least one element
z.array(z.string()).max(10) // At most 10 elements
z.string().array() // Same as z.array(z.string())
// Tuples
z.tuple([z.string(), z.number()]) // [string, number]
// Objects
z.object({
name: z.string(),
nested: z.object({
value: z.number(),
}),
})
// Records (dictionaries)
z.record(z.string(), z.number()) // Record<string, number>
// Unions
z.union([z.string(), z.number()]) // string | number
z.string().or(z.number()) // Same thing
// Discriminated unions
z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), content: z.string() }),
z.object({ type: z.literal("image"), url: z.string().url() }),
z.object({ type: z.literal("video"), url: z.string().url(), duration: z.number() }),
])
// Enums
z.enum(["draft", "published", "archived"])
z.nativeEnum(MyTypescriptEnum)
// Literals
z.literal("active")
z.literal(42)
z.literal(true)
Object Manipulation
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
password: z.string(),
role: z.enum(["admin", "user"]),
createdAt: z.date(),
});
// Pick specific fields
const loginSchema = userSchema.pick({ email: true, password: true });
// { email: string; password: string }
// Omit fields
const publicUserSchema = userSchema.omit({ password: true });
// { id: number; name: string; email: string; role: ...; createdAt: Date }
// Make all fields optional
const updateUserSchema = userSchema.partial();
// All fields are optional
// Make specific fields optional
const createUserSchema = userSchema.partial({ id: true, createdAt: true });
// id and createdAt are optional, rest required
// Extend with additional fields
const adminSchema = userSchema.extend({
permissions: z.array(z.string()),
lastLogin: z.date().optional(),
});
// Merge two schemas
const merged = schemaA.merge(schemaB);
Transforms and Coercion
Zod can transform data as it validates — parsing strings into numbers, trimming whitespace, normalizing formats.
// Coercion — parse strings into proper types (great for query params)
const querySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
active: z.coerce.boolean().default(true),
});
// "?page=3&limit=50&active=false" → { page: 3, limit: 50, active: false }
querySchema.parse({ page: "3", limit: "50", active: "false" });
// Custom transforms
const slugSchema = z.string().transform((val) =>
val.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")
);
slugSchema.parse("Hello World!"); // "hello-world"
// Transform with validation
const monetarySchema = z.string()
.regex(/^\d+\.\d{2}$/, "Must be in format '10.99'")
.transform((val) => parseFloat(val));
// Preprocess — run a function BEFORE validation
const numberFromString = z.preprocess(
(val) => (typeof val === "string" ? parseInt(val, 10) : val),
z.number().int().positive()
);
Practical Patterns
API Request Validation
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(5).default([]),
publishAt: z.string().datetime().optional(),
});
type CreatePostInput = z.infer<typeof createPostSchema>;
app.post(
"/api/posts",
zValidator("json", createPostSchema),
async (c) => {
const data = c.req.valid("json"); // Typed as CreatePostInput
// data is guaranteed to be valid here
}
);
Environment Variables
// env.ts — validate your env vars at startup, crash early if misconfigured
const envSchema = z.object({
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(20),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
REDIS_URL: z.string().url().optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
export const env = envSchema.parse(process.env);
// Now env.DATABASE_URL is typed as string (not string | undefined)
// If DATABASE_URL is missing, the app crashes at startup with a clear error
Form Validation (React)
import { z } from "zod";
const signupSchema = z.object({
username: z.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be under 20 characters")
.regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores"),
email: z.string().email("Invalid email address"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain an uppercase letter")
.regex(/[0-9]/, "Must contain a number"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
// Use with react-hook-form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
type SignupForm = z.infer<typeof signupSchema>;
function SignupPage() {
const { register, handleSubmit, formState: { errors } } = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
});
// errors.username?.message, errors.email?.message, etc.
}
API Response Validation
Don't just trust API responses. Validate them.
const githubUserSchema = z.object({
login: z.string(),
id: z.number(),
avatar_url: z.string().url(),
name: z.string().nullable(),
public_repos: z.number(),
});
type GitHubUser = z.infer<typeof githubUserSchema>;
async function getGitHubUser(username: string): Promise<GitHubUser> {
const response = await fetch(https://api.github.com/users/${username});
const data = await response.json();
return githubUserSchema.parse(data); // Validates and types the response
}
Zod vs Alternatives
| Feature | Zod | Yup | Joi | io-ts | Valibot |
|---|---|---|---|---|---|
| TypeScript-first | Yes | Partial | No | Yes | Yes |
| Type inference | Excellent | Limited | Manual | Excellent | Excellent |
| Bundle size | ~13KB | ~15KB | ~30KB | ~8KB | ~1KB per validator |
| Runtime | Browser + Node | Browser + Node | Node focused | Browser + Node | Browser + Node |
| Ecosystem | Growing fast | Mature | Mature | Niche | Growing |
| Learning curve | Low | Low | Medium | High | Low |
Zod has become infrastructure. It's in tRPC, Hono, React Hook Form, Next.js server actions, Astro content schemas, and dozens of other tools. Learning it well pays dividends across your entire TypeScript stack. For more TypeScript tooling guides, check out CodeUp.