Zod v4 Overview
Zod v4 is the next major version of the TypeScript schema validation library. It includes performance improvements, better error messages, and new utilities.
Performance Improvements
| Metric | Zod v3 | Zod v4 | Improvement |
|---|---|---|---|
| Object Parse Speed | 10.2μs | 6.8μs | 33% faster |
| Array Parse Speed (1000 elements) | 850μs | 520μs | 39% faster |
| Bundle Size (gzip) | 14KB | 10KB | 29% smaller |
New Features
- Improved error messages
- Enhanced JSON Schema output
- Metadata API
- New primitive types
- Simplified custom validation
Basic Usage
import { z } from 'zod';
// Schema definition
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(2).max(100),
age: z.number().int().positive().optional(),
role: z.enum(['admin', 'user', 'guest']),
preferences: z.object({
theme: z.enum(['light', 'dark']).default('light'),
notifications: z.boolean().default(true),
}).optional(),
createdAt: z.coerce.date(),
});
// Type inference
type User = z.infer<typeof UserSchema>;
// {
// id: string;
// email: string;
// name: string;
// age?: number;
// role: 'admin' | 'user' | 'guest';
// preferences?: { theme: 'light' | 'dark'; notifications: boolean };
// createdAt: Date;
// }
// Validation
const result = UserSchema.safeParse(input);
if (result.success) {
console.log(result.data); // Type-safe
} else {
console.log(result.error.issues);
}
Improved Error Messages
// Zod v4: More detailed error paths
const OrderSchema = z.object({
items: z.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})
).min(1),
});
const result = OrderSchema.safeParse({
items: [
{ productId: 'invalid-uuid', quantity: -1 },
],
});
// Error output
result.error?.issues.forEach((issue) => {
console.log({
path: issue.path.join('.'), // "items.0.productId"
message: issue.message, // "Invalid uuid"
code: issue.code, // "invalid_string"
});
});
Custom Error Messages
const FormSchema = z.object({
email: z.string({
required_error: 'Email is required',
invalid_type_error: 'Email must be a string',
}).email({
message: 'Please enter a valid email address',
}),
password: z.string()
.min(8, { message: 'Password must be at least 8 characters' })
.regex(/[A-Z]/, { message: 'Must include at least one uppercase letter' })
.regex(/[0-9]/, { message: 'Must include at least one number' }),
age: z.number({
required_error: 'Please enter your age',
invalid_type_error: 'Age must be a number',
}).int().min(0).max(150),
});
// Internationalized error map
const errorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.too_small) {
if (issue.type === 'string') {
return { message: `Please enter at least ${issue.minimum} characters` };
}
if (issue.type === 'number') {
return { message: `Please enter a value of ${issue.minimum} or more` };
}
}
return { message: ctx.defaultError };
};
z.setErrorMap(errorMap);
New Utility Types
z.coerce (Type Coercion)
// Convert string to number
const NumberSchema = z.coerce.number();
NumberSchema.parse('42'); // 42
NumberSchema.parse('3.14'); // 3.14
NumberSchema.parse(true); // 1
// Convert string to date
const DateSchema = z.coerce.date();
DateSchema.parse('2024-01-01'); // Date
DateSchema.parse(1704067200000); // Date
DateSchema.parse(new Date()); // Date
// Convert string to boolean
const BoolSchema = z.coerce.boolean();
BoolSchema.parse('true'); // true
BoolSchema.parse('false'); // false
BoolSchema.parse(1); // true
BoolSchema.parse(0); // false
z.pipe (Transform Chain)
// String → Number → Validation
const PriceSchema = z
.string()
.pipe(z.coerce.number().positive());
PriceSchema.parse('100'); // 100
PriceSchema.parse('-50'); // Error: Number must be positive
// JSON string parsing
const JsonDataSchema = z
.string()
.transform((str) => JSON.parse(str))
.pipe(z.object({
name: z.string(),
value: z.number(),
}));
JsonDataSchema.parse('{"name": "test", "value": 42}');
z.discriminatedUnion (Discriminated Union)
// Event type discrimination
const EventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('user_created'),
userId: z.string(),
email: z.string().email(),
}),
z.object({
type: z.literal('user_updated'),
userId: z.string(),
changes: z.record(z.unknown()),
}),
z.object({
type: z.literal('user_deleted'),
userId: z.string(),
deletedAt: z.date(),
}),
]);
type Event = z.infer<typeof EventSchema>;
// TypeScript discriminatable
function handleEvent(event: Event) {
switch (event.type) {
case 'user_created':
console.log(event.email); // Type-safe
break;
case 'user_updated':
console.log(event.changes);
break;
case 'user_deleted':
console.log(event.deletedAt);
break;
}
}
JSON Schema Output
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
const ProductSchema = z.object({
id: z.string().uuid().describe('Product unique identifier'),
name: z.string().min(1).max(255).describe('Product name'),
price: z.number().positive().describe('Price in cents'),
category: z.enum(['electronics', 'clothing', 'food']),
tags: z.array(z.string()).optional(),
metadata: z.record(z.unknown()).optional(),
});
const jsonSchema = zodToJsonSchema(ProductSchema, {
name: 'Product',
target: 'openApi3',
});
// Output:
// {
// "$schema": "http://json-schema.org/draft-07/schema#",
// "type": "object",
// "properties": {
// "id": {
// "type": "string",
// "format": "uuid",
// "description": "Product unique identifier"
// },
// "name": {
// "type": "string",
// "minLength": 1,
// "maxLength": 255,
// "description": "Product name"
// },
// ...
// },
// "required": ["id", "name", "price", "category"]
// }
Metadata API
// Attach metadata to schema
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
}).meta({
title: 'User',
description: 'Represents a user in the system',
examples: [
{ id: '123e4567-e89b-12d3-a456-426614174000', email: 'user@example.com', name: 'John Doe' },
],
});
// Get metadata
const metadata = UserSchema._def.meta;
console.log(metadata.title); // 'User'
console.log(metadata.description); // 'Represents a user in the system'
// Use for OpenAPI generation, etc.
function generateOpenApiSchema(schema: z.ZodType) {
const meta = schema._def.meta ?? {};
return {
title: meta.title,
description: meta.description,
examples: meta.examples,
// ...
};
}
Branded Types
// Type-level distinction
const UserId = z.string().uuid().brand<'UserId'>();
const PostId = z.string().uuid().brand<'PostId'>();
type UserId = z.infer<typeof UserId>;
type PostId = z.infer<typeof PostId>;
// Usage example
function getUser(id: UserId): Promise<User> { ... }
function getPost(id: PostId): Promise<Post> { ... }
const userId = UserId.parse('123e4567-e89b-12d3-a456-426614174000');
const postId = PostId.parse('987fcdeb-51a2-34d6-b789-123456789abc');
getUser(userId); // OK
getUser(postId); // TypeScript Error!
Form Validation
// Integration with React Hook Form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const SignUpSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(8, 'Please enter at least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type SignUpForm = z.infer<typeof SignUpSchema>;
function SignUpPage() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignUpForm>({
resolver: zodResolver(SignUpSchema),
});
const onSubmit = (data: SignUpForm) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
Advanced Patterns
Recursive Schema
// Tree structure
interface Category {
id: string;
name: string;
children: Category[];
}
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
id: z.string(),
name: z.string(),
children: z.array(CategorySchema),
})
);
Conditional Validation
const PaymentSchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('credit_card'),
cardNumber: z.string().regex(/^\d{16}$/),
expiryDate: z.string().regex(/^\d{2}\/\d{2}$/),
cvv: z.string().regex(/^\d{3,4}$/),
}),
z.object({
method: z.literal('bank_transfer'),
bankCode: z.string(),
accountNumber: z.string(),
}),
z.object({
method: z.literal('paypal'),
email: z.string().email(),
}),
]);