《TypeScript编程入门》13.2 Zod 模式验证与类型推导

本节介绍如何在运行时检查数据形状:用 Zod 声明 schema,用 z.infer 让编译期类型自动推导,告别「类型与校验规则各写一份」的漂移。内容覆盖 parse 与 safeParse 的取舍、issues 结构、optional/default/transform/refine 等修饰、z.input 与 z.output 的差异、判别联合与递归 schema,以及常见坑。

本节目标:读完这一节,你能用 Zod 声明一份 schema,并用 z.infer 从它自动推导出 TypeScript 类型,让「校验规则」与「类型定义」只维护一份;能区分 parse 与 safeParse 的使用场景,读懂 ZodError.issues 的结构;能用 optional、default、transform、refine 处理真实业务里的边界情况;能写出判别联合与递归 schema;并且知道 z.input 与 z.output 为什么可能不同。

13.2 Zod 模式验证与类型推导

上一节我们把问题摆清楚了:类型在运行时被擦除,as 断言只是承诺,边界数据必须真正检查一遍。

问题是,检查写成什么样?最朴素的做法是手写判断:

function isUser(value: unknown): value is User {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return typeof v.id === "number" && typeof v.name === "string";
}

这段代码有三个问题:写起来啰嗦、嵌套对象会迅速膨胀、而且它和 interface User 是两份独立维护的定义。后端加一个字段,你得改两处;改漏一处,类型和校验就漂移了。

我们需要的是「一份定义,两处生效」:既能被运行时执行,又能被编译器推导。这正是 schema 校验库要做的事,而 Zod 是当前 TypeScript 生态里最主流的选择。

从 schema 到类型:一个定义,两个用途

本书的 Zod 示例使用 zod@3.25.76(npm install --save-exact zod@3.25.76),以复现 flatten() 与下文错误结构。Zod 4 迁移指南 涉及 API 与错误格式变化,升级时需核对,不能直接混用两版输出。先看完整形态:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).optional(),
  role: z.enum(["admin", "editor", "viewer"]).default("viewer"),
  tags: z.array(z.string()).default([]),
});

// 从 schema 推导类型 —— 注意 typeof
type User = z.infer<typeof UserSchema>;

User 等价于手写的 { id: number; name: string; email: string; age?: number; role: "admin" | "editor" | "viewer"; tags: string[] }——编辑器悬停可以看到完全一致的结构。

几个细节值得留意:

  • z.enum([...]) 推导出的是字面量联合,不是 string。这和 7.1 联合类型与字面量类型 讲过的能力对上了。
  • z.number().int().positive() 推导出的仍然是 number。Zod 的校验规则不改变类型,它只影响运行时是否放行。
  • .default("viewer") 让 role 在输出里是必填的,而在输入里是可选的。这个不对称是理解 z.input / z.output 的钥匙。

parse 与 safeParse:抛错还是返回结果

// 方式一:失败抛 ZodError
const user = UserSchema.parse(raw);

// 方式二:失败返回结果对象,不抛错
const result = UserSchema.safeParse(raw);
if (result.success) {
  console.log(result.data.name); // 此处 result.data 是 User
} else {
  console.log(result.error.issues);
}

safeParse 的返回值是一个判别联合(7.2 判别联合 的实战应用):{ success: true; data: Output } | { success: false; error: z.ZodError }。

因为判别字段是 success,if (result.success) 之后编译器会自动收窄到成功分支,result.data 直接就是 User 类型——不需要任何断言。这是 schema 库最优雅的地方:校验结果本身携带了类型信息。

选择建议:

场景推荐理由
脚本、CLI、启动期配置parse失败就应该立刻崩溃
HTTP 请求体、表单safeParse需要把错误转成 400 响应
上游 API 响应safeParse需要记录问题并降级处理
测试断言parse失败即测试失败,信息足够

读懂 ZodError.issues

校验失败的细节全在 error.issues 里。给一段真实输入:

const bad = { id: -1, name: "", email: "not-an-email" };
const result = UserSchema.safeParse(bad);

if (!result.success) {
  console.log(JSON.stringify(result.error.issues, null, 2));
}

输出(节选两个):

[
  {
    "code": "too_small",
    "minimum": 1,
    "type": "number",
    "inclusive": false,
    "message": "Number must be greater than 0",
    "path": ["id"]
  },
  {
    "validation": "email",
    "code": "invalid_string",
    "message": "Invalid email",
    "path": ["email"]
  }
]

