编译期类型再严谨,也无法保证"越过进程边界"的数据是安全的——HTTP 请求体、数据库行、消息队列里的消息,在运行时都可能与声明不符。这就是运行时类型安全边界(Runtime Type Safety Boundary)存在的意义:在数据进入可信域的那一刻,用 schema 做一次"诚实检查"。
本文是 https://plumephp.com/typescript-zod-validation/ 的进阶,聚焦三件事:Zod 的高级建模能力(判别联合、transform、refine、effects)、Valibot 对比与选型、以及把 schema 当作 API 契约 + 与类型体操深度结合。
1. Zod 深度建模
1.1 判别联合(Discriminated Union)
业务状态机(订单状态、事件类型)是运行时校验最典型的高价值场景。z.discriminatedUnion 用判别字段精确区分联合分支,错误信息会定位到具体分支:
import { z } from 'zod';
const OrderEvent = z.discriminatedUnion('type', [
z.object({
type: z.literal('created'),
orderId: z.string().uuid(),
at: z.coerce.date(),
}),
z.object({
type: z.literal('paid'),
orderId: z.string().uuid(),
amount: z.number().positive(),
paymentMethod: z.enum(['wechat', 'alipay', 'card']),
}),
z.object({
type: z.literal('cancelled'),
orderId: z.string().uuid(),
reason: z.string().min(1).optional(),
}),
]);
type OrderEvent = z.infer<typeof OrderEvent>;
// => 精确的判别联合,type 字段自动收窄
// 消费时享受判别收窄:TS 根据 type 自动推断其余字段
function handleEvent(event: OrderEvent) {
switch (event.type) {
case 'created':
event.orderId; // 存在
event.amount; // 编译错误:created 分支没有 amount
break;
case 'paid':
event.amount; // 存在,且是 number
break;
}
}
对比普通的 z.union:discriminatedUnion 校验速度更快(直接读 type 字段定位分支),且每个分支的错误不混淆。
1.2 transform 与链式转换
transform 让 schema 具备"校验 → 转换"两段能力。z.infer 取到的是 transform 之后的输出类型,这是"输入松散、输出严格"的关键:
const PointSchema = z.object({
x: z.coerce.number(),
y: z.coerce.number(),
}).transform(({ x, y }) => ({ x: Math.round(x), y: Math.round(y) }));
// z.input 是转换前的类型,z.output 是转换后的类型
type PointInput = z.input<typeof PointSchema>; // { x: number; y: number }
type PointOutput = z.output<typeof PointSchema>; // { x: number; y: number }
// 更常用的转换:字符串 → 业务对象
const DateRangeSchema = z.object({
from: z.string().datetime(),
to: z.string().datetime(),
}).transform(({ from, to }) => ({
from: new Date(from),
to: new Date(to),
days: Math.round((new Date(to).getTime() - new Date(from).getTime()) / 86_400_000),
}));
transform 链可以多段组合,配合 pipe 可以做到"先校验、再转换、再校验":
const CsvSchema = z
.string()
.transform((s) => s.split(',').map((n) => Number(n)))
.pipe(z.array(z.number().int().nonnegative())); // 转换后二次校验
1.3 refine / superRefine:跨字段业务校验
refine 处理"字段级难以表达的跨字段约束",superRefine 更进一步提供精确的路径化错误:
const BookingSchema = z
.object({
checkIn: z.coerce.date(),
checkOut: z.coerce.date(),
roomCount: z.number().int().min(1).max(5),
})
.refine((b) => b.checkOut > b.checkIn, {
message: 'checkOut 必须晚于 checkIn',
path: ['checkOut'], // 错误定位到具体字段
});
superRefine 可以用 ctx.addIssue 追加多条错误,适合复杂规则:
const OrderSchema = z.object({
items: z.array(z.object({ sku: z.string(), qty: z.number().int().min(1) })),
coupon: z.string().optional(),
}).superRefine((data, ctx) => {
// 规则 1:无商品
if (data.items.length === 0) {
ctx.addIssue({ code: 'custom', message: '订单至少包含一件商品', path: ['items'] });
}
// 规则 2:使用优惠券但金额不足
if (data.coupon && data.items.reduce((s, i) => s + i.qty, 0) < 3) {
ctx.addIssue({ code: 'custom', message: '数量不满 3 件不可用优惠券', path: ['coupon'] });
}
});
2. Zod Effects 与 Schema 组合
2.1 preprocess:在校验之前预处理
z.preprocess 在校验之前对原始值做任意转换,比 coerce 更自由(比如解析 JSON、Trim 字符串):
const QuerySchema = z.object({
raw: z.preprocess((v) => {
if (typeof v === 'string') {
try { return JSON.parse(v); } catch { return v; }
}
return v;
}, z.object({ page: z.number(), size: z.number() })),
});
2.2 递归 Schema:树形数据
树形数据(分类、评论嵌套、AST)需要递归 schema。z.lazy 是官方方案:
interface CategoryNode {
id: string;
name: string;
children?: CategoryNode[];
}
const CategorySchema: z.ZodType<CategoryNode> = z.lazy(() =>
z.object({
id: z.string(),
name: z.string().min(1),
children: z.array(CategorySchema).optional(),
}),
);
type Category = z.infer<typeof CategorySchema>;
z.ZodType<CategoryNode> 作为类型注解打破循环引用,lazy 延迟求值直到运行时。
2.3 从 Schema 推导复杂类型:Schema → Type
Zod 的核心哲学是"一份 schema,双向类型"。复杂 schema 的推断能力让 z.infer 成为类型体操的天然来源:
const ApiResponseSchema = z.object({
code: z.number().int(),
data: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('user'), user: z.object({ id: z.string(), name: z.string() }) }),
z.object({ kind: z.literal('list'), items: z.array(z.string()) }),
]),
meta: z.object({ requestId: z.string().uuid() }).optional(),
});
type ApiResponse = z.infer<typeof ApiResponseSchema>;
// 自动得到精确的响应类型,无需手写接口
这是运行时类型安全边界的第一个支柱:单一来源(Single Source of Truth)——接口定义与运行时校验永远同步,不会出现"类型说 A、校验器验 B"。
3. Valibot 对比与选型
3.1 体积与 Tree-shaking
Valibot 最大的卖点是模块化与 tree-shaking 友好:每个校验器是一个独立函数,打包时只保留用到的部分。对比数据(理论值):
| 维度 | Zod | Valibot |
|---|---|---|
| 全量体积(min) | ~13KB+(未 tree-shake 时更大) | 基础 ~1KB,按需引入 |
| Tree-shaking | 整体库不可摇 | 每个 schema 独立可摇 |
| API 风格 | 链式 z.object().refine() | 函数组合 object({...}) + 独立校验器 |
| 错误对象 | z.ZodError(结构化 issues) | ValiError(issues 数组) |
| 类型推断 | z.infer / z.input | Input / Output 类型工具 |
| 依赖 | 零依赖 | 零依赖 |
import * as v from 'valibot';
const UserSchema = v.object({
id: v.number(),
name: v.string([v.minLength(1), v.maxLength(100)]),
email: v.pipe(v.string(), v.email()),
role: v.union([v.literal('user'), v.literal('admin')]),
});
type User = v.Output<typeof UserSchema>;
const result = v.safeParse(UserSchema, unknownData);
if (result.success) {
result.output; // 类型为 User
}
注意 API 差异:Valibot 的约束是数组参数 v.string([v.minLength(1)]),而不是链式;转换用 v.pipe(),相当于 Zod 的 pipe。
3.2 能力对照:核心特性
| 特性 | Zod | Valibot |
|---|---|---|
| 判别联合 | discriminatedUnion | variant(等价的) |
| 转换 | transform / pipe | pipe + transform |
| 复杂自定义错误 | superRefine + addIssue | check + addIssue |
| 递归 schema | z.lazy | recursive |
| Schema 元数据 | z.description 等 | metadata |
| 生态 | 最广(tRPC、react-hook-form、trpc) | 渐增 |
3.3 选型建议
- 追求最小包体(组件库、边缘函数、小程序):选 Valibot,只引入用到的校验器。
- 生态优先、团队熟悉、复杂业务规则多:选 Zod,生态工具链最完整(tRPC 默认、react-hook-form 集成、Prisma 生态)。
- 两者可共存:边界清晰时混合使用并无不可,但同一项目内尽量统一,避免心智负担。
实际体积影响要在真实打包中验证——下面用 bundlesize 思路做参考:
# 参考:仅打包一个 object + string 校验
npx esbuild --bundle --minify main.ts # Zod vs Valibot 各测一次,对比产物字节
4. API 输入输出契约
4.1 请求 / 响应 schema 契约
类型安全边界的第二个支柱:每个 API 端点都有明确的输入/输出 schema,形成契约:
// contracts.ts —— 端点契约集中定义
import { z } from 'zod';
export const CreateUserInput = z.object({
name: z.string().min(1).max(50),
email: z.string().email(),
password: z.string().min(8).max(72),
role: z.enum(['user', 'admin']).default('user'),
});
export const UserOutput = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
role: z.enum(['user', 'admin']),
createdAt: z.iso.datetime(), // 或 z.string().datetime()
});
export type CreateUserInput = z.infer<typeof CreateUserInput>;
export type UserOutput = z.infer<typeof UserOutput>;
客户端与服务器共享同一份 contracts(monorepo 中放共享包),就获得了端到端类型安全:
// 客户端:请求体由输入 schema 推导,响应由输出 schema 校验
async function createUser(input: CreateUserInput): Promise<UserOutput> {
const res = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(input),
});
const data = await res.json();
return UserOutput.parse(data); // 运行时验证响应
}
4.2 与服务端框架集成
在 Fastify 中,schema 直接绑定路由的 schema 选项;在 NestJS 中用 ValidationPipe:
// Fastify + 共享契约
import { CreateUserInput, UserOutput } from '@contracts/user';
server.post('/api/users', {
schema: {
body: CreateUserInput, // Fastify 内置校验 + 序列化
response: { 201: UserOutput },
},
handler: async (req, reply) => {
const input = req.body; // 已被校验并类型化为 CreateUserInput
// ...
},
});
// NestJS + zod 校验管道(配合 class-validator 之外的替代方案)
// 在控制器方法上用 zod guard 包装
async create(@Body() raw: unknown): Promise<UserOutput> {
const input = CreateUserInput.parse(raw);
// ...
}
4.3 契约测试:双向验证
契约不是写出来就完事,需要双向测试——既验证服务端"拒绝了坏输入",也验证"输出符合 schema":
// 契约测试(vitest 风格)
import { describe, it, expect } from 'vitest';
it('服务端拒绝非法 email', async () => {
const res = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'x', email: 'not-an-email', password: '12345678' }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.issues?.[0].path).toContain('email');
});
it('服务端响应符合 UserOutput', async () => {
const res = await fetch('/api/users/123');
const data = await res.json();
expect(() => UserOutput.parse(data)).not.toThrow(); // 输出契约
});
当 schema 变更时,契约测试让"服务端与客户端不同步"在 CI 里立刻暴露。
5. 与类型体操的深度结合
5.1 从 Schema 推导出边界类型
运行时校验与类型体操结合的最高价值形态:用类型体操把 schema 的能力"前移"到编译期。例如,从 schema 推导出"只读的输入类型":
import { z } from 'zod';
const EnvSchema = z.object({
DATABASE_URL: z.string().min(1),
JWT_SECRET: z.string().min(16),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
type Env = z.infer<typeof EnvSchema>;
// 类型体操:把 Env 变成"部分配置 + 只读"
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] };
type EnvTemplate = DeepReadonly<Partial<Env>>;
// 用途:在测试里只覆盖需要覆盖的字段,其余用默认
const testEnv: EnvTemplate = { LOG_LEVEL: 'debug' };
5.2 输入输出一致性检查
用类型工具强制"请求类型必须与校验 schema 输入一致":
type InputOf<S> = S extends z.ZodType<infer Out, infer Def, infer In> ? In : never;
type OutputOf<S> = S extends z.ZodType<infer Out> ? Out : never;
// 编译期断言:契约的输入输出类型与推导一致
type AssertEqual<A, B, M extends string> = (<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2) ? true : M;
type _Check = AssertEqual<
OutputOf<typeof UserOutput>,
{ id: string; name: string; email: string; role: 'user' | 'admin'; createdAt: string },
'UserOutput 与期望类型不一致'
>;
这类断言让"schema 变了但类型假设没更新"的问题在编译期暴露。
5.3 让 schema 驱动表单类型
前端表单是运行时校验的经典场景——用 schema 推导表单状态类型,配合 react-hook-form 获得双向类型:
const LoginFormSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
remember: z.boolean().default(false),
});
type LoginFormValues = z.input<typeof LoginFormSchema>; // 表单原始值
type LoginFormResult = z.output<typeof LoginFormSchema>; // 校验后的值
// 使用 zodResolver 接入 react-hook-form
// const { register, handleSubmit } = useForm<LoginFormValues>({
// resolver: zodResolver(LoginFormSchema),
// });
6. 最佳实践清单
| 场景 | 推荐 schema 手法 |
|---|---|
| 事件 / 状态机 | discriminatedUnion,享受判别收窄 |
| 外部 API 响应 | parse 严格校验(失败即抛,无法静默通过) |
| 用户输入容错 | safeParse + 错误收集(UI 展示) |
| 字符串 → 日期/数字 | coerce 或 preprocess + transform |
| 跨字段规则 | superRefine + addIssue(路径化错误) |
| 树形数据 | lazy / recursive |
| 打包体积敏感 | 评估 Valibot |
三条核心原则:
- 边界越少越安全:只在"进入可信域"的边界校验(API 入口、队列消费、DB 写前),不要在每行代码里重复
parse。 - 单一来源:schema 是类型与校验的唯一真源,不要手写与之并行的 interface。
- 契约要可测:每个边界配契约测试,schema 变更进 CI。
想深入 schema 与类型系统的组合能力,可参考 https://plumephp.com/typescript-type-level-programming/;把运行时校验放入大型项目的模块边界,可参考 https://plumephp.com/typescript-project-architecture-tsconfig/;在 Node 后端(Fastify/NestJS)中落地校验管道与错误处理,可参考 https://plumephp.com/typescript-nodejs-backend/ 与 https://plumephp.com/typescript-async-concurrency-control/。
7. 总结
运行时类型安全边界解决的是**“编译期信任无法覆盖运行时数据”**这一根本问题。Zod 的高级建模(判别联合、transform、superRefine、lazy 递归)把业务规则编码进 schema;Valibot 提供体积敏感场景的替代;而"契约共享 + 契约测试 + 类型体操断言"则让边界成为可维护的工程资产。
真正的类型安全 = 编译期类型(静态) × 运行时 schema(动态),两者在边界上精确对齐。把校验放在该放的地方、让 schema 成为唯一真源、用测试锁住契约——你的系统在任何"不可信数据"面前都保持诚实。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。