☰
Zod 实战指南:3 步搞定 TypeScript 数据校验,从表单到 API 入参
2026/10/24 5:22:32 网站建设 项目流程

Zod 实战指南:3 步搞定 TypeScript 数据校验,从表单到 API 入参

【免费下载链接】zodTypeScript-first schema validation with static type inference项目地址: https://gitcode.com/GitHub_Trending/zo/zod

凌晨三点,线上报警:一个字段没做类型校验,把字符串 "100" 当成了数字存进数据库,下游服务直接炸掉。这类事故的共同点,是数据在运行时绕过了一切静态检查。Zod 就是一个解决这个问题的库:用 TypeScript 声明模式(schema),再对任意未知数据做运行时校验和类型推断,输入不可信,输出强类型。

先搞清楚:Zod 数据校验能替你做什么

装一下就够用,整个库零外部依赖:

npm install zod

定义一个 schema 并解析数据

Zod 数据校验的基本单元是 schema。这段代码做的事是:声明一个玩家对象的形状,然后用parse校验任意输入。

import * as z from "zod"; // 声明结构:username 必须是字符串,xp 必须是数字 const Player = z.object({ username: z.string(), xp: z.number(), }); // 输入是 unknown,不信任任何来源 const input: unknown = { username: "billie", xp: 100 }; // 校验通过,返回强类型的深拷贝 const data = Player.parse(input); console.log(data.username); // "billie"

搞定了"输入进来怎么办",接下来看类型从哪来。

从 schema 推断 TypeScript 类型

不用再手写 interface,类型直接从模式推导出来。这段代码做的事是:把 schema 推断出的类型提取出来,当作普通类型使用。

import * as z from "zod"; const Player = z.object({ username: z.string(), xp: z.number(), }); // 提取推断类型:{ username: string; xp: number } type Player = z.infer<typeof Player>; const player: Player = { username: "billie", xp: 100 };

有转换逻辑时,输入和输出类型会分开,z.input/z.output可以分别提取。类型有了,那校验失败的时候呢?

用 safeParse 拿到结构化的错误

parse失败会抛ZodError,但更常用的是safeParse,它返回一个判别联合,不用 try/catch。这段代码做的事是:校验失败时把每个字段的错误逐条列出来。