每个 issue 都有:

  • code:机器可读的错误类别,适合做统计与国际化映射;
  • path:出错字段的路径数组,嵌套对象会是 ["address", "city"];
  • message:默认英文提示,可以被 .min(1, "名字不能为空") 这类第二参数覆盖。

给表单用时,通常要压成「字段 → 消息数组」的形状:

const flat = result.error.flatten();
// {
//   formErrors: [],
//   fieldErrors: {
//     id: ["Number must be greater than 0"],
//     name: ["String must contain at least 1 character(s)"],
//     email: ["Invalid email"]
//   }
// }

formErrors 收集的是「不属于任何具体字段」的错误,比如整个对象级别的 refine 失败。

常用修饰符速查

API作用对推导类型的影响
.optional()允许 undefinedT | undefined
.nullable()允许 nullT | null
.nullish()允许 null 或 undefinedT | null | undefined
.default(v)缺失时填默认值输入可选,输出必填
.catch(v)任何失败都回退到 v永不失败,输出必为 T
.transform(fn)校验通过后转换输出类型由 fn 决定
.refine(fn, msg)自定义布尔校验类型不变
.superRefine(fn)多字段联动、可加多个 issue类型不变
.brand<"UserId">()打上名义类型标记变成不可与 number 互换的类型
.coerce.number()先转成数字再校验输入是 unknown

.brand() 值得一提:它把 number 变成名义类型,让 UserId 和 OrderId 不再互相赋值,弥补结构化类型的短板。这个技巧的完整讨论在 5.3 结构化类型与两者取舍 。

.refine:跨字段规则

单字段规则用链式 API 就够了,跨字段的必须用 refine:

const SignupSchema = z
  .object({
    password: z.string().min(8),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    message: "两次输入的密码不一致",
    path: ["confirm"], // 把错误挂到 confirm 字段上
  });

superRefine 是它的加强版,可以在一次回调里通过 ctx.addIssue({ code: z.ZodIssueCode.custom, message, path }) 追加多个问题——适合「开始时间不能晚于结束时间」这类需要同时报告多条冲突的规则。注意 addIssue 必须带 code,custom 是最通用的取值。

.transform:校验之后再加工

transform 让 schema 承担一部分「解析」职责,而不只是「放行」:

const DateSchema = z
  .string()
  .datetime()
  .transform((s) => new Date(s));

type Parsed = z.infer<typeof DateSchema>; // Date

这正好解决了 13.1 里 createdAt: Date 那个坑:JSON 里是字符串,解析后变成真正的 Date 对象。

z.input 与 z.output:为什么它们是两个类型

一旦用了 transform 或 default,schema 就有了两个不同的类型:进来时和出去时。

const Schema = z.object({
  name: z.string().trim(),
  age: z.coerce.number().default(0),
  createdAt: z.string().transform((s) => new Date(s)),
});

type In = z.input<typeof Schema>;
// { name: string; age?: unknown; createdAt: string }

type Out = z.output<typeof Schema>;
// { name: string; age: number; createdAt: Date }
  • z.infer<T> 是 z.output<T> 的别名——这是最容易记混的一点。
  • 函数签名里,参数位置应该用 z.input,返回值位置应该用 z.output:
function createUser(payload: z.input<typeof Schema>): z.output<typeof Schema> {
  return Schema.parse(payload);
}

如果参数写成了 z.output,调用方就必须自己提前转换,schema 的价值就丢了一半。

判别联合:z.discriminatedUnion

手写判别联合的校验很麻烦,Zod 提供了专门的原语:

const EventSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
  z.object({ type: z.literal("keypress"), key: z.string().min(1) }),
]);

type AppEvent = z.infer<typeof EventSchema>;
// { type: "click"; x: number; y: number }
// | { type: "keypress"; key: string }

function handle(e: AppEvent) {
  switch (e.type) {
    case "click":
      return e.x + e.y; // 收窄到 click 分支
    case "keypress":
      return e.key.toUpperCase(); // 收窄到 keypress 分支
  }
}

相比 z.union,discriminatedUnion 会先读判别字段再选分支,错误信息更精确(不会把两个分支的错误都堆给你),性能也更好。第 7 章的判别联合在这里完成了从「类型技巧」到「运行时保障」的闭环。

递归 schema:z.lazy

树形结构需要递归定义。类型可以自引用,但 schema 是值,直接自引用会触发「变量在初始化前使用」:

// ❌ Block-scoped variable 'CategorySchema' used before its declaration
const CategorySchema = z.object({ name: z.string(), children: z.array(CategorySchema) });

用 z.lazy 把求值推迟到真正解析的时候:

interface Category {
  name: string;
  children: Category[];
}

const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    name: z.string().min(1),
    children: z.array(CategorySchema),
  })
);

这里显式标注了 z.ZodType<Category>,因为递归推导会让编译器无从下手。代价是类型来自你手写的 interface,而不是从 schema 推导——递归场景下必须接受这一点,也意味着这个 interface 需要和 schema 同步维护。

未知字段策略:strip / passthrough / strict

后端多加一个字段时,默认行为是什么?Zod 默认 strip(剥掉未知字段):

const UserSchema = z.object({ id: z.number(), name: z.string() });

UserSchema.parse({ id: 1, name: "Ada", isAdmin: true });
// → { id: 1, name: "Ada" }  isAdmin 被丢弃

三种策略对比:

策略行为适用场景
默认(strip)丢弃未知字段解析上游响应,防止脏数据扩散
.passthrough()原样保留需要透传未知字段(如代理层)
.strict()未知字段报错契约测试、本地配置,抓拼写错误

一个实用组合:生产环境用 strip,测试环境用 strict。生产环境需要容忍上游演进,测试环境需要尽早发现拼写错误与契约漂移。

常见坑与报错

坑一:z.infer 忘了 typeof。

type A = z.infer<typeof UserSchema>; // ✅ 对
type B = z.infer<UserSchema>;        // ❌ UserSchema 是值,不是类型

报错信息:

'UserSchema' refers to a value, but is being used as a type here.
Did you mean 'typeof UserSchema'? ts(2749)

坑二:schema 与手写 interface 双写。 定义了 interface User 又写了 UserSchema,然后 parse 的结果 as User。这就是 13.1 的盲区原地复活。正确做法是只留 schema,类型用 z.infer 推导。

坑三:把 .optional() 与 .default() 混用。

z.string().optional().default("x"); // 输入 string | undefined,输出 string
z.string().default("x").optional(); // 输出变成 string | undefined

链式顺序会影响结果,写反了会让下游多出一层 undefined 判断。

坑四:以为 schema 能替代业务校验。 z.string().min(1) 只保证非空字符串;「这个用户名是否已被占用」需要查数据库,属于业务逻辑,应该放在 schema 之后。

与手写类型守卫的对比

回到本节开头那个 isUser 函数,现在可以给出结论了:

维度手写类型守卫Zod schema
类型推导需要手写 value is Userz.infer 自动
定义份数2 份(interface + 守卫)1 份
错误信息只有 true/false带 path 与 code 的结构化 issues
嵌套对象手写递归,极易出错自动组合
转换能力无transform / coerce
运行时开销最小有成本,通常可接受
包体积0增加依赖

对于零依赖、极简的检查(比如判断 typeof x === "string"),类型守卫仍然合适——7.3 类型守卫与控制流分析 里的技巧在日常开发中依然有用。但只要涉及对象结构、嵌套、错误提示,schema 就是更划算的选择。

延伸阅读:Zod 在表单场景下的完整实践,可以参考既有专题 /frontend-forms-validation-architecture/ 与 /typescript-zod-validation/ ;如果需要更细的性能取舍与替代方案对比,可以看 /typescript-runtime-validation-typesafe/ 。

小结

  • schema 的价值是「一份定义,两处生效」:运行时由 parse / safeParse 执行,编译期由 z.infer 推导,从根上消除类型与校验规则漂移。
  • safeParse 返回判别联合,if (result.success) 之后编译器自动收窄,无需断言;失败细节在 error.issues,可用 flatten() 压成表单友好的形状。
  • optional / nullable / default / catch / transform / refine 覆盖了绝大多数业务边界;default 与 transform 会让输入类型与输出类型分离,因此有了 z.input 与 z.output(z.infer 是 z.output 的别名)。
  • 判别联合用 z.discriminatedUnion,递归结构用 z.lazy 并显式标注 z.ZodType<T>。
  • 未知字段默认 strip;生产用 strip 容错、测试用 strict 抓错,是稳妥的组合。
  • 常见坑集中在:忘写 typeof、与手写 interface 双写、链式顺序颠倒、把 schema 当成业务校验的替代品。

有了 Zod,我们手里就有了一把能同时满足编译期和运行时的尺子。下一节要解决的是「尺子量在哪里」——把校验准确地放到进程的每一个入口:环境变量、HTTP 请求体、上游响应、消息队列,并处理契约演进与错误上报。

阅读导航:上一节:13.1 类型擦除带来的运行时盲区 · 下一节:13.3 API 契约与边界数据校验 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes