Zod 是一个 TypeScript 优先的 schema 验证库,让你定义数据的 schema 并在运行时验证,同时提供完整的 TypeScript 类型推断。定义一次,同时获得运行时验证和编译时类型安全。
为什么选择 Zod?
传统 TypeScript 类型仅在编译时存在。来自外部源的数据在运行时从不被验证。Zod 通过提供自动生成 TypeScript 类型的运行时验证来弥合这一差距。
主要优势
- 零依赖:没有外部包的小型库
- TypeScript 优先:完整的类型推断
- 可组合:schema 可以组合、扩展和转换
- 不可变:所有方法返回新的 schema 实例
- 详细错误:带路径和自定义消息的丰富错误信息
- 到处运行:Node.js、Deno、Bun、浏览器
基本 Schema
Zod 为所有 JavaScript 类型提供原语,以及组合成复杂 schema 的方法。
import { z } from "zod";
// Primitive types
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const undefinedSchema = z.undefined();
const nullSchema = z.null();
const bigintSchema = z.bigint();
// String validations
const emailSchema = z.string().email("Invalid email address");
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();
const minSchema = z.string().min(3, "Too short");
const maxSchema = z.string().max(100, "Too long");
const regexSchema = z.string().regex(/^[A-Z]{2}\d{4}$/, "Invalid format");
const trimSchema = z.string().trim(); // trims whitespace
// Number validations
const positiveSchema = z.number().positive();
const intSchema = z.number().int();
const rangeSchema = z.number().min(0).max(100);
const finiteSchema = z.number().finite();
// Enums
const roleSchema = z.enum(["admin", "user", "guest"]);
type Role = z.infer<typeof roleSchema>; // "admin" | "user" | "guest"
// Literals
const statusSchema = z.literal("active");
// Unions
const idSchema = z.union([z.string(), z.number()]);
// Shorthand: z.string().or(z.number())
// Parsing
const result = emailSchema.safeParse("test@example.com");
if (result.success) {
console.log(result.data); // "test@example.com"
} else {
console.log(result.error.issues);
}对象 Schema
对象 schema 是最常见的类型,验证 JavaScript 对象的形状并为每个属性提供类型推断。
// Object schemas with full type inference
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(["admin", "user", "guest"]).default("user"),
tags: z.array(z.string()).default([]),
address: z.object({
street: z.string(),
city: z.string(),
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
country: z.string().length(2),
}).optional(),
});
// Infer the TypeScript type automatically
type User = z.infer<typeof UserSchema>;
// {
// id: number;
// name: string;
// email: string;
// age?: number | undefined;
// role: "admin" | "user" | "guest";
// tags: string[];
// address?: { street: string; city: string; zip: string; country: string } | undefined;
// }
// Schema composition
const CreateUserSchema = UserSchema.omit({ id: true });
const UpdateUserSchema = UserSchema.partial().required({ id: true });
const PublicUserSchema = UserSchema.pick({ id: true, name: true, role: true });
// Extending schemas
const AdminSchema = UserSchema.extend({
permissions: z.array(z.string()),
department: z.string(),
});
// Merging schemas
const WithTimestamps = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const UserWithTimestamps = UserSchema.merge(WithTimestamps);转换和细化
转换修改输出类型,细化添加自定义验证逻辑。两者对实际验证都必不可少。
自定义细化
refine 方法添加超越类型检查的自定义验证规则。
// Custom refinements
const PasswordSchema = z.string()
.min(8, "Password must be at least 8 characters")
.refine(val => /[A-Z]/.test(val), "Must contain uppercase letter")
.refine(val => /[a-z]/.test(val), "Must contain lowercase letter")
.refine(val => /[0-9]/.test(val), "Must contain number")
.refine(val => /[^A-Za-z0-9]/.test(val), "Must contain special character");
// Super refinement for 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"],
});
}
});
// Date range validation
const DateRangeSchema = z.object({
startDate: z.date(),
endDate: z.date(),
}).refine(
data => data.endDate > data.startDate,
{ message: "End date must be after start date", path: ["endDate"] }
);数据转换
transform 方法改变 schema 的输出类型,用于解析字符串为数字、规范化数据等。
// Transform: string to number
const NumericStringSchema = z.string().transform(Number);
// Input: "42" -> Output: 42
// Transform: normalize data
const NormalizedEmailSchema = z.string()
.email()
.transform(email => email.toLowerCase().trim());
// Transform with validation
const PositiveIntString = z.string()
.transform(Number)
.pipe(z.number().int().positive());
// Input: "42" -> validates as string -> transforms to 42 -> validates as positive int
// Transform: parse JSON strings
const JsonSchema = z.string().transform((str, ctx) => {
try {
return JSON.parse(str);
} catch {
ctx.addIssue({ code: "custom", message: "Invalid JSON" });
return z.NEVER;
}
});
// Coercion (built-in transforms)
const coercedNumber = z.coerce.number(); // "42" -> 42
const coercedBoolean = z.coerce.boolean(); // "true" -> true
const coercedDate = z.coerce.date(); // "2026-01-01" -> Date实际模式
这些模式在生产 TypeScript 应用中常用。
API 响应验证
验证 API 响应以捕获破坏性变更。
// Validate API responses
const ApiResponseSchema = z.object({
data: z.array(UserSchema),
pagination: z.object({
page: z.number(),
perPage: z.number(),
total: z.number(),
totalPages: z.number(),
}),
});
type ApiResponse = z.infer<typeof ApiResponseSchema>;
async function fetchUsers(page: number): Promise<ApiResponse> {
const res = await fetch(`/api/users?page=${page}`);
const json = await res.json();
// Validate response - throws if invalid
return ApiResponseSchema.parse(json);
}
// Safe version that returns errors
async function fetchUsersSafe(page: number) {
const res = await fetch(`/api/users?page=${page}`);
const json = await res.json();
return ApiResponseSchema.safeParse(json);
}环境变量验证
在应用启动时验证环境变量。
// Validate environment variables at startup
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
REDIS_URL: z.string().url().optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
CORS_ORIGINS: z.string().transform(s => s.split(",")),
});
// Parse and export typed env
const env = EnvSchema.parse(process.env);
export default env;
// env.PORT is number (not string!)
// env.CORS_ORIGINS is string[]
// env.NODE_ENV is "development" | "production" | "test"高级特性
可辨识联合
可辨识联合基于共享的辨识字段验证对象。
// Discriminated union - efficient, checks "type" field first
const EventSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("click"),
x: z.number(),
y: z.number(),
target: z.string(),
}),
z.object({
type: z.literal("keypress"),
key: z.string(),
modifiers: z.array(z.enum(["ctrl", "shift", "alt", "meta"])),
}),
z.object({
type: z.literal("scroll"),
deltaX: z.number(),
deltaY: z.number(),
}),
]);
type Event = z.infer<typeof EventSchema>;
function handleEvent(event: Event) {
switch (event.type) {
case "click":
console.log(`Click at (${event.x}, ${event.y})`); // TS knows x, y exist
break;
case "keypress":
console.log(`Key: ${event.key}`); // TS knows key exists
break;
case "scroll":
console.log(`Scroll: ${event.deltaY}`);
break;
}
}品牌类型
品牌类型创建名义类型值,防止混合结构相同的类型。
// Branded types prevent mixing structurally identical types
const UserId = z.string().uuid().brand<"UserId">();
const PostId = z.string().uuid().brand<"PostId">();
type UserId = z.infer<typeof UserId>; // string & { __brand: "UserId" }
type PostId = z.infer<typeof PostId>; // string & { __brand: "PostId" }
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
const userId = UserId.parse("550e8400-e29b-41d4-a716-446655440000");
const postId = PostId.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8");
getUser(userId); // OK
// getUser(postId); // TypeScript ERROR! PostId is not UserId错误处理
Zod 提供可以格式化为用户友好消息的详细错误信息。
// Handling validation errors
const result = UserSchema.safeParse(invalidData);
if (!result.success) {
// Zod error has structured issues
const errors = result.error.issues;
// [
// { code: "too_small", path: ["name"], message: "Too short" },
// { code: "invalid_string", path: ["email"], message: "Invalid email" }
// ]
// Format errors by field
const fieldErrors = result.error.flatten().fieldErrors;
// { name: ["Too short"], email: ["Invalid email"] }
// Format as a single message
const formatted = result.error.format();
// { name: { _errors: ["Too short"] }, email: { _errors: ["Invalid email"] } }
}
// Custom error map
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.too_small) {
return { message: `Must be at least ${issue.minimum} characters` };
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);框架集成
tRPC
tRPC 使用 Zod schema 进行输入验证,提供端到端类型安全。
// tRPC with Zod: end-to-end type safety
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
// input is typed as { id: string }
return await db.user.findUnique({ where: { id: input.id } });
}),
createUser: t.procedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
// input is typed as { name: string; email: string }
return await db.user.create({ data: input });
}),
});
// Client gets full type inference from the schemas!
// const user = await trpc.getUser.query({ id: "..." });常见问题
Zod 与 Yup 相比如何?
Zod 是 TypeScript 优先的,有更好的类型推断。对于新的 TypeScript 项目,Zod 是更好的选择。
Zod 影响运行时性能吗?
Zod 设计为快速的。简单 schema 验证只需微秒。建议只在系统边界验证。
可以不用 TypeScript 使用 Zod 吗?
可以,但会失去自动类型推断的主要优势。JavaScript 项目可以考虑 Joi 或 Yup。
如何验证嵌套对象?
在 z.object() 中嵌套 z.object()。使用 z.lazy() 处理递归结构。
Zod 能取代 TypeScript 接口吗?
对于跨系统边界的数据可以。对于内部应用类型,普通 TypeScript 接口更简单。