const result = Player.safeParse({ username: 42, xp: "100" }); if (!result.success) { // 每个问题都带 path 和 message,能直接定位到字段 console.log(result.error.issues); // [ // { expected: "string", code: "invalid_type", path: ["username"], message: "Invalid input: expected string" }, // { expected: "number", code: "invalid_type", path: ["xp"], message: "Invalid input: expected number" } // ] } else { const player = result.data; // { username: string; xp: number } }

实战一:注册表单的 Zod 数据校验

上面三个能力拼起来,就是一个完整的表单校验器。这段代码做的事是:用链式方法约束每个字段,再用refine写跨字段规则,包括异步检查。

import * as z from "zod"; const RegisterForm = z .object({ // 用户名:3-20 位,只允许字母、数字、下划线 username: z.string() .min(3, "用户名至少 3 个字符") .max(20, "用户名最多 20 个字符") .regex(/^[a-zA-Z0-9_]+$/, "只能包含字母、数字和下划线"), // 邮箱:v4 用 .check() 挂校验项 email: z.string().check(z.email("请输入有效的邮箱")), // 密码:至少 8 位,且同时包含大小写和数字 password: z .string() .min(8, "密码至少 8 位") .regex(/[A-Z]/, "需要大写字母") .regex(/[a-z]/, "需要小写字母") .regex(/\d/, "需要数字"), confirmPassword: z.string(), }) // 跨字段校验:两次密码必须一致 .refine((d) => d.password === d.confirmPassword, { message: "两次输入的密码不一致", path: ["confirmPassword"], // 错误定位到这个字段 }) // 异步校验:用户名查重 .refine(async (d) => await checkUsername(d.username), { message: "用户名已被占用", path: ["username"], }); // 含异步 refine,用 safeParseAsync const result = await RegisterForm.safeParseAsync(formValues);

表单里checkUsername是你自己的查重函数,返回Promise<boolean>即可。表单数据从前端来,接口数据从外部服务来——下一段看后者。

实战二:给 API 入参加个守门员

接口入参的校验思路和表单一样,区别在于错误要按字段返回给调用方,而且往往带分页、ID 这类约束。这段代码做的事是:定义入参 schema,校验失败时把 issues 按 path 聚合回显。

import * as z from "zod"; // 入参 schema:uuid 校验 + 分页参数范围 const ListUsersInput = z.object({ page: z.number().int("page 必须是整数").min(1, "page 最小为 1"), pageSize: z.number().int().min(1).max(100, "pageSize 最大 100"), userId: z.string().uuid("userId 必须是合法的 UUID").optional(), }); export async function listUsers(body: unknown) { const result = await ListUsersInput.safeParseAsync(body); if (!result.success) { // 把错误按字段聚合成 { 字段名: 消息 } 回显给调用方 const fieldErrors: Record<string, string> = {}; for (const issue of result.error.issues) { const key = issue.path.join(".") || "(root)"; fieldErrors[key] = issue.message; } return { status: 400, errors: fieldErrors }; } // 从这里开始,input 就是强类型,直接用 const input = result.data; return { status: 200, data: await queryUsers(input) }; }

到这里,"校验数据"已经覆盖得差不多了。接下来是几个不那么显眼、但项目里很快会用到的用法。

进阶:错误中文化、编译提速、迷你版

错误消息太英文?一行配置换中文 locale

问题是:默认错误消息是英文,直接透传给用户不体面,全项目手写 message 又太累。做法:给z.config挂一个 locale。效果:所有没显式传 message 的校验项都会用中文报错。

import * as z from "zod"; import zhCN from "zod/locales/zh-CN"; // 全局生效:后续未指定 message 的错误都用中文 z.config(zhCN); const S = z.object({ age: z.number().int("age 必须是整数") }); S.safeParse({ age: "xx" }); // issues[0].message => "无效的类型:预期 number,实际 string"

仓库里 packages/zod/src/v4/locales/ 内置了 60 多个语言,按文件路径按需引入,没有的语言可以照模板自己写一个。

热路径上把校验提速:z.compile(schema)

问题是:每次请求都要跑一遍复杂 schema,解析开销肉眼可见。做法:用z.compile把 schema 提前编译成快速路径。效果:官方 README 给出的 55 个 schema 基准测试里,中位数提速 2.4 倍,大对象或大数组场景约 9 倍。

import * as z from "zod"; const Player = z.object({ username: z.string(), xp: z.number() }); // 返回一个"编译版" schema,用法和原来完全一样 const FastPlayer = z.compile(Player); const data = FastPlayer.parse({ username: "billie", xp: 100 });

注意两点:含异步 refine 的 schema 无法编译,z.compile会原样返回,不会报错;从编译版 schema 再派生(比如.refine())会回到未编译状态,记得对最终 schema 编译。

体积敏感的场景:zod/mini

问题是:完整版带着你可能永远用不到的 API,bundle 里每一字节都是成本。做法:改用zod/mini入口,只保留声明和校验的骨架。效果:核心 API 保持一致,体积显著更小,且支持 tree-shaking。

import * as z from "zod/mini"; const User = z.object({ name: z.string(), age: z.number().check(z.int(), z.positive()), }); // 迷你版的 parse 是函数式:schema 和输入分开传 const data = z.parse(User, { name: "Colin", age: 30 });

和同类库一句话的区别

Joi、Valibot 这类运行时校验库也能干这些活,但 Zod 是类型系统优先设计的:schema 即类型来源,z.infer一次搞定静态类型,不用在"类型定义"和"校验规则"之间来回同步。

接下来做什么

两件事:第一,npm install zod,把"实战一"的表单校验原样跑起来,再对照 packages/zod/README.md 的 API 列表补齐你缺的字段;第二,打开 packages/zod/src/v4/classic/tests/ 挑几个测试文件读,那里是现成的用法示例库。用法上的疑问,直接去仓库提 issue 讨论,比翻文档快。

【免费下载链接】zodTypeScript-first schema validation with static type inference项目地址: https://gitcode.com/GitHub_Trending/zo/zod

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询