TypeScript 的类型在编译后完全消失,运行时 incoming 数据(API 请求、表单输入、环境变量)可能是任意值。Zod 是在 TypeScript 生态中最流行的运行时类型验证库——它与 TS 类型系统天然配合,用声明式的方式定义数据 schema,并自动推断出对应的 TS 类型。
一、Zod 基础
npm install zod
1.1 Schema 定义与类型推断
Zod 的核心哲学是「一份 schema,双重收益」。你只用写一次验证规则,Zod 就能在运行时校验数据,同时在编译时自动推导出 TypeScript 类型。这种「单源真理」的设计避免了类型定义与验证逻辑不同步的问题。
import { z } from 'zod';
// 定义 schema
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).optional(),
role: z.enum(['user', 'admin']),
});
// 自动推断 TypeScript 类型
type User = z.infer<typeof UserSchema>;
// 等同于:
// type User = { id: number; name: string; email: string; age?: number; role: 'user' | 'admin' }
// 运行时验证
const result = UserSchema.safeParse(unknownData);
if (!result.success) {
console.log(result.error.issues); // 详细的验证错误
} else {
const user: User = result.data;
}
1.2 强制类型转换(Coercions)
Web 表单和 URL Query 参数本质上都是字符串,但业务逻辑往往期望数字或布尔值。Zod 提供 coerce 系列方法,在验证的同时完成类型转换,省去手写 Number() 或 Boolean() 的样板代码。
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
isActive: z.coerce.boolean().default(true),
tags: z.string().transform((s) => s.split(',')).optional(),
});
QuerySchema.parse({ page: '3', limit: '50', isActive: 'true' });
// => { page: 3, limit: 50, isActive: true }
z.coerce.number() 内部会调用 Number(value),因此对 '42'、42、甚至 ' 42 ' 都能正确解析,但 'abc' 会被优雅地拦截下来并返回结构化的错误信息。
1.3 Transforms 与 Refinements
当内置验证器不够用的时候,transform 和 refine 两个 API 可以让你在 schema 链中嵌入任意逻辑。
const PasswordSchema = z
.string()
.min(8)
.refine(
(val) => /[A-Z]/.test(val) && /[a-z]/.test(val) && /\d/.test(val),
{
message: '密码必须包含大小写字母和数字',
}
);
const DateRangeSchema = z
.object({
start: z.string().datetime(),
end: z.string().datetime(),
})
.refine((data) => new Date(data.start) < new Date(data.end), {
message: '结束时间必须晚于开始时间',
path: ['end'],
});
// transform 在验证通过后执行数据清洗
const SlugSchema = z
.string()
.min(1)
.max(200)
.transform((s) =>
s
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
);
refine 适合业务规则的校验,transform 适合数据规范化。两者可以链式组合,但要注意 transform 一旦执行,原始数据就不可恢复了。
1.4 Branded Types(品牌类型)
在大型代码库中,两个结构相同的 primitive 类型(比如 string)可能代表完全不同的语义含义——用户 ID 和订单 ID 都是 string,但绝不能混用。Zod 的 brand 可以在运行时保持原有值,在编译时引入名义类型(nominal typing),防止这种隐式错误。
const UserId = z.string().brand<'UserId'>();
const OrderId = z.string().brand<'OrderId'>();
type UserId = z.infer<typeof UserId>;
type OrderId = z.infer<typeof OrderId>;
function cancelOrder(orderId: OrderId) { /* ... */ }
const uid = UserId.parse('u_123');
cancelOrder(uid); // TypeScript Error: 类型 "string & BRAND<"UserId">" 的参数不能赋给类型 "string & BRAND<"OrderId">" 的参数
Branded types 尤其适用于资金计算、实体 ID、加密哈希值等对类型安全要求极高的场景。
1.5 递归 Schema(Lazy / Recursive Schemas)
目录树、组织架构、评论嵌套等场景都需要自引用类型。Zod 提供 z.lazy() 解决 TypeScript 的循环引用问题。
type TreeNode = {
id: number;
label: string;
children: TreeNode[];
};
const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
z.object({
id: z.number(),
label: z.string(),
children: z.array(TreeNodeSchema),
})
);
TreeNodeSchema.parse({
id: 1,
label: 'Root',
children: [{ id: 2, label: 'Child', children: [] }],
});
使用 z.lazy() 时,必须显式标注 z.ZodType<YourType>,否则 TypeScript 的类型推断会陷入死循环。
二、API 请求验证
2.1 Express 中间件
// Express 中间件
import { Request, Response, NextFunction } from 'express';
import { z, ZodError } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).max(10),
});
function validateBody<T extends z.ZodType>(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues,
});
}
req.body = result.data;
next();
};
}
app.post('/posts', validateBody(CreatePostSchema), (req, res) => {
// req.body 已验证为 CreatePost 类型
const { title, content, tags } = req.body;
// ...
});
2.2 NestJS Pipes 集成
NestJS 原生支持基于 class-validator 的方案,但社区也提供了 nestjs-zod 包,让你可以直接在 DTO 中使用 Zod schema,并享受到自动 Swagger 文档生成的好处。
import { z } from 'zod';
import { createZodDto } from 'nestjs-zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(50),
age: z.number().int().min(0).optional(),
});
export class CreateUserDto extends createZodDto(CreateUserSchema) {}
// controller.ts
import { Body, Controller, Post } from '@nestjs/common';
@Controller('users')
export class UserController {
@Post()
create(@Body() dto: CreateUserDto) {
// dto 已经通过 Zod 验证
return this.userService.create(dto);
}
}
NestJS 的 ValidationPipe 会自动调用 DTO 上的 zodSchema.parse(),不需要你手动在 controller 中写任何验证逻辑。这种方式将验证层与业务逻辑层彻底解耦。
2.3 Fastify 验证
Fastify 通过 preValidation 钩子支持 Zod 集成。结合 @fastify/swagger,可以同时完成请求验证和 API 文档自动生成。
import Fastify from 'fastify';
import { z } from 'zod';
const fastify = Fastify();
const BodySchema = z.object({
username: z.string().min(3),
password: z.string().min(8),
});
fastify.post(
'/register',
{
schema: {
body: BodySchema,
response: {
200: z.object({ token: z.string() }),
},
},
},
async (request, reply) => {
const body = request.body; // 类型已推断为 { username: string; password: string }
return { token: await generateToken(body) };
}
);
Fastify 的 schema-based 架构天然适合 Zod——两者都强调「声明式定义,自动执行」。你可以把 Zod schema 序列化为 JSON Schema 注册到 Fastify,实现零重复代码。
2.4 OpenAPI / Swagger 自动生成
手动维护 Swagger 文档与代码同步是一件痛苦的事。zod-to-openapi 库可以将任意 Zod schema 转换为 OpenAPI Schema Object,再配合路由注册器,实现文档的完全自动生成。
import { extendZodWithOpenApi, OpenApiGeneratorV3 } from 'zod-to-openapi';
import { z } from 'zod';
extendZodWithOpenApi(z);
const UserSchema = z
.object({ id: z.number().openapi({ example: 42 }), name: z.string().openapi({ example: 'Alice' }) })
.openapi('User');
const generator = new OpenApiGeneratorV3([UserSchema]);
const docs = generator.generateDocument({
openapi: '3.0.0',
info: { title: 'My API', version: '1.0.0' },
});
通过这种方式,后端的 API 文档永远与实际验证规则保持一致,不会出现「文档说可选,代码里 required」的陷阱。
三、环境变量类型安全
3.1 基础 env.ts
// env.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
PORT: z.string().transform(Number).default('3000'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
REDIS_URL: z.string().url().optional(),
});
export const env = envSchema.parse(process.env);
// 如果缺少必需变量或格式错误,启动时直接抛出清晰的错误
3.2 dotenv 文件加载
在本地开发和测试环境中,环境变量通常存放在 .env 文件中。推荐在 env.ts 中显式调用 dotenv 的加载逻辑,并把 .env.example 作为团队共享的模板文件。验证不通过时,Zod 会输出包含具体字段和期望类型的错误信息,让同事一眼就知道缺少哪些配置。
import { config } from 'dotenv';
import { z } from 'zod';
import { resolve } from 'path';
// 根据 NODE_ENV 决定加载哪个 .env 文件
const envFile = process.env.NODE_ENV === 'test' ? '.env.test' : '.env';
config({ path: resolve(process.cwd(), envFile) });
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
API_BASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export const env = envSchema.parse(process.env);
3.3 CI/CD 环境校验
在生产部署的 CI/CD 流水线中,建议在构建或启动阶段前置执行一次 env.ts 的验证脚本。如果环境变量缺失,整条流水线应该立即失败,而不是让带着错误配置的服务启动后再崩溃。
# .github/workflows/deploy.yml
- name: Validate Environment Variables
run: npx ts-node scripts/validate-env.ts
// scripts/validate-env.ts
import '../src/env';
console.log('Environment variables validated successfully.');
这样即便运维人员填写了错误的 DATABASE_URL,也能在部署早期被拦截下来,避免线上事故。
3.4 Docker Secrets 集成
在 Docker Swarm 或 Kubernetes 环境中,敏感信息不应以明文环境变量注入,而是通过 Docker Secrets 或挂载文件的方式提供。Zod 的 preprocess 可以读取这些文件内容并注入到 schema 中。
import { readFileSync } from 'fs';
import { z } from 'zod';
function fromSecret(path: string): string {
try {
return readFileSync(path, 'utf-8').trim();
} catch {
return '';
}
}
const envSchema = z.object({
DATABASE_URL: z.preprocess(
(val) => val || fromSecret('/run/secrets/database_url'),
z.string().url()
),
JWT_SECRET: z.preprocess(
(val) => val || fromSecret('/run/secrets/jwt_secret'),
z.string().min(32)
),
});
export const env = envSchema.parse(process.env);
这种分离敏感数据的方式不仅提升了安全性,也让本地开发(.env)与生产部署(Docker Secrets)可以共用同一套验证逻辑。
四、React Hook Form + Zod
4.1 基础表单验证
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('请输入有效的邮箱'),
password: z.string().min(8, '密码至少8位'),
});
type FormData = z.infer<typeof schema>;
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
<button type="submit">登录</button>
</form>
);
}
4.2 Next.js App Router Server Actions 表单验证
Next.js 14 引入的 Server Actions 让表单提交可以直接调用服务端函数,无需中间 API 路由。Zod 可以在 Server Action 中对原始表单数据进行服务端二次验证,防止客户端校验被绕过。
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const ContactFormSchema = z.object({
name: z.string().min(2, '姓名至少2个字符'),
email: z.string().email('邮箱格式不正确'),
message: z.string().min(10, '留言内容至少10个字符').max(1000),
});
export async function submitContactForm(formData: FormData) {
const raw = Object.fromEntries(formData.entries());
const parsed = ContactFormSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors };
}
await db.insert('contacts').values(parsed.data);
revalidatePath('/contact');
return { success: true };
}
// page.tsx
// 'use client'
// import { useFormState } from 'react-dom';
// const [state, formAction] = useFormState(submitContactForm, null);
客户端的 Zod + React Hook Form 提供即时反馈体验,Server Action 中的 Zod 验证则构成最后一道安全防线。这种「双重验证」的设计是生产级应用的标配。
4.3 Field Arrays 动态表单
动态表单(比如添加多个工作经历、技能标签)在业务中很常见。React Hook Form 的 useFieldArray 与 Zod 配合时,需要把数组中的每个元素都定义好 schema。
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
members: z.array(
z.object({
name: z.string().min(1, '姓名不能为空'),
email: z.string().email('邮箱格式错误'),
role: z.enum(['admin', 'editor', 'viewer']),
})
).min(1, '至少需要一名成员'),
});
type FormData = z.infer<typeof schema>;
function TeamForm() {
const { register, control, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { members: [{ name: '', email: '', role: 'viewer' }] },
});
const { fields, append, remove } = useFieldArray({
control,
name: 'members',
});
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`members.${index}.name`)} placeholder="姓名" />
<input {...register(`members.${index}.email`)} placeholder="邮箱" />
<select {...register(`members.${index}.role`)}>
<option value="admin">管理员</option>
<option value="editor">编辑</option>
<option value="viewer">查看者</option>
</select>
<button type="button" onClick={() => remove(index)}>删除</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: '', email: '', role: 'viewer' })}
>
添加成员
</button>
<button type="submit">提交</button>
</form>
);
}
useFieldArray 会自动为每个动态行分配唯一的 id,即使删除中间行也不会影响 Zod 的路径定位。结合 z.array().min() 可以在删除最后一行时阻止提交,UX 与数据完整性兼得。
五、Zod 与 Prisma 联动
ORM 的模型定义与运行时验证 schema 非常容易 diverge:开发者在 Prisma schema 里改了字段名,却忘了同步修改 Zod 的 DTO。社区工具 zod-prisma-types 可以基于 prisma generate 的 AST 自动产出对应的 Zod schema,彻底消除这份维护负担。
// schema.prisma
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
authorId Int
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}
在 prisma/schema.prisma 的顶部加上 generator 配置:
generator zod {
provider = "zod-prisma-types"
output = "./zod"
}
执行 npx prisma generate 后,你会在 prisma/zod 目录下得到:PostSchema、PostCreateInputSchema、PostUpdateInputSchema、PostWhereInputSchema 等十多种变体。这些 schema 覆盖了 CRUD 的完整生命周期:
import { PostCreateInputSchema, PostUpdateInputSchema } from './prisma/zod';
// 创建时 title 是必填的
const createData = PostCreateInputSchema.parse({
title: 'Hello Zod',
content: 'Prisma + Zod 自动同步',
author: { connect: { id: 1 } },
});
// 更新时所有字段都是 optional 的
const updateData = PostUpdateInputSchema.parse({
published: true,
});
当团队迭代数据库结构时,只需修改 Prisma schema 再执行 generate,所有依赖的 Zod schema 会自动重建。这种「代码生成」模式比手动维护要可靠得多。
六、错误国际化与自定义错误消息
面向多语言用户的产品需要把验证错误翻译成不同的 locale。Zod 本身不提供内置的 i18n 系统,但支持通过定制 errorMap 实现完全可控的国际化策略。
6.1 全局 errorMap 定制
import { z } from 'zod';
import i18next from 'i18next';
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
switch (issue.code) {
case z.ZodIssueCode.too_small:
if (issue.type === 'string') {
return {
message: i18next.t('validation.minLength', {
field: issue.path.join('.'),
min: issue.minimum,
}),
};
}
break;
case z.ZodIssueCode.invalid_string:
if (issue.validation === 'email') {
return { message: i18next.t('validation.invalidEmail') };
}
break;
case z.ZodIssueCode.invalid_enum_value:
return {
message: i18next.t('validation.invalidEnum', {
options: issue.options.join(', '),
}),
};
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);
安装 zod-i18n-map 库可以省去自己写所有 case 的麻烦。它内置了中文、英文、日文、德文等 20 多种 locale 映射,只需一行配置即可切换语言环境。
6.2 字段级自定义消息
对于不需要完整 i18n 的小型项目,直接在 schema 链上写中文提示是最轻量的方案:
const UserSchema = z.object({
username: z
.string({ required_error: '用户名不能为空' })
.min(3, '用户名至少3个字符')
.max(20, '用户名不能超过20个字符'),
email: z.string().email('请输入正确的邮箱地址'),
phone: z
.string()
.regex(/^1[3-9]\d{9}$/, '请输入有效的中国大陆手机号'),
});
当表单字段较多时,可以把这些消息抽离到一个独立的 messages 对象里,既保持 schema 清晰,又方便后期统一调整文案风格。
七、性能优化:大对象预编译 Schema
Zod 的解析过程涉及大量的递归 AST 遍历。对于高频调用的小 schema,这种开销可以忽略不计;但如果你需要在一个批量导入接口里解析上万个复杂对象,性能差距就会显现出来。
7.1 使用 z.preprocess 减少重复计算
z.preprocess 允许你在进入主 schema 之前,先对原始数据做一次轻量的规范化。例如把 JSON 字符串提前解析为对象,避免每个子字段都去判断类型。
const PayloadSchema = z.preprocess(
(val) => (typeof val === 'string' ? JSON.parse(val) : val),
z.object({
items: z.array(z.object({ id: z.number(), qty: z.number() })),
})
);
7.2 缓存已解析的 Schema
在 Node.js 服务端,module-level 的 schema 定义天然是单例的,不需要额外缓存。但在 Web Worker 或边缘计算(V8 Isolate)场景下,schema 可能会被反复初始化。可以把解析过的 schema 挂在全局对象或一个模块级 Map 上复用。
const schemaCache = new Map<string, z.ZodTypeAny>();
function getCachedSchema(key: string, factory: () => z.ZodTypeAny) {
if (!schemaCache.has(key)) {
schemaCache.set(key, factory());
}
return schemaCache.get(key)!;
}
如果项目对性能要求极高,也可以考虑 @zod/core 的新实验性编译器(Zod v4 规划),它在构建阶段把 schema 编译为更高效的验证函数,运行时开销接近手写校验代码。
八、测试策略:Mock 数据生成与 Fuzzing 测试
手动编写测试数据既枯燥又容易遗漏边界条件。Zod schema 作为「数据源-验证规则」的权威描述,天然适合反向生成测试数据。
8.1 从 Schema 生成 Mock 数据
zod-mock 库可以遍历 Zod schema 的 AST,根据每个字段的类型和约束自动产生合法的 mock 值。
import { generateMock } from '@anatine/zod-mock';
import { z } from 'zod';
const ProductSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1),
price: z.number().min(0),
inStock: z.boolean(),
tags: z.array(z.string()).max(5),
});
const mockProduct = generateMock(ProductSchema);
// => { id: 123, name: 'mock-string', price: 42, inStock: true, tags: ['tag1'] }
配合 faker 可以从 min、max、email、url 等约束中推断语义,生成更贴近真实业务的数据。例如 z.string().email() 会产出看起来像邮箱的字符串,而不是随机的字符组合。
8.2 Fuzzing 测试——故意传入坏数据
单元测试不仅要验证「好数据通过」,更要验证「坏数据被正确拦截」。可以写一个通用的 fuzzing 辅助函数:
import { z } from 'zod';
function expectInvalid<T extends z.ZodTypeAny>(
schema: T,
badValues: unknown[]
) {
for (const val of badValues) {
const result = schema.safeParse(val);
if (result.success) {
throw new Error(`Expected failure but got success for: ${JSON.stringify(val)}`);
}
}
}
// 使用示例
const AgeSchema = z.number().int().min(0).max(120);
expectInvalid(AgeSchema, [
'not a number',
-1,
121,
3.14,
null,
undefined,
{},
]);
结合 fast-check 等 property-based testing 工具,可以进一步自动生成海量随机输入,让验证逻辑在统计学意义上得到充分覆盖。
相关阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。