把 TypeScript 接口转成 Zod Schema:运行时校验该怎么补
TypeScript 类型编译后就消失了,运行时拿不到数据形状的保证。这篇讲清楚为什么要把 interface 转成 Zod schema,z.object 和 z.string 怎么写,表单和 API 响应怎么校验,以及怎么省掉手写。
把 TypeScript 接口转成 Zod Schema:运行时校验该怎么补
写 TypeScript 久了会习惯一种安全感:类型对了,代码就不会出问题。可这种安全感只覆盖到编译那一刻。tsc 把类型擦掉,产出的 JavaScript 里没有任何一行检查 res.json() 回来的东西到底是不是你声明的那个形状。类型是给编译器看的,数据是运行时来的,这两件事之间隔着一条你以为已经焊死、其实只是画了条线的边界。
TypeScript 类型在编译后会消失
这是最容易被忽略的一点。interface ApiResponse { id: number; name: string } 写得再严谨,编译完也只剩下空气。你写
const data = await res.json() as ApiResponse
这个 as 不是检查,是一句承诺。TS 选择相信你,但服务端没在这份合同上签字。它可以把 id 返回成字符串,可以漏掉 name,可以多塞一个字段。等到某一层代码真的去读 data.name.trim(),你看到的是三层调用栈之外的 undefined is not a function,而不是 "服务端少给了 name"。
运行时校验补的就是这条缝。Zod 让你在数据进门那一刻验一遍,形状不对当场抛出可读的 ZodError,而不是带着脏数据一路往下跑。
z.object 和 z.string:schema 长什么样
Zod 的写法和 TS 类型几乎是一一对应,只是从"声明"变成了"能在运行时执行的对象"。看一个真实的输入输出。
输入,一个普通的 TypeScript 接口:
interface User {
id: number;
email: string;
role: 'admin' | 'user';
age?: number;
}
转出来的 Zod schema:
export const UserSchema = z.object({
id: z.number(),
email: z.string(),
role: z.enum(["admin", "user"]),
age: z.number().optional(),
}).strict();
export type User = z.infer<typeof UserSchema>;
几个细节值得留意。'admin' | 'user' 这种全字符串字面量的联合,折叠成了 z.enum;可选的 age? 接了 .optional();最后那行 z.infer 把 schema 反推回静态类型,于是你只维护 schema 一处,类型和校验同源。.strict() 是个有主张的默认:Zod 默认会把未声明的 key 悄悄删掉,在 API 边界上这几乎总是错的,服务端多给了字段你应该知道,而不是被默默吞掉。
这套对应关系我一开始是手敲的。json-formatter、regex-tester 这类工具我每天都在用,但 interface 转 schema 这件事手写起来又机械又容易漏,联合类型该不该当 enum、T | null 该用 .nullable() 还是 .optional(),每次都要想一遍。后来直接把这步交给 /zh/t/typescript-to-zod-schema/,粘 interface 拷 schema,省下来的就是这些容易出错的重复判断。
表单校验:让 schema 和 DTO 严格对齐
前端表单最常见的漏洞,是表单校验逻辑和后端 DTO 各写各的,慢慢就漂走了。Zod schema 能让两边共用一份契约。
假设接口收的是 CreateUserDto,把这个 DTO 转成 CreateUserDtoSchema 之后,前端用
useForm({ resolver: zodResolver(CreateUserDtoSchema) })
喂给 react-hook-form,后端在 handler 里加一行 CreateUserDtoSchema.parse(req.body)。同一份 schema,前端拦住格式不对的提交,后端再兜一道,改 DTO 的时候只改一处。表单和接口不会再因为某次只改了一边而对不上。
API 响应校验:safeParse 拿到的是分支结果
校验响应时,我更常用 safeParse 而不是 parse。parse 失败直接抛异常,safeParse 返回一个区分式联合:
const result = SearchParamsSchema.safeParse(
Object.fromEntries(url.searchParams)
);
if (!result.success) {
return renderError(result.error);
}
const data = result.data; // 这里 data 已经是收窄过的类型
{ success: true, data } | { success: false, error } 这个结构可以直接喂进错误 UI,不用 try/catch 包一层。校验 query 参数、校验 webhook payload、加载配置文件时校验一遍,都是这个套路。配置文件尤其值得在启动时 z.array(FlagSchema).parse(...) 一下,坏配置在启动崩,总比在生产环境某条路径第一次读到它时才崩要好。
省掉手写:从 JSON 到类型再到 schema
如果你的类型本来就是从 JSON 样本推出来的,这条链能闭环。先用 /zh/t/json-to-typescript-interface/ 从一份 Stripe webhook 样本推出 interface,再把这段 TS 转成 Zod schema。静态类型和运行时校验都来自同一份 payload,Stripe 加字段时两边重新生成就行,不会一边更新一边忘掉。
需要说清楚的是,Zod 只能表达运行时存在的东西。条件类型、mapped type、template literal type、infer、keyof 这些是编译期的形状操纵,运行时没有对应物,转换时会落成 z.unknown() 并在输出上方列出 warning,提醒你哪几条要手改。这是"粘了再改"的工作流,不是承诺把任何 TS 写法都自动搞定。
把类型和校验当成同一件事的两面来维护,边界上的那些 undefined is not a function 会少很多。运行时校验不是给类型系统挑刺,而是把编译后消失的那层保证,重新补回到数据真正进门的地方。
Made by Toolora · Updated 2026-06-13