跳到主要内容

运行时校验与 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

最常见的是 objectstringnumberarray 这些基础组合。

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. parsesafeParse

如果你确认失败就该中断,直接用 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 有 transformcodec,输入和输出类型可能不同。这时可以分开拿:

type UserInput = z.input<typeof UserSchema>;
type UserOutput = z.output<typeof UserSchema>;

像“传进来是字符串,处理完变成 Datenumber”这种场景,就很适合这么拆。

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

如果规则不是单字段能表达的,再上 refinesuperRefine。别一上来就把所有逻辑都塞进去。

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. 先管入口,不要先追求“全项目统一改造”

优先级通常是:

  1. HTTP request
  2. 环境变量
  3. 表单提交
  4. AI 输出和工具参数
  5. 数据库存量数据清洗

先守最脏、最外层的边界,收益通常最大。

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 体系的老项目,迁移成本明显大于收益

它最适合的,还是那种边界多、输入不完全可信、而且会长期维护的项目。

推荐阅读

官方文档