Why Zod Is Needed
TypeScript types only exist at compile time. Once the build is complete, no type information remains in JavaScript. Data coming from external sources — user input, API responses, environment variables — must be validated at runtime.
Zod is a runtime validation library that automatically infers TypeScript types from schema definitions. A single schema handles both validation and type definition simultaneously.
Installation and Basic Schemas
npm install zod
Zod schemas are defined using chaining methods like z.object(), z.string(), and z.number().
import { z } from "zod";
// Basic schema definition
const UserSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email format"),
age: z.number().int().min(0).max(150),
role: z.enum(["admin", "user", "guest"]),
bio: z.string().optional(), // Optional field
});
// Automatic TypeScript type inference from the schema
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number; role: "admin" | "user" | "guest"; bio?: string }
// Valid data — success
const validData = {
name: "Alice",
email: "alice@example.com",
age: 30,
role: "admin" as const,
};
const result = UserSchema.safeParse(validData);
if (result.success) {
console.log("Validation passed:", result.data);
// Validation passed: { name: "Alice", email: "alice@example.com", age: 30, role: "admin" }
} else {
console.log("Validation failed:", result.error.issues);
}
// Invalid data — failure
const invalidData = {
name: "A", // Less than 2 characters
email: "not-email", // Not email format
age: -5, // Negative
role: "superuser", // Not in enum
};
const failResult = UserSchema.safeParse(invalidData);
if (!failResult.success) {
failResult.error.issues.forEach((issue) => {
console.log(`${issue.path.join(".")}: ${issue.message}`);
});
// name: Name must be at least 2 characters
// email: Invalid email format
// age: Number must be greater than or equal to 0
// role: Invalid enum value. Expected 'admin' | 'user' | 'guest', received 'superuser'
}
safeParse returns a result object without throwing exceptions. parse throws a ZodError on failure. safeParse is recommended for external input.
Advanced Schema Patterns
Zod provides various advanced features including transforms, refinements, and composition.
import { z } from "zod";
// transform: convert values after validation
const TrimmedString = z.string().trim().toLowerCase();
console.log(TrimmedString.parse(" Hello World ")); // "hello world"
// coerce: force-convert input before validation
const CoercedNumber = z.coerce.number();
console.log(CoercedNumber.parse("42")); // 42 (string -> number)
const CoercedDate = z.coerce.date();
console.log(CoercedDate.parse("2026-01-15")); // Date object
// refine: custom validation logic
const PasswordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.refine((val) => /[A-Z]/.test(val), {
message: "Must contain at least 1 uppercase letter",
})
.refine((val) => /[0-9]/.test(val), {
message: "Must contain at least 1 number",
})
.refine((val) => /[!@#$%^&*]/.test(val), {
message: "Must contain at least 1 special character",
});
// superRefine: cross-field validation
const SignupSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["confirmPassword"],
});
}
});
// discriminatedUnion: tag-based union type
const EventSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("click"),
x: z.number(),
y: z.number(),
}),
z.object({
type: z.literal("keypress"),
key: z.string(),
ctrlKey: z.boolean(),
}),
z.object({
type: z.literal("scroll"),
deltaY: z.number(),
}),
]);
type AppEvent = z.infer<typeof EventSchema>;
// Different fields are automatically inferred based on the type
const clickEvent = EventSchema.parse({ type: "click", x: 100, y: 200 });
console.log(clickEvent); // { type: "click", x: 100, y: 200 }
API Response Validation
Validating data from external APIs with Zod prevents runtime errors caused by type mismatches.
import { z } from "zod";
// API response schema definition
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
userId: z.number(),
});
const PostListSchema = z.array(PostSchema);
// Infer API response type
type Post = z.infer<typeof PostSchema>;
// Safe API call function
async function fetchPosts(): Promise<Post[]> {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
const json: unknown = await response.json(); // Receive as unknown
// Runtime validation: error if schema does not match
const result = PostListSchema.safeParse(json);
if (!result.success) {
console.error("API response format mismatch:", result.error.flatten());
throw new Error("API response validation failed");
}
return result.data; // Post[] type is guaranteed
}
// Environment variable validation
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(10),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
// Validate environment variables at app startup
function validateEnv() {
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error("Environment variable validation failed:");
result.error.issues.forEach((issue) => {
console.error(` ${issue.path.join(".")}: ${issue.message}`);
});
process.exit(1); // Exit immediately on missing env vars
}
return result.data;
}
const env = validateEnv();
console.log(`Server port: ${env.PORT}`); // Type-safe environment variable access
Form Validation (React Hook Form Integration)
Zod integrates seamlessly with React Hook Form via @hookform/resolvers.
import { z } from "zod";
// Registration form schema
const RegisterFormSchema = z
.object({
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be 20 characters or fewer")
.regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores allowed"),
email: z.string().email("Please enter a valid email"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain at least 1 uppercase letter")
.regex(/[0-9]/, "Must contain at least 1 number"),
confirmPassword: z.string(),
agreeTerms: z.literal(true, {
errorMap: () => ({ message: "You must agree to the terms" }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
// Infer form data type
type RegisterForm = z.infer<typeof RegisterFormSchema>;
// React Hook Form integration example (pseudo code)
// import { useForm } from "react-hook-form";
// import { zodResolver } from "@hookform/resolvers/zod";
//
// const { register, handleSubmit, formState: { errors } } = useForm<RegisterForm>({
// resolver: zodResolver(RegisterFormSchema),
// });
// Manual validation example
const formData = {
username: "alice_doe",
email: "alice@example.com",
password: "Secure1234",
confirmPassword: "Secure1234",
agreeTerms: true as const,
};
const formResult = RegisterFormSchema.safeParse(formData);
if (formResult.success) {
console.log("Form validation passed:", formResult.data.username);
// Form validation passed: alice_doe
} else {
const fieldErrors = formResult.error.flatten().fieldErrors;
console.log("Field errors:", fieldErrors);
}
Summary
- Schema = Type + Validation: Using
z.inferto infer types keeps schemas and types always in sync. - Use safeParse: For external input, use
safeParsewhich does not throw exceptions. - coerce for type conversion: Use
z.coercewhen converting strings to numbers or dates. - refine/superRefine: Use
refinefor single-field validation andsuperRefinefor cross-field validation. - API + Environment variables: Applying Zod validation at external data entry points greatly improves runtime safety.
- Form library integration: Integrate with React Hook Form, Formik, and others through resolvers.