运行时校验与 Zod
TypeScript 能帮我们在编 辑器里发现不少问题,但它管不到运行时。接口返回错字段、表单传进来空值、环境变量没配、AI 输出结构跑偏,这些都不是 type 自己能兜住的。
Zod 很适合拿来补这块空白。可以把它理解成一层真的会在运行时执行的类型边界:先定义 schema,再用 schema 去校验输入,顺手把静态类型也推出来。
如果从整个站点的结构来看,它更像工程问题,不只是类型问题。
这篇真正关心的是:
- 接口入口怎么收边界
- 环境变量怎么尽早失败
- 表单校验怎么和类型对齐
- AI 输出和工具参数怎么别失控
所以把它放在 工程化 下面会比放在 TypeScript 里更顺。这里讲的是运行时边界,不是语法本身。
如果你想先看更完整的横向选型,可以先读 运行时校验库选型:Zod、Valibot、ArkType、TypeBox 等。
先抓住一件事:Zod 解决的不是“写类型”,而是“守边界”
只写类型时,我们通常会这样写:
type CreatePostInput = {
title: string;
content: string;
};
这在编辑器里很好用,但如果数据来自:
HTTP request- 表单提交
process.env- 队列消息
- 数据库存量脏数据
- AI 工具调用或结构化输出
那运行时拿到的值,照样可能和你想的不一样。
Zod 的基本思路,就是把“类型定义”和“运行时校验”写到同一份东西里:
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
});
type CreatePostInput = z.infer<typeof CreatePostSchema>;
这样有两个直接收益:
- schema 真能在运行时执行
CreatePostInput不需要再手写第二份
基础用法
1. 定义 schema
最常见的是 object、string、number、array 这些基础组合。
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.email(),
age: z.number().int().min(0).optional(),
tags: z.array(z.string()).default([]),
});
这里顺手能看到几件事:
- 字段规则和字段结构写在一起
.optional()、.default()这种语义很直白z.email()、z.uuid()这类格式校验不用自己手搓正则
2. parse 和 safeParse
如果你确认失败就该中断,直接用 parse。
const user = UserSchema.parse(input);
如果你要自己处理错误分支,通常更推荐 safeParse。
const result = UserSchema.safeParse(input);
if (!result.success) {
return {
ok: false,
errors: result.error.flatten(),
};
}
return {
ok: true,
data: result.data,
};
在日常业务里,safeParse 往往比 try/catch 更顺手,尤其是接口层、表单层和工具层。
3. 从 schema 反推类型
type User = z.infer<typeof UserSchema>;
如果 schema 有 transform 或 codec,输入和输出类型可能不同。这时可以分开拿:
type UserInput = z.input<typeof UserSchema>;
type UserOutput = z.output<typeof UserSchema>;
像“传进来是字符串,处理完变成 Date 或 number”这种场景,就很适合这么拆。
4. coerce 很适合处理“看起来像字符串”的输入
前端表单、URL query、环境变量,经常会把数字和布尔值都先变成字符串。Zod 4 里可以直接做显式转换:
const QuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(20),
});
这样你不用先手动 Number(...) 再继续校验。
5. 什么时候该加 refine
如果规则不是单字段能表达的,再上 refine 或 superRefine。别一上来就把所有逻辑都塞进去。
const PasswordSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string().min(8),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "两次密码输入不一致",
});
更稳的做法是,先把大部分规则留在基础 schema API 里。真到了跨字段约束、定制错误路径这些情况,再补 refine。
常见集成方式
1. Next.js Route Handler
这类场景很适合 Zod,因为请求体、query、headers 没一样能默认信任。
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
});
export async function POST(request: Request) {
const body = await request.json();
const parsed = CreatePostSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: parsed.error.flatten() },
{ status: 422 },
);
}
return Response.json({ ok: true, data: parsed.data });
}
这和当前仓库里“Next.js 写操作与接口层”那篇文档里的示例是同一思路:TypeScript 管编辑期,Zod 管接口入口。
2. 环 境变量
这是 Zod 最容易让人立刻感到省事的一块。很多奇怪 bug,根源其实不是业务逻辑,而是环境变量漏配、拼错或者类型不对。
import { z } from "zod";
const EnvSchema = z.object({
OPENAI_API_KEY: z.string().min(1),
AGENT_MODEL: z.string().default("gpt-4.1"),
AGENT_MAX_STEPS: z.coerce.number().int().positive().default(8),
});
export const env = EnvSchema.parse(process.env);
好处很直接:
- 缺配置时尽早失败
- 默认值集中放
- 代码里不必到处散落
process.env.X
3. React Hook Form
如果项目里表单比较多,Zod + React Hook Form 是很常见的组合。@hookform/resolvers 官方仓库直接提供了 zodResolver。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "必填"),
age: z.number().min(10),
});
type FormValues = z.infer<typeof schema>;
const form = useForm<FormValues>({
resolver: zodResolver(schema),
});
这套组合的好处不只是代码变短,更重要的是表单规则和类型终于不用分开维护。字段一改,只动一份 schema 就够了。
4. AI SDK 结构化输出
如果你在做 AI 应用,Zod 现在基本已经成了默认选项之一。AI SDK 的 Output.object() 支持直接吃 Zod schema。
import { generateText, Output } from "ai";
import { z } from "zod";
const RecipeSchema = z.object({
name: z.string(),
ingredients: z.array(z.string()),
});
const { output } = await generateText({
model: "openai/gpt-4.1",
prompt: "给我一个简单早餐配方",
output: Output.object({
schema: RecipeSchema,
}),
});
在这种场景里,Zod 的价值不只是推导类型,更重要的是替你守住模型输出边界。模型嘴上说自己返回 JSON,不代表它每次都老老实实按你的结构来。
5. OpenAI Agents SDK 工具参数
Agent 里的 function tool 参数天然适合用 schema 管。
import { Agent, tool } from "@openai/agents";
import { z } from "zod";
const lookupPolicy = tool({
name: "lookup_policy",
description: "根据主题返回内部政策摘要",
parameters: z.object({
topic: z.string(),
}),
execute: async ({ topic }) => {
return `topic: ${topic}`;
},
});
这也是当前仓库 OpenAI Agents SDK 指南 里已经在用的写法。工具参数这层不先收住,后面调试会很磨人。
6. JSON Schema / OpenAPI
Zod 4 已经内建 z.toJSONSchema()。如果你想把同一份 schema 继续拿去做 OpenAPI、文档生成,或者结构化输出描述,这条路会顺很多。
import { z } from "zod";
const PostSchema = z.object({
title: z.string(),
published: z.boolean(),
});
const jsonSchema = z.toJSONSchema(PostSchema, {
target: "openapi-3.0",
});
如果项目需要“一份定义,多处复用”,这条线值得早点知道。
7. codec 适合处理网络边界上的格式转换
这是 Zod 4 里比较容易被忽略,但很实用的一块。比如服务端传 ISO 日期字符串,业务代码里想直接用 Date,就可以用 codec。
import { z } from "zod";
const IsoDateCodec = z.codec(z.iso.datetime(), z.date(), {
decode: (value) => new Date(value),
encode: (value) => value.toISOString(),
});
const createdAt = IsoDateCodec.decode("2024-01-15T10:30:00.000Z");
这种写法比“每次拿到值都手动 new Date(...)”集中得多,也不容易漏。
当前仓库里已经有的 Zod 使用案例
这个仓库之前虽然没有单独的 Zod 专题页,但相关用法已经散在几篇文档里了。整理下来,大概就是下面这几类。
案例 1:接口层参数校验
在“Next.js 写操作与接口层”那篇文档里,已经给过一段 createPostSchema.safeParse(body) 的例子。
这类用法重点不在“怎么调 API”,而在这里:
- 请求入口先收口
- 错误尽量在业务逻辑之前暴露
- 返回给前端的错误结 构保持稳定
案例 2:Agent 项目的输入 schema
在 TypeScript 在 Agent 项目中的落地 里,已经有这类模式:
export const SearchDocsInputSchema = z.object({
query: z.string().min(1),
topK: z.number().int().positive().max(20).default(5),
});
export type SearchDocsInput = z.infer<typeof SearchDocsInputSchema>;
那篇文档讲得很直接:外部输入一多,只靠 type 不够。这个判断挺准,也很适合拿来当团队共识。
案例 3:环境变量收口
还是在同一篇 Agent 工程文档里,环境变量也用了 Zod 来收边界:
const EnvSchema = z.object({
OPENAI_API_KEY: z.string().min(1),
AGENT_MODEL: z.string().default("gpt-4.1"),
AGENT_MAX_STEPS: z.coerce.number().int().positive().default(8),
});
这类 schema 往往不大,但回报很高。尤其项目开始分环境、接 CI、接部署平台以后,很快就能感受到差别。
案例 4:Agent 工具参数
在 OpenAI Agents SDK 指南 里,工具 parameters 直接用 z.object(...) 定义。
这一层很关键,因为 tool call 不一定来自人手写代码,很多时候是模型自己在组装参数。参数 schema 写清楚,后面排查 tool 调用失败会轻松很多。
实际项目里怎么分层更顺
如果团队里准备正式用 Zod,我更建议按下面这套顺序来。
1. 先管入口,不要先追求“全项目统一改造”
优先级通常是:
HTTP request- 环境变量
- 表单提交
- AI 输出和工具参数
- 数据库存量数据清洗
先守最脏、最外层的边界,收益通常最大。
2. schema 和业务类型尽量靠近
别把所有 schema 都堆进一个 schemas.ts。更推荐按领域拆:
src/
modules/
post/
post.schema.ts
post.service.ts
post.types.ts
如果 schema 离消费点太远,最后很容易变成“明明有校验,但没人知道该复用哪份”。
3. 能 safeParse 的地方,优先 safeParse
接口层、表单层、工具层通常都属于“失败了也能正常返回”的场景。这时候直接返回错误结果,往往比让异常一路往外抛更好收。
4. 不要把所有逻辑都塞进 schema
schema 适合做:
- 结构校验
- 格式校验
- 边界值校验
- 轻量转换
它不适合承载复杂业务规则、数据库查询、权限判断。这些还是应该留在 service 或 domain 层。
5. 别为了“类型更优雅”重复写两套定义
如果已经有 Zod schema,就优先从它反推类型。再手写一份同结构 type,时间一长基本都会漂。
什么时候不一定非上 Zod
也别把 Zod 用成宗教。下面几种情况,就不一定值得马上引:
- 一次性脚本,输入来源很固定
- 完全内部调用、边界非常清楚的小函数
- 已经有成熟 schema 体系的老项目,迁移成本明显大于收益
它最适合的,还是那种边界多、输入不完全可信、而且会长期维护的项目。
推荐阅读
- TypeScript 项目接入
- TypeScript 项目案例与配置
Next.js 写操作 与接口层- TypeScript 在 Agent 项目中的落地
- OpenAI Agents SDK 指南