Zod v4 - Schema Validation Skill
Zod v4 is a TypeScript-first schema declaration and validation library that provides static type inference, runtime validation, and exceptional performance. Version 4 delivers 14x faster parsing and 66% smaller bundles while maintaining full type safety.
Quick Start
bash
1npm install zod@^4.0.0
typescript
1import * as z from "zod";
2
3// Basic schema creation and validation
4const UserSchema = z.object({
5 id: z.number().int().positive(),
6 email: z.email(),
7 username: z.string().min(3).max(20),
8 age: z.number().int().min(18).optional(),
9});
10
11type User = z.infer<typeof UserSchema>;
12
13// Parse and validate
14const user = UserSchema.parse({
15 id: 1,
16 email: "user@example.com",
17 username: "johndoe",
18 age: 25,
19});
20
21// Safe parsing with error handling
22const result = UserSchema.safeParse(input);
23if (!result.success) {
24 console.log(result.error);
25}
Common Patterns
Object Schemas with Validation
typescript
1// Comprehensive user validation
2const UserProfile = z.object({
3 id: z.number().int().positive(),
4 email: z.email(),
5 username: z
6 .string()
7 .min(3, "Username must be at least 3 characters")
8 .max(20, "Username cannot exceed 20 characters")
9 .regex(/^[a-zA-Z0-9_]+$/, "Only alphanumeric characters and underscores"),
10 age: z.number().int().min(13).max(120),
11 role: z.enum(["user", "admin", "moderator"]).default("user"),
12 bio: z.string().max(500).optional(),
13 website: z.url().optional(),
14 createdAt: z.date().default(() => new Date()),
15});
16
17// Extending schemas
18const AdminProfile = UserProfile.extend({
19 permissions: z.array(z.string()),
20 accessLevel: z.number().min(1).max(10),
21});
typescript
1// Built-in format validators
2const Validations = {
3 email: z.email(),
4 uuid: z.uuidv4(),
5 url: z.url(),
6 ipv4: z.ipv4(),
7 ipv6: z.ipv6(),
8 base64: z.base64(),
9 jwt: z.jwt(),
10
11 // ISO formats
12 isoDate: z.iso.date(),
13 isoDateTime: z.iso.datetime(),
14 isoTime: z.iso.time(),
15
16 // Custom email with specific pattern
17 strictEmail: z.email({ pattern: z.regexes.rfc5322Email }),
18};
19
20// Template literal validation
21const VersionString = z.templateLiteral([
22 z.number(),
23 ".",
24 z.number(),
25 ".",
26 z.number(),
27]);
28
29const CSSValue = z.templateLiteral([
30 z.number(),
31 z.enum(["px", "em", "rem", "%", "vh", "vw"]),
32]);
Array and Collection Validation
typescript
1// Array with constraints
2const NumberArray = z.array(z.number()).min(1).max(100);
3
4// Tuple validation
5const Coordinates = z.tuple([z.number(), z.number()]);
6const MixedTuple = z.tuple([z.string(), z.number()], z.boolean());
7
8// Set validation
9const UniqueStrings = z.set(z.string()).min(3);
10
11// Map validation
12const UserPermissions = z.map(
13 z.string(), // user ID
14 z.array(z.string()), // permissions
15);
16
17// Record validation
18const StringToNumber = z.record(z.string(), z.number());
19const StatusRecord = z.record(
20 z.enum(["pending", "active", "complete"]),
21 z.boolean(),
22);
Union and Discriminated Unions
typescript
1// Simple union
2const StringOrNumber = z.union([z.string(), z.number()]);
3
4// Discriminated union for API responses
5const ApiResponse = z.discriminatedUnion("status", [
6 z.object({
7 status: z.literal("success"),
8 data: z.unknown(),
9 }),
10 z.object({
11 status: z.literal("error"),
12 error: z.string(),
13 code: z.number(),
14 }),
15 z.object({
16 status: z.literal("loading"),
17 message: z.string().optional(),
18 }),
19]);
20
21// Nested discriminated unions
22const BaseError = z.object({
23 status: z.literal("error"),
24 message: z.string(),
25});
26
27const DetailedError = z.discriminatedUnion("code", [
28 BaseError.extend({ code: z.literal(400), field: z.string() }),
29 BaseError.extend({ code: z.literal(401), realm: z.string() }),
30 BaseError.extend({ code: z.literal(500), stack: z.string() }),
31]);
Custom Refinements and Validation
typescript
1// Custom validation with refinements
2const PasswordSchema = z
3 .string()
4 .min(8, "Password must be at least 8 characters")
5 .refine((val) => /[A-Z]/.test(val), "Must contain uppercase letter")
6 .refine((val) => /[a-z]/.test(val), "Must contain lowercase letter")
7 .refine((val) => /[0-9]/.test(val), "Must contain number")
8 .refine((val) => /[^A-Za-z0-9]/.test(val), "Must contain special character");
9
10// Complex validation with superRefine
11const UserRegistration = z
12 .object({
13 email: z.email(),
14 password: z.string(),
15 confirmPassword: z.string(),
16 age: z.number(),
17 termsAccepted: z.boolean(),
18 })
19 .superRefine((data, ctx) => {
20 if (data.password !== data.confirmPassword) {
21 ctx.addIssue({
22 code: "custom",
23 path: ["confirmPassword"],
24 message: "Passwords must match",
25 });
26 }
27
28 if (data.age < 18 && !data.termsAccepted) {
29 ctx.addIssue({
30 code: "custom",
31 path: ["termsAccepted"],
32 message: "Parental consent required for users under 18",
33 });
34 }
35 });
typescript
1// Transform data during validation
2const StringToNumber = z
3 .string()
4 .transform((val) => parseInt(val, 10))
5 .refine((val) => !isNaN(val), "Must be a valid number");
6
7const TimestampToDate = z
8 .number()
9 .transform((timestamp) => new Date(timestamp));
10
11const NormalizeEmail = z.string().transform((val) => val.toLowerCase().trim());
12
13// Overwrite for type-preserving transforms
14const RoundNumber = z.number().overwrite((val) => Math.round(val));
15
16// Pipeline transformations
17const ProcessUrl = z
18 .string()
19 .transform((val) => val.trim())
20 .transform((val) => val.toLowerCase())
21 .transform((val) => {
22 try {
23 return new URL(val);
24 } catch {
25 throw new Error("Invalid URL format");
26 }
27 });
Recursive Schemas
typescript
1// Recursive category structure
2const Category = z.object({
3 id: z.number(),
4 name: z.string(),
5 get subcategories() {
6 return z.array(Category);
7 },
8});
9
10// Mutually recursive types
11const User = z.object({
12 id: z.number(),
13 name: z.string(),
14 get posts() {
15 return z.array(Post);
16 },
17});
18
19const Post = z.object({
20 id: z.number(),
21 title: z.string(),
22 content: z.string(),
23 get author() {
24 return User;
25 },
26 get comments() {
27 return z.array(Comment);
28 },
29});
30
31const Comment = z.object({
32 id: z.number(),
33 text: z.string(),
34 get author() {
35 return User.pick({ id: true, name: true });
36 },
37});
File Validation
typescript
1// File upload validation
2const ImageUpload = z.object({
3 avatar: z
4 .file()
5 .max(5_000_000, "File must be less than 5MB")
6 .mime(["image/jpeg", "image/png", "image/webp"], "Must be an image"),
7
8 banner: z
9 .file()
10 .max(10_000_000, "File must be less than 10MB")
11 .mime(["image/jpeg", "image/png"], "Must be JPEG or PNG")
12 .optional(),
13});
14
15// Document validation
16const DocumentUpload = z
17 .file()
18 .min(1000, "File must be at least 1KB")
19 .max(50_000_000, "File must be less than 50MB")
20 .mime([
21 "application/pdf",
22 "application/msword",
23 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
24 ]);
Function Validation
typescript
1// Define validated functions
2const CalculateTax = z.function({
3 input: [z.number().min(0), z.number().min(0).max(1)],
4 output: z.number(),
5});
6
7const taxCalculator = CalculateTax.implement((amount, rate) => {
8 return amount * rate;
9});
10
11// Async function validation
12const FetchUser = z.function({
13 input: [z.number().int().positive()],
14 output: z.object({
15 id: z.number(),
16 name: z.string(),
17 email: z.email(),
18 }),
19});
20
21const getUser = FetchUser.implementAsync(async (id) => {
22 const response = await fetch(`/api/users/${id}`);
23 return response.json();
24});
Error Handling
typescript
1// Custom error messages
2const CustomValidation = z.object({
3 username: z
4 .string({
5 error: (issue) => {
6 if (issue.input === undefined) return "Username is required";
7 if (typeof issue.input !== "string") return "Username must be a string";
8 return "Invalid username";
9 },
10 })
11 .min(3, "Username must be at least 3 characters"),
12});
13
14// Error handling patterns
15const validateInput = (input: unknown) => {
16 const result = UserSchema.safeParse(input);
17
18 if (!result.success) {
19 const error = result.error;
20
21 // Pretty print errors
22 console.log(z.prettifyError(error));
23
24 // Extract field errors
25 const fieldErrors = error.issues.reduce(
26 (acc, issue) => {
27 const field = issue.path.join(".");
28 acc[field] = issue.message;
29 return acc;
30 },
31 {} as Record<string, string>,
32 );
33
34 return { success: false, errors: fieldErrors };
35 }
36
37 return { success: true, data: result.data };
38};
Default Values and Coercion
typescript
1// Schema with defaults
2const ConfigSchema = z.object({
3 theme: z.enum(["light", "dark"]).default("light"),
4 notifications: z.boolean().default(true),
5 fontSize: z.number().min(10).max(30).default(14),
6 timeout: z.number().default(5000),
7});
8
9// Type coercion
10const CoercedConfig = z.object({
11 port: z.coerce.number().default(3000),
12 https: z.coerce.boolean().default(false),
13 maxConnections: z.coerce.number().int().positive().default(100),
14});
15
16// Environment variable parsing
17const EnvSchema = z.object({
18 NODE_ENV: z
19 .enum(["development", "production", "test"])
20 .default("development"),
21 PORT: z.coerce.number().default(3000),
22 DEBUG: z.stringbool().default("false"),
23});
Zod Mini (Tree-Shakable)
typescript
1import * as z from "zod/mini";
2
3// Functional API for smaller bundles
4const OptionalString = z.optional(z.string());
5const StringArray = z.array(z.string());
6const StringOrNumber = z.union([z.string(), z.number()]);
7
8// Check functions for validations
9const ValidatedEmail = z
10 .string()
11 .check(z.regex(/@/), z.minLength(5), z.maxLength(100));
12
13const PositiveInt = z.number().check(z.int(), z.positive(), z.lt(1000));
14
15const NonEmptyArray = z.array(z.any()).check(z.minSize(1));
Practical Examples
typescript
1// Contact form validation
2const ContactForm = z.object({
3 name: z.string().min(1, "Name is required").max(100),
4 email: z.email("Please provide a valid email"),
5 subject: z.string().min(5, "Subject must be at least 5 characters"),
6 message: z.string().min(10, "Message must be at least 10 characters"),
7 newsletter: z.boolean().default(false),
8});
9
10// React form integration
11const handleSubmit = (formData: FormData) => {
12 const data = {
13 name: formData.get("name"),
14 email: formData.get("email"),
15 subject: formData.get("subject"),
16 message: formData.get("message"),
17 newsletter: formData.get("newsletter") === "on",
18 };
19
20 const result = ContactForm.safeParse(data);
21 if (!result.success) {
22 const errors = result.error.flatten();
23 return { errors: errors.fieldErrors };
24 }
25
26 // Process valid data
27 return { success: true, data: result.data };
28};
API Response Validation
typescript
1// API response schemas
2const UserResponse = z.object({
3 data: z.object({
4 id: z.number(),
5 name: z.string(),
6 email: z.email(),
7 createdAt: z.string().transform((val) => new Date(val)),
8 }),
9 meta: z.object({
10 total: z.number(),
11 page: z.number(),
12 totalPages: z.number(),
13 }),
14});
15
16// Typed API client
17const apiClient = {
18 async getUser(id: number): Promise<z.infer<typeof UserResponse>["data"]> {
19 const response = await fetch(`/api/users/${id}`);
20 const data = await response.json();
21
22 const result = UserResponse.safeParse(data);
23 if (!result.success) {
24 throw new Error(`Invalid API response: ${result.error.message}`);
25 }
26
27 return result.data.data;
28 },
29};
Configuration Validation
typescript
1// Application configuration
2const AppConfig = z.object({
3 server: z.object({
4 port: z.coerce.number().min(1).max(65535).default(3000),
5 host: z.string().default("localhost"),
6 cors: z.boolean().default(true),
7 }),
8 database: z.object({
9 url: z.string(),
10 ssl: z.boolean().default(false),
11 maxConnections: z.coerce.number().int().positive().default(10),
12 }),
13 auth: z.object({
14 jwtSecret: z.string().min(32),
15 tokenExpiry: z.string().default("24h"),
16 refreshExpiry: z.string().default("7d"),
17 }),
18});
19
20// Load and validate config
21const loadConfig = (configPath: string) => {
22 const rawConfig = require(configPath);
23 const config = AppConfig.parse(rawConfig);
24 return config;
25};
Requirements
- TypeScript: 4.5+ (recommended for best inference)
- Runtime: Node.js, browsers, Deno, Bun
- Bundle size: 5.36kb gzipped (full), 1.88kb gzipped (mini)
Installation
bash
1# Full Zod v4
2npm install zod@^4.0.0
3
4# For minimal bundle size
5npm install zod@^4.0.0
6# Then import from "zod/mini"
Key Features
- Static Type Inference: Automatic TypeScript type generation from schemas
- Runtime Validation: Comprehensive input validation with detailed errors
- Performance: 14x faster parsing than v3, optimized for production
- Tree Shakable: Zod Mini provides 85% bundle size reduction
- Template Literals: Validate string patterns matching TypeScript template literals
- File Validation: Built-in File object validation with size and MIME type constraints
- Recursive Types: Full support for recursive and self-referential schemas
- JSON Schema: First-party JSON Schema generation
- Function Validation: Type-safe function definitions with validated inputs/outputs