Zod 运行时 Schema 校验
介绍 Zod 如何把不可信运行时输入解析为具有 TypeScript 类型的输出,以及校验、转换与错误处理的边界。
[!info] related notes
- 所属 MOC:TypeScript MOC
- 关系笔记:TypeScript 静态类型与运行时校验
- 收窄:TypeScript 联合类型、收窄与类型守卫
- 项目入口:BodySense 项目 MOC
Zod 运行时 Schema 校验
[!abstract] 文档规格
- 类型:库资源与项目化学习笔记
- 读者:会写 TypeScript 类型,但还会在
JSON.parse后使用as T- 工具:Zod 4、TypeScript、Vitest
- 结果:能独立为一个 API 或流事件边界设计 Schema、错误策略和测试
这是什么
Zod 是 TypeScript 优先的运行时 Schema 校验库。TypeScript 接口会在编译后消失,而 Zod Schema 是实际执行的 JavaScript 值,因此能检查网络响应、表单、环境变量和存储数据。
它解决的是这条链路:
unknown 输入
-> Zod Schema 执行校验 / 转换
-> 成功:有类型的输出
-> 失败:结构化 ZodError
从外部输入到可信事件
先从最小流事件开始:
import * as z from 'zod';
const TextDeltaEventSchema = z.object({
version: z.literal(1),
seq: z.number().int().nonnegative(),
channel: z.literal('message'),
type: z.literal('message.text.delta'),
ids: z.object({
conversation_id: z.string().nullable().optional(),
run_id: z.string().nullable().optional(),
message_id: z.string().nullable().optional(),
}),
payload: z.object({ delta: z.string() }),
});
type TextDeltaEvent = z.infer<typeof TextDeltaEventSchema>;
类型连接是:
TextDeltaEventSchema(运行时值)
-> typeof TextDeltaEventSchema(Schema 的静态类型)
-> z.infer<...>(解析后的输出类型)
Schema 应放在网络或协议适配器附近。组件和 reducer 消费解析成功的 TextDeltaEvent,不应再次重复字段检查。
parse 与 safeParse
const event = TextDeltaEventSchema.parse(value);
parse(input: unknown) 成功时返回经过解析的深拷贝,失败时抛出 ZodError。适合“坏数据必须终止当前操作”的边界。
const result = TextDeltaEventSchema.safeParse(value);
if (!result.success) {
reportProtocolIssue(result.error.issues);
return;
}
dispatch(result.data);
safeParse 返回可辨识联合:
{ success: true, data: Output }
或
{ success: false, error: ZodError }
它适合流式协议和批量导入,因为调用者可以决定跳过单条记录、关闭连接或降级展示。
异步 refinement / transform 对应 parseAsync 与 safeParseAsync;没有异步规则时不要无故引入异步链路。
input 与 output 为什么可能不同
普通 Schema 的输入输出通常一致;转换、默认值和 coercion 会让它们分开:
const PageSchema = z.object({
page: z.coerce.number().int().positive().default(1),
});
type PageInput = z.input<typeof PageSchema>;
type PageOutput = z.output<typeof PageSchema>;
z.input:调用解析器前允许进入的值。z.output/z.infer:解析成功后业务代码得到的值。
不要把“能转换”误解为“应该宽松接收一切”。例如 JavaScript 的 Boolean('false') 是 true,通用 z.coerce.boolean() 可能不符合表单或环境变量语义;明确的字符串枚举通常更安全。
optional、nullable 与 default
.optional():允许undefined或属性缺失。.nullable():允许null。.nullish():同时允许null与undefined。.default(value):输入为undefined时产生输出默认值。
它们分别表达不同协议语义。数据库允许 null、JSON 缺失字段和 UI 暂未填写,不应全部用 optional 混为一谈。
联合协议优先判别字段
流事件天然适合判别联合:
const StreamEventSchema = z.discriminatedUnion('type', [
TextDeltaEventSchema,
StreamDoneEventSchema,
StreamErrorEventSchema,
]);
解析器先读取 type,再验证对应分支。这样运行时 Schema 与 TypeScript 的可辨识联合共享同一个协议判别字段。
错误策略属于产品设计
| 边界 | 常见策略 |
|---|---|
| 表单 | 把字段 path 和 message 显示给用户 |
| 单次 API | 抛出领域错误并进入错误页或重试 |
| SSE 流 | 记录协议错误;按严重度跳过事件或终止连接 |
| 批量导入 | 收集每条失败原因,继续处理有效记录 |
| localStorage | 丢弃过期结构并回退默认值 |
Schema 只提供证据;究竟如何恢复由业务层决定。
BodySense 的应用边界
适合第一轮落地的位置是 useSSEProcessor.ts 中 JSON.parse 之后、EVENT_MAP 分发之前。第二个入口是 consultationService.ts 的历史事件回放。
当前 Web 应用没有把 Zod 声明为直接依赖。虽然 pnpm 锁文件里因 AI SDK 等包存在 Zod,业务代码也不应依赖传递依赖。决定采用时,应先显式添加到 Web 应用依赖,再提交 Schema 与测试。
局限与选择边界
- Schema 与后端模型仍可能手工漂移,需要 fixture 或 Schema 生成策略。
- 大型联合和复杂 transform 会增加 bundle、启动与维护成本。
- 数据在可信内部边界中不需要层层重复 parse。
- 只有类型守卫就够用的小对象,不必为了统一强制使用库。
- Schema 校验不等同于业务授权、数据库约束或安全清洗。
从阅读到独立实现
- 预测:缺少
payload.delta的 JSON 经过as StreamEvent和safeParse分别会发生什么? - 模仿:补出
stream.done与stream.error两个 Schema,组成判别联合。 - 重建:不看本页,根据 StreamEvent 需求重新实现三个分支和测试。
- 调试:设计一个 coercion 导致空字符串变成错误业务值的用例。
- 迁移:为历史回放选择“坏一条全失败”或“跳过坏记录”,并说明可观测性要求。