Zod 运行时 Schema 校验

介绍 Zod 如何把不可信运行时输入解析为具有 TypeScript 类型的输出,以及校验、转换与错误处理的边界。

#type / resource #status / growing #tech / dev / frontend #package / zod #resource / typescript

[!info] related notes

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 对应 parseAsyncsafeParseAsync;没有异步规则时不要无故引入异步链路。

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():同时允许 nullundefined
  • .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.tsJSON.parse 之后、EVENT_MAP 分发之前。第二个入口是 consultationService.ts 的历史事件回放。

当前 Web 应用没有把 Zod 声明为直接依赖。虽然 pnpm 锁文件里因 AI SDK 等包存在 Zod,业务代码也不应依赖传递依赖。决定采用时,应先显式添加到 Web 应用依赖,再提交 Schema 与测试。

局限与选择边界

  • Schema 与后端模型仍可能手工漂移,需要 fixture 或 Schema 生成策略。
  • 大型联合和复杂 transform 会增加 bundle、启动与维护成本。
  • 数据在可信内部边界中不需要层层重复 parse。
  • 只有类型守卫就够用的小对象,不必为了统一强制使用库。
  • Schema 校验不等同于业务授权、数据库约束或安全清洗。

从阅读到独立实现

  1. 预测:缺少 payload.delta 的 JSON 经过 as StreamEventsafeParse 分别会发生什么?
  2. 模仿:补出 stream.donestream.error 两个 Schema,组成判别联合。
  3. 重建:不看本页,根据 StreamEvent 需求重新实现三个分支和测试。
  4. 调试:设计一个 coercion 导致空字符串变成错误业务值的用例。
  5. 迁移:为历史回放选择“坏一条全失败”或“跳过坏记录”,并说明可观测性要求。

官方入口

创建于 2026/8/1 更新于 2026/8/1