Zod 是当下最流行的 TypeScript 运行时类型校验 库。很多开发者误以为用了 TypeScript 就安全了——但 TS 的类型只存在于编译期,一旦面对外部输入(接口响应、表单提交、环境变量),类型系统就彻底失效。Zod 让你用一份 schema 同时完成运行时校验与静态类型推导,把”类型安全”从编译阶段一路延伸到运行阶段。本文用可落地的代码,讲清 Zod 的核心用法与最佳实践。
一、为什么需要运行时校验
TypeScript 的类型检查发生在编译(tsc / 打包)阶段,产物是纯 JavaScript,类型信息在运行时被完全抹除。这意味着下面这段”类型安全”的代码,在运行时会照常执行:
function getUserName(u: { name: string }) {
return u.name.toUpperCase();
}
// 运行时传入结构不对的数据,TS 根本拦不住
getUserName(JSON.parse('{"name": 123}')); // 直接崩溃
凡是跨越”信任边界”的数据——后端接口返回、用户表单、配置文件、第三方 Webhook——都必须在运行时被明确校验。过去我们常用 if 链或 joi/yup,但它们与 TS 类型是”两套体系”,改一处要同步两处。Zod 的杀手锏,是 schema 即类型:定义一次,编译期有类型、运行期有校验。
真实项目里,缺了运行时校验的代价往往很贵:某次后端把 id 从 number 改成 string,前端照旧调用 .toUpperCase() 直接白屏;又或者配置中心的端口被写成字符串,Node 启动后监听在非法端口上静默失败。这类 bug 在 TypeScript 编译期毫无征兆,只有 Zod 这类运行时守卫能在数据进入核心逻辑前就把它们拦下,避免把脏数据一路传播到数据库或更下游的服务。
二、Zod 核心:定义 schema 并推断类型
一切从 z.object 开始。下面的 schema 描述了一个用户对象,z.infer 会反向推导出对应的 TS 类型,你完全不用手写 interface:
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().nonnegative().optional(),
role: z.enum(["admin", "editor", "viewer"]),
});
// 推导出的类型与 schema 永远一致,改 schema 类型自动变
type User = z.infer<typeof UserSchema>;
// { id: number; name: string; email: string; age?: number; role: "admin" | "editor" | "viewer" }
校验通过 .parse()(失败抛错)或 .safeParse()(返回结果对象,不抛错)。生产代码几乎永远用 safeParse:
const result = UserSchema.safeParse(jsonFromApi);
if (!result.success) {
console.error(result.error.issues); // 可读的错误明细
} else {
const user = result.data; // 这里 user 是类型安全的 User
}
三、校验 API 响应与请求体
把 Zod 放在”边界”上是它最大的价值。下面用同一个 schema 校验后端返回、并在 Node 服务里校验请求体:
// 前端:校验不可信的接口响应,而不是盲目信任后端
async function fetchUser(id: number) {
const res = await fetch(`/api/users/${id}`);
const json = await res.json();
return UserSchema.parse(json); // 失败立即抛出,fail-fast
}
// 后端(Express):校验进来的请求体
app.post("/api/users", (req, res) => {
const parsed = UserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.format() });
}
// parsed.data 已保证结构正确,可放心入库
saveUser(parsed.data);
});
这样前后端共享同一份 schema(通过共享类型文件或 tRPC 这类方案),接口契约就再也不会”悄悄漂移”。这也是我们在 TypeScript 高级类型体操 一文中强调的”类型即契约”理念在运行时的延伸。
四、表单校验:从手写 if 到声明式
表单是运行时校验的高频场景。用 .refine() 表达跨字段规则(如”两次密码一致”),比堆 if 清爽得多:
const SignupSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
message: "两次输入的密码不一致",
path: ["confirm"], // 错误精确挂到 confirm 字段
});
const r = SignupSchema.safeParse(formValue);
if (!r.success) {
// 把 issues 映射成 { 字段: 错误信息 } 展示在界面上
const fieldErrors = r.error.flatten().fieldErrors;
}
在 Vue3 + TypeScript 工程化 的项目里,可以把 schema 直接接到 computed 或表单库的校验钩子上,实现”类型驱动”的表单。
五、与前端框架集成
Zod 不绑定框架,与 React、Vue 都能自然配合。下面是个最小化的 React Hook,把 schema 变成实时校验:
function useZodForm<T>(schema: z.ZodType<T>) {
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (value: unknown) => {
const r = schema.safeParse(value);
setErrors(r.success ? {} : r.error.flatten().fieldErrors as any);
return r.success;
};
return { errors, validate };
}
在 Next.js 与 React Server Components 架构里,Zod 常被放在 Server Action 或 Route Handler 的入口,作为”服务端最后一道防线”,既校验用户输入,又给内部逻辑提供稳定类型。
六、进阶技巧速查
Zod 的方法非常多,下面这张表列出实战里最高频的几个,记住它们能覆盖 80% 场景:
| 方法 | 作用 | 典型场景 |
z.infer | 从 schema 推导类型 | 替代手写 interface |
z.enum | 限定字符串联合 | 角色、状态机 |
.refine | 自定义跨字段规则 | 密码确认、范围 |
.transform | 校验后转换值 | 字符串转 Date / number |
z.discriminatedUnion | 带标签的联合类型 | 多种事件 / 消息体 |
.brand | 给类型打”印记”防混用 | UserId 与 OrderId 区分 |
两个值得单独强调的技巧。第一,.transform() 能在校验通过后顺手把数据”洗”成你要的形状:
const IsoDate = z.string().transform((s) => new Date(s));
IsoDate.parse("2026-09-23"); // 直接得到 Date 对象
第二,错误信息可以整体格式化,方便对接 i18n 或前端组件:
const msg = UserSchema.safeParse(bad).error?.format();
// { email: { _errors: ["Invalid email"] } }
再补一个高频场景:当接口返回多种”形状”不同的数据时(例如埋点上报的多种事件),用 z.discriminatedUnion 比普通的 z.union 更安全也更快——它靠一个固定的判别字段(下面的 type)把数据路由到对应 schema,类型推导也最精准:
const ClickEvent = z.object({ type: z.literal("click"), x: z.number(), y: z.number() });
const ScrollEvent = z.object({ type: z.literal("scroll"), top: z.number() });
const TrackEvent = z.discriminatedUnion("type", [ClickEvent, ScrollEvent]);
TrackEvent.parse({ type: "scroll", top: 100 }); // 校验通过
TrackEvent.parse({ type: "click", top: 100 }); // 失败:click 事件不需要 top 字段
判别联合让”一个字段决定整体结构”的协议在 TS 侧有了完整类型支撑,配合 IDE 自动补全,写多形态数据处理逻辑时几乎不会漏判分支。
七、性能与最佳实践
Zod 很方便,但用错地方会拖慢系统。三条原则:
- 只在边界校验。接口入口、表单提交、配置加载才用 Zod;函数内部的纯计算不要层层
parse,否则既是性能浪费也是过度防御。 - 复用 schema。把 schema 抽到共享模块(如
schemas/user.ts),前后端共用,避免重复定义导致契约漂移。 - 错误要可读。对外暴露的错误用
.format()或自定义中文文案,别把原始 stack 丢给用户。
用 高效的开发工具链 配合 Zod 的 TS 插件,还能在编辑器里实时看到 schema 推导出的类型提示,写代码体验更顺。性能敏感的热路径若担心 Zod 开销,可考虑 zod-mini 这类体积更小的变体,或只在入口做一次校验后全程信任。
八、真实项目里的三个典型坑
即便用上了 Zod,下面三个坑依然常见,提前避开能省不少调试时间。
- 数字还是字符串。接口常把
id、port以字符串形式返回,z.number()会直接判失败。要么在 schema 用z.coerce.number()做隐式转换,要么在网关层统一清洗,别让前端到处做Number()强转。 - 把校验放在了错误的地方。有人图省事,给每一个内部函数都包一层
parse,结果 CPU 大量消耗在重复校验上。记住:Zod 是”边界守卫”,只在信任边界用一次,进入内部后类型系统已经替你保证。 - 忽略可选字段的默认。
.optional()得到的是undefined而非默认值,下游用到时仍要判空;需要兜底值时用.default(x),解析后会直接补上。
把这三个细节固化进团队的 schema 规范,Zod 带来的类型红利才能稳定兑现,而不是变成又一处需要人工盯防的隐患。
九、总结
Zod 的价值不在于”会校验”,而在于它把类型即文档、类型即契约的理念真正延伸到了运行时:一份 schema,编译期给你类型,运行期给你保护。把它固定在系统的信任边界上——API 出入口、表单、配置——你就能在保留 TypeScript 开发效率的同时,挡住绝大多数因数据结构不符引发的线上事故。对于追求工程健壮性的 TypeScript 项目,Zod 几乎是必装的那一个。




