乐于分享
好东西不私藏

Zod入门教程:9亿周下载,TypeScript生态的数据校验’通用语言’

Zod入门教程:9亿周下载,TypeScript生态的数据校验’通用语言’

目录

  • [[#第1章 概述与生态]]
    • [[#1.1 什么是 Zod]]
    • [[#1.2 生态地位]]
    • [[#1.3 设计理念]]
  • [[#第2章 基础类型定义]]
    • [[#2.1 原始类型]]
    • [[#2.2 对象 object]]
    • [[#2.3 数组与元组]]
    • [[#2.4 联合与枚举]]
    • [[#2.5 判别联合 discriminatedUnion]]
    • [[#2.6 递归 schema lazy]]
  • [[#第3章 校验与约束]]
    • [[#3.1 链式校验方法]]
    • [[#3.2 自定义校验 refine 与 superRefine]]
    • [[#3.3 默认值与可选可空]]
    • [[#3.4 强制类型转换 coerce]]
    • [[#3.5 错误恢复 catch]]
  • [[#第4章 解析与类型系统]]
    • [[#4.1 parse 与 safeParse]]
    • [[#4.2 错误格式化]]
    • [[#4.3 类型推导 z.infer]]
    • [[#4.4 JSON Schema 转换 toJSONSchema]]
  • [[#第5章 转换与 Schema 操作]]
    • [[#5.1 数据转换 transform]]
    • [[#5.2 预处理 preprocess]]
    • [[#5.3 Schema 串联 pipe]]
    • [[#5.4 Schema 组合 pick omit partial extend merge]]
    • [[#5.5 异步校验 parseAsync]]
  • [[#第6章 运行时机制与源码]]
    • [[#6.1 源码架构 v4]]
    • [[#6.2 _def 配置单与 checks 数组]]
    • [[#6.3 parse 执行流程]]
    • [[#6.4 Schema 即配置的设计]]
  • [[#第7章 工程实践]]
    • [[#7.1 interface 与 z.object 的选择]]
    • [[#7.2 常见坑点与反模式]]

第1章 概述与生态

1.1 什么是 Zod

Zod 是一个 TypeScript-first schema 声明与验证库。核心思想:用代码定义数据结构,自动推导 TypeScript 类型,同时提供运行时校验拦截非法数据。

GitHub: https://github.com/colinhacks/zod Stars: 42.9k

TypeScript-first:Zod 的设计优先级是”先有 TS 类型”,而不是”先有 JS 验证”。传统验证库(如 Joi)在 JS 里写校验规则,类型需要额外维护接口。Zod 反过来——写 schema,TS 类型自动从 schema 推导(z.infer),不用再写一遍 interface。schema 改则类型自动跟着改,不会不一致。

Schema:意思是”数据结构的定义/蓝图”。来自数据库和 JSON Schema 标准。

一个 Zod schema 同时干三件事:

① 文档——一眼看出数据长什么样;

② 运行时校验——.parse() 检查真实数据是否符合描述;

③ 类型推导——z.infer 把它变成 TS 类型。

最小案例:

import { z } from 'zod';// 定义 schema — 同时是类型源头和运行时校验器const UserSchema = z.object({name: z.string().min(2).max(20),age: z.number().int().positive(),email: z.email(),});// 推导 TypeScript 类型(编译时)type User = z.infer<typeof UserSchema>;//    ^? { name: string; age: number; email: string }// 运行时校验 — 拦截非法数据const result = UserSchema.safeParse({ name'A'age: -1email'bad' });//    ^? { success: false, error: ZodError }// result.error.issues → [//   { path: ['name'], message: 'Invalid input: expected string, received number' }//   ...// ]

作用:一份定义同时解决两个问题——编译期 TypeScript 类型安全 + 运行时数据校验(API 入参、表单、配置文件等场景)。

1.2 生态地位

Zod 的生态辐射极广,几乎覆盖了整个 TypeScript 后端和全栈领域。npm 上 96,903 个包直接依赖它,周下载量约 9.74 亿

类别
项目
关系
后端框架 Express / Fastify / Hono
用 Zod 校验 API 请求参数(body / query / params)
AI Agent Vercel AI SDK
tool schema 和结构化输出的标准定义方式
Mastra
tool/workflow/scorer 全部依赖 Zod
LangChain.js
结构化输出使用 Zod schema
ORM Prisma
社区标准实践:Prisma 定义数据库模型 → Zod 做 API 层运行时校验
前端 Next.js / Remix
Server Action 的参数校验,T3 Stack 的核心组件
React Hook Form
官方提供 @hookform/resolvers/zod,直接用 Zod schema 做表单验证

一句话总结:Zod 已经超出了”一个库”的范畴,变成了 TypeScript 生态中 schema 定义的通用语言——就像 JSON 是数据交换的通用格式一样,Zod 是 TS 世界描述”数据长什么样”的标准方式。

1.3 设计理念

1.3.1 TypeScript-first,schema 即类型源头

传统做法是分别写接口定义和验证逻辑,容易不一致。Zod 反过来:schema 是唯一事实源,z.infer 从 schema 推导出 TS 类型,杜绝双重维护。

1.3.2 链式、不可变、追加校验

Zod 的方法都不修改原实例,而是返回一个新实例。这就是”不可变”(immutable)。

因为每次都返回同类型的新实例,所以可以把调用串起来写——这就是”链式调用”(chain calling)。

每次调用(.min().max().email() 等)都在内部往 checks 数组追加一条校验规则。

const n1 = z.string();// n1 → ZodString { _def: { type: 'string' } }const n2 = n1.min(2);// n2 → ZodString { _def: { type: 'string', checks: [ $ZodCheckMinLength {} ] } }n1 === n2  // false — n1 没变,n2 是全新实例

n1 依然是 { type: 'string' },没被修改过。n2 多了一个 checks 数组。这就是”追加校验”的含义——返回新对象,新对象的 checks 比原对象多一条。链式调用只是把 n1.min(2).max(20) 写成一行而已。


第2章 基础类型定义

2.1 原始类型

z.string()z.number()z.bigint()z.boolean()z.date()z.nan()z.undefined()z.null()z.void()z.never()z.unknown()z.any()

2.2 对象 object

z.object({ name: z.string(), age: z.number() })

z.object() 接收一个 shape 对象,每个 key 的值是一个 Zod schema。输出类型是所有字段的交叉类型,默认所有字段必填,且禁止额外属性(additionalProperties: false)。

z.strictObject({ name: z.string() })   // 显式禁止未知属性,等同于 z.object().strict()z.looseObject({ name: z.string() })    // 保留未知属性(透传),等同于 z.object().passthrough()z.record(z.number())                   // → Record<string, number>

2.3 数组与元组

z.array(z.string())                  // → string[]z.tuple([z.string(), z.number()])    // → [string, number]  定长定类型

2.4 联合与枚举

z.union([z.string(), z.number()])    // → string | numberz.enum(['a''b''c'])             // → 'a' | 'b' | 'c'z.literal('hello')                   // → 'hello'(精确字面量类型)

2.5 判别联合 discriminatedUnion

当数据的结构由某一个字段的值决定时,用 z.discriminatedUnion() 而不是 z.union()。它通过检查区分字段的值直接定位到对应的 schema,不需要逐个尝试,性能更好且错误信息更精确。

const ToolResult = z.discriminatedUnion('type', [  z.object({ type: z.literal('weather'), temperature: z.number(), city: z.string() }),  z.object({ type: z.literal('search'), results: z.array(z.string()), query: z.string() }),  z.object({ type: z.literal('error'), message: z.string(), code: z.number() }),]);// type: 'weather' → 匹配第一个 schemaToolResult.parse({ type'weather'temperature25city'Beijing' });// → { type: 'weather', temperature: 25, city: 'Beijing' }// type: 'unknown' → 没有匹配的 schemaToolResult.safeParse({ type'unknown' });// → { success: false, error: 'Invalid discriminator value. Expected "weather" | "search" | "error"' }

在 Agent 场景中,tool 的返回结果通常是多态的——不同 tool 返回不同结构,用 type 字段区分。z.infer 能正确推导出判别联合的类型,在 TypeScript 中通过 result.type 做类型收窄。

2.6 递归 schema lazy

当数据结构自引用时(如分类树、嵌套评论),用 z.lazy() 延迟 schema 求值以打破循环依赖。

type Category = z.infer<typeof CategorySchema>;const CategorySchema: z.ZodType<Category> = z.object({name: z.string(),subcategories: z.lazy(() => CategorySchema.array()),});CategorySchema.parse({name'电子产品',subcategories: [    { name'手机'subcategories: [] },    { name'电脑'subcategories: [      { name'笔记本'subcategories: [] },    ]},  ],});

z.lazy() 接收一个返回 schema 的函数,该函数在 .parse() 时才执行,因此定义时可以引用自身。


第3章 校验与约束

3.1 链式校验方法

// 字符串 — 格式校验z.string().email().url().uuid().ip().datetime()// 字符串 — 长度与内容z.string().min(2).max(100).length(10)  .startsWith('https://').endsWith('.com').includes('needle')// 字符串 — 变换z.string().trim().toLowerCase()// 数字z.number().int().positive().min(0).max(100).multipleOf(5).finite()// 元数据(不影响校验,toJSONSchema 时输出)z.string().describe('字段说明')

3.2 自定义校验 refine 与 superRefine

内置校验方法(.min().email() 等)覆盖不了的场景,用 .refine() 写自定义逻辑。

// .refine() — 返回 true/false,适合单字段校验const passwordSchema = z.string()  .refine(val => val.length >= 8'密码至少 8 位')  .refine(val => /[A-Z]/.test(val), '密码必须包含大写字母')  .refine(val => /[0-9]/.test(val), '密码必须包含数字');// .superRefine() — 可添加多条 issue、自定义 error code,适合跨字段校验const userSchema = z.object({password: z.string(),confirmPassword: z.string(),}).superRefine((data, ctx) => {if (data.password !== data.confirmPassword) {    ctx.addIssue({code: z.ZodIssueCode.custom,path: ['confirmPassword'],message'两次密码不一致',    });  }});

.refine() 只返回 true/false,自动生成一条 issue。.superRefine() 通过 ctx.addIssue() 可添加多条 issue、指定 error code、或设 fatal: true 提前终止。

关于 .check():v4 中另有 .check() 方法,定位是更低层的校验 API,日常使用不推荐。跨字段校验用 .superRefine() 即可。

3.3 默认值与可选可空

z.string().optional()   // 字段可选,等同 string | undefinedz.string().nullable()   // 字段可为 null,等同 string | nullz.string().nullish()    // 可选且可为 null,等同 string | undefined | nullz.string().default('default')  // 缺失时使用默认值,输出类型去掉 undefined

default() 在输入为 undefined 时填充默认值,z.infer 推导的输出类型会自动去掉 undefined。可选性和可空性按需组合。

v4 注意.default() 的默认值必须匹配输出类型(而非输入类型),且直接短路返回不经过 schema 解析。若需要默认值也经过 schema 校验(v3 行为),用 .prefault()

3.4 强制类型转换 coerce

z.coerce 在验证之前先用 JavaScript 内置转换函数把值转成目标类型,本质等价于 .pipe() 一个转换步骤。典型场景是 URL query 参数、HTML 表单提交、环境变量——这些来源的数据全是字符串,需要转成数字、布尔或日期。

// URL query / form 传过来的全是字符串const input = { age'25'active'true'price'9.99' };const schema = z.object({age: z.coerce.number(),      // Number('25') → 25active: z.coerce.boolean(),  // Boolean('true') → trueprice: z.coerce.number(),    // Number('9.99') → 9.99});schema.parse(input);// → { age: 25, active: true, price: 9.99 }

z.coerce.number() 大致等价于 z.any().pipe(z.number())——先做类型转换,再用目标 schema 的校验规则检查转换后的值。

3.5 错误恢复 catch

.catch() 在值存在但无效时用兜底值替换,让校验不中断。与 .default() 互补:default 兜 absent,catch 兜 invalid。

z.string().catch('fallback').parse(42);       // → 'fallback'z.number().catch(0).parse('not a number');    // → 0

对象属性中,.catch() 只在 key 存在但值类型错误时激活;key 缺失时需用 .default() 或 .optional()

// catch + default 组合覆盖两种缺失场景z.string().catch('fallback').default('default')// undefined → 'default'(.default() 处理缺失)// 42 → 'fallback'(.catch() 处理无效值)

第4章 解析与类型系统

4.1 parse 与 safeParse

// 抛错模式schema.parse(data)// 安全模式(推荐)const r = schema.safeParse(data);if (!r.success) {  r.error.issues// 错误列表else {  r.data// 类型安全的数据}

safeParse 和 parse 逻辑完全一样,区别只是返回方式:

.parse(data)     → 校验通过返回数据,不通过 throw ZodError.safeParse(data) → 通过返回 { success: true, data },不通过返回 { success: false, error: ZodError }

推荐始终使用 safeParse,避免 try/catch 包裹。

4.2 错误格式化

Zod v4 提供以下错误格式化方法,把原始的 issues 数组转成不同结构。

const result = schema.safeParse({ name'A'age: -1email'bad' });if (!result.success) {// 1. error.issues — 原始数组,包含 path / code / message 等完整信息  result.error.issues// → [{ path: ['name'], code: 'too_small', message: '...' }, ...]// 2. flattenError() — 扁平结构,适合表单场景  z.flattenError(result.error)// → { formErrors: [], fieldErrors: { name: ['...'], age: ['...'] } }// 3. treeifyError() — 树形结构,按层级组织  z.treeifyError(result.error)// → { errors: [], properties: { name: { errors: ['...'] } } }// 4. formatError() — ⚠ 已废弃,用 treeifyError() 替代  z.formatError(result.error)// → { _errors: [], name: { _errors: ['...'] } }// 5. prettifyError() — 可读字符串,适合日志和 CLI  z.prettifyError(result.error)// → "✖ Too small: expected string to have >=2 characters\n  → at name\n..."}

flattenError() 最常用于前端表单场景,fieldErrors 的 key 直接对应表单字段名,方便绑定到 UI 组件上显示错误提示。prettifyError() 适合后端日志输出或命令行工具,直接拿到人类可读的错误描述。

4.3 类型推导 z.infer

z.infer 是 TypeScript 的类型语法,不是函数调用。<> 里传的是类型参数而非值参数,整个表达式在编译后完全消失,不产生任何 JavaScript 代码。

type T = z.infer<typeof schema>;//       ^^^^^^^^  ^^^^^^^^^^^^^//       类型名     泛型参数(typeof 从变量提取类型)

z 本身是通过 import { z } from 'zod' 导入的一个对象,它借助 TypeScript 的声明合并(Declaration Merging)机制,在同一个名字下同时挂了运行时存在的值(string()number() 等工厂函数)和编译后才生效的类型(inferinputoutput)。TypeScript 会根据上下文自动区分——后面跟 () 就是值空间调用函数,写在 type 后面就是类型空间取类型。

// Zod 源码(简化版)export const z = {string() => new ZodString(),number() => new ZodNumber(),object(shape) => new ZodObject(shape),// ... 值空间(运行时存在)};export namespace z {export type infer<T> = T['_output'];export type input<T> = T['_input'];export type output<T> = T['_output'];// ... 类型空间(编译后消失)}

从源码来看,infer 实际上是 output 的别名,定义在 src/v4/core/core.ts:117-120,通过 src/v4/classic/external.ts:13 导出供用户使用。

// src/v4/core/core.ts:117-120export type input<T> = T extends { _zod: { inputany } } ? T["_zod"]["input"] : unknown;export type output<T> = T extends { _zod: { outputany } } ? T["_zod"]["output"] : unknown;export type { output as infer };  // infer 就是 output 的别名

实际使用时,z.infer 负责在编译时从 schema 提取出静态类型,供函数签名、变量声明、接口组合等场景使用;而 .parse() 负责在运行时对真实数据进行校验并返回类型安全的值。两者配合使得同一个 schema 定义能同时服务于编译期类型检查和运行时数据校验

// 1. 定义 schemaconst UserSchema = z.object({ name: z.string(), age: z.number() });// 2. 用 infer 提取类型(编译时)type User = z.infer<typeof UserSchema>;// 3. 用 parse 验证数据(运行时)function createUser(dataunknown): User {return UserSchema.parse(data);  // ← 运行时验证,返回类型安全的值}// 4. 用 User 类型做其他事const usersUser[] = [];function processUser(userUser): void { ... }

4.4 JSON Schema 转换 toJSONSchema

z.toJSONSchema() 把 Zod schema 转换成 JSON Schema 标准格式。这个转换在 AI Agent 场景中至关重要——LLM 的 tool calling 接口不接受 Zod 对象,只接受 JSON Schema,所以框架在背后自动调用 toJSONSchema() 将开发者写的 Zod schema 转成 LLM 能理解的参数描述。

import { z, toJSONSchema } from 'zod';const schema = z.object({location: z.string().describe('City name'),units: z.enum(['celsius''fahrenheit']).default('celsius'),});const jsonSchema = toJSONSchema(schema);// → {//     "type": "object",//     "properties": {//       "location": { "type": "string", "description": "City name" },//       "units": { "type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius" }//     },//     "required": ["location", "units"],//     "additionalProperties": false//   }

.describe() 在这个流程中变成了 LLM 能读懂的参数说明,这也是为什么在定义 tool 的 inputSchema 时给每个字段加 .describe() 很有价值——它直接影响 LLM 对参数的理解。


第5章 转换与 Schema 操作

5.1 数据转换 transform

.transform() 在验证通过后对数据做转换,输出类型会改变

// 字符串 → 数字const numSchema = z.string()  .transform(val => parseInt(val, 10));// z.infer<typeof numSchema> → number(输入是 string,输出是 number)// 字符串 → Dateconst dateSchema = z.string()  .refine(val => !isNaN(Date.parse(val)), '无效日期')  .transform(val => new Date(val));// z.infer<typeof dateSchema> → Date// 链式:先 trim 再转小写const emailSchema = z.string()  .transform(val => val.trim().toLowerCase())  .pipe(z.string().email());

.refine() vs .transform() 的区别:

方法
作用
输出类型
.refine()
校验,不改变数据
不变
.transform()
校验 + 转换
改变

5.2 预处理 preprocess

z.preprocess() 在 schema 校验之前对原始数据做转换,用于清洗不可信的输入。与 .transform() 的区别:preprocess 作用于输入侧,transform 作用于输出侧。

// query string 逗号分隔 → 数字数组const numArray = z.preprocess(val => typeof val === 'string' ? val.split(',').map(Number) : val,  z.array(z.number()),);numArray.parse('1,2,3');  // → [1, 2, 3]// 空字符串 → undefined(配合 optional 使用)const optionalString = z.preprocess(val => val === '' ? undefined : val,  z.string().optional(),);optionalString.parse('');       // → undefinedoptionalString.parse('hello');  // → 'hello'

典型场景:表单提交的空字符串 "" 转 undefined、逗号分隔字符串转数组、类型不明确的第三方数据做标准化。

5.3 Schema 串联 pipe

.pipe() 把前一个 schema 的输出作为后一个 schema 的输入。

// 例 1:先验证是字符串,再转成数字z.string().pipe(z.coerce.number())// 输入: "123" → 输出: 123// 例 2:先 trim,再验证 email 格式z.string()  .transform(s => s.trim())  .pipe(z.string().email())// 输入: "  test@example.com  " → 输出: "test@example.com"

为什么 .transform() 之后必须用 .pipe() 才能继续校验:

// ❌ 这样写会报错z.string()  .transform(s => s.trim())  // 返回 ZodPipe,不再是 ZodString  .min(3)                    // ← 没有 .min() 方法!// ✅ 必须用 .pipe() 交给新的 schemaz.string()  .transform(s => s.trim())  .pipe(z.string().min(3))   // ← 新的 ZodString,有 .min()

什么时候必须用 .pipe()

场景
原因
.transform()

 之后要再校验
transform 返回 ZodPipe,链式断了,没有 .min() 等方法
复用已有 schema
比如已有 const EmailSchema = z.string().email(),直接 pipe 进去
类型发生了根本变化
string → number → boolean

,每一步都是独立的 schema

什么时候不需要 .pipe()

// 纯链式就够了,不需要 pipez.string().min(3).max(100).email()

5.4 Schema 组合 pick omit partial extend merge

Zod 提供了一组方法从已有 schema 派生出新 schema,全部返回新实例,不修改原 schema。

const UserSchema = z.object({id: z.string(),name: z.string(),email: z.string().email(),age: z.number(),});// .pick() — 只保留指定字段const NameOnly = UserSchema.pick({ nametrueemailtrue });// → { name: string, email: string }// .omit() — 排除指定字段const NoId = UserSchema.omit({ idtrue });// → { name: string, email: string, age: number }// .partial() — 所有字段变可选const Partial = UserSchema.partial();// → { id?: string, name?: string, email?: string, age?: number }// .extend() — 追加字段const Extended = UserSchema.extend({ role: z.string() });// → { id, name, email, age, role }// .merge() — 合并两个 object schemaconst AddressSchema = z.object({ city: z.string(), zip: z.string() });const Merged = UserSchema.merge(AddressSchema);// → { id, name, email, age, city, zip }

典型场景是定义一个基础 schema,然后通过 pick/omit/extend 派生出不同用途的子 schema,避免重复定义。比如从完整的 User schema 中 pick 出 name 和 email 作为创建接口的入参,用 partial 作为更新接口的入参(所有字段可选)。

5.5 异步校验 parseAsync

当校验逻辑需要查数据库、调外部 API 等异步操作时,.refine() 的回调可以是 async 函数,配合 parseAsync() 或 safeParseAsync() 使用。同步的 .parse() 无法处理异步校验,会抛出错误。

// 模拟异步校验(查数据库用户名是否已存在)const checkUsernameExists = async (usernamestring) => {const taken = ['admin''root''test'];return taken.includes(username);};const schema = z.object({username: z.string().min(3),email: z.string().email(),}).refine(async (data) => {const exists = await checkUsernameExists(data.username);return !exists;}, { message'用户名已被占用'path: ['username'] });// 必须用 safeParseAsync,不能用 safeParseconst result = await schema.safeParseAsync({ username'admin'email'a@b.com' });// → { success: false, error: { issues: [{ path: ['username'], message: '用户名已被占用' }] } }

异步校验的典型场景包括:检查用户名/邮箱是否已存在、验证 API key 是否有效、调用第三方服务确认数据合法性等。


第6章 运行时机制与源码

6.1 源码架构 v4

src/v4/├── core/          # zod/v4/core — 核心层│   ├── core.ts    # $ZodType 基类,定义 schema 最小接口│   ├── schemas.ts # 所有 schema 类型(ZodString / ZodNumber / ZodObject ...)│   ├── parse.ts   # 解析引擎:深度优先遍历 schema 树,执行校验│   ├── checks.ts  # 内置校验规则(min / max / email / regex / int ...)│   ├── errors.ts  # ZodError / ZodIssue 错误系统│   └── ...├── classic/       # 用户日常使用的 API 层│   └── schemas.ts # 在 core 之上包装链式 API(z.string().min() 等语法糖)├── mini/          # zod/v4/mini — 可摇树优化的轻量版└── locales/       # 国际化错误消息

分层关系:

core/$ZodType (最小接口:_parse / safeParse / 类型定义)        ↑classic/ZodType (加链式 API:.min() .max() .email() .default() ...)        ↑用户代码:z.object({ name: z.string().min(2) })

6.2 _def 配置单与 checks 数组

每个 schema 实例的 _def 存了它的全部配置。它是 Zod 内部用 Object.defineProperty 在构造时挂载的(src/v4/classic/schemas.ts:228)。

字段
存在位置
含义
type
所有 schema
数据类型名,如 'string''number''object'
checks
ZodString / ZodNumber 等
校验规则数组,每条是一个 check 对象
shape
ZodObject
子 schema 的映射表
format
ZodNumber
数字格式约束,如 'safeint'
z.string().min(2).max(20)._def// → { type: 'string', checks: [ $ZodCheckMinLength {}, $ZodCheckMaxLength {} ] }z.number().int().positive()._def// → { type: 'number', checks: [ ZodNumberFormat { format: 'safeint' }, $ZodCheckGreaterThan {} ] }z.object({ name: z.string() })._def// → { type: 'object', shape: { name: ZodString } }

每次链式调用 .min().max().email().int().positive(),Zod 都往 checks 数组追加一个 check 对象。

z.string()              // checks: []                               — 无校验  .min(2)               // checks: [ $ZodCheckMinLength ]            — 一条规则  .max(20)              // checks: [ $ZodCheckMinLength, $ZodCheckMaxLength ]  — 两条  .email()              // checks: [...前两条, ZodEmail ]             — 三条

_def.checks.length = 你链式调用了几个校验方法。

6.3 parse 执行流程

每个 z.xxx() 调用都创建一个类实例(ZodStringZodObjectZodNumber…),这些实例通过 _def 属性记住自己的”配置”。

const strSchema = z.string();// strSchema 是 ZodString 实例,_def 存了 { type: 'string' }const objSchema = z.object({name: z.string(),age: z.number().int(),});// objSchema 是 ZodObject 实例// _def.shape 存了 { name: ZodString, age: ZodNumber }

所以 z.object({ name: z.string(), age: z.number() }) 实际上创建了一棵对象树:

ZodObject  ├── shape.name  → ZodString   { type'string' }  └── shape.age   → ZodNumber   { type'number', checks: [...] }

当调用 schema.parse(data),Zod 从根节点开始深度优先遍历这棵 schema 树。对于每个节点:

schema.parse(data)// 1. 根节点(ZodObject):遍历 shape 的每个 key//     ↓// 2. name 节点(ZodString):遍历 checks 数组//       逐个执行:min(2) 通过?max(20) 通过?...//       不通过 → 记下 ZodIssue { path: ['name'], ... }//     ↓// 3. age 节点(ZodNumber):遍历 checks 数组//       逐个执行:int 通过?positive 通过?...//       不通过 → 记下 ZodIssue { path: ['age'], ... }//     ↓// 4. 所有节点检查完毕//      有错误 → throw ZodError(issues 数组包含所有问题)//      无错误 → 返回传入的数据

关键:不短路。即使 name 已经错了,age 仍然会检查,最后把所有错误一起返回。这让用户能一次看到全部问题,而不是修一个错再报下一个。

6.4 Schema 即配置的设计

定义 schema 时只是创建了一个 ZodObject 实例,不会触发任何验证。验证是在调用 .parse() 或 .safeParse() 时才发生。

// ① 模块加载时:创建 schema 实例(不验证)const weatherTool = createTool({inputSchema: z.object({ location: z.string() }),// ...});// ② 用户调用 tool 时:框架内部做验证// 框架源码大致是这样:async function executeTool(tool, userInput) {const validated = tool.inputSchema.parse(userInput);  // ← 这里才真正验证return tool.execute(validated);}

类比: 就像在餐厅门口贴了一张”衣冠不整禁止入内”的告示(定义 schema),但告示本身不会拦人。等顾客来了(用户调用 tool),保安才会检查(inputSchema.parse())。

为什么要这样设计? 因为 schema 需要被多处使用:

  • 传给 LLM,告诉它 tool 需要什么参数(JSON Schema)
  • 运行时校验用户输入
  • TypeScript 类型推导

如果验证在定义时就发生,这些用途都没法实现。

直接使用 Zod vs 框架使用:

场景
谁触发验证
直接用 Zod
你写 schema.parse()
Mastra tool
Mastra 内部调用 inputSchema.parse()
Vercel AI SDK
框架内部调用 inputSchema.parse()
React Hook Form
框架内部调用 schema.parse()

直接用 Zod 时需要自己写验证逻辑:

// API 路由 — 你自己写验证app.post('/user'(req, res) => {const result = UserSchema.safeParse(req.body);  // ← 手动触发if (!result.success) {return res.status(400).json(result.error.issues);  }  db.user.create({ data: result.data });});// 表单提交 — 你自己写验证function handleSubmit(formDataFormData) {const data = Object.fromEntries(formData);const result = FormSchema.safeParse(data);  // ← 手动触发if (!result.success) {showErrors(result.error.issues);return;  }submit(result.data);}

框架只是把 schema.parse(data) 藏在了内部,让你不用手写。本质是一样的。


第7章 工程实践

7.1 interface 与 z.object 的选择

判断标准:数据从不信任的地方来,还是从信任的地方来。

场景
用什么
原因
API 入参(用户/LLM 传来)
z.object()
数据不可信,必须运行时校验
API 出参(返回给调用方)
z.object()
框架需要校验返回值是否符合约定
fetch()

 的响应
应该用 Zod,但实际常偷懒用 interface
外部 API 响应不可信,as T 只是骗编译器
Prisma 查询结果
interface
ORM 已经保证了类型,数据可信
内部函数参数
interface
调用方是你自己,类型系统够用
配置文件读取
z.object()
文件内容不可信

一句话:数据跨越信任边界 → Zod,同一个信任域内 → interface。

7.2 常见坑点与反模式

.transform() 之后链式调用断裂

.transform() 返回 ZodPipe 而非原类型,链式校验方法(.min() 等)不再可用。

// ❌ ZodPipe 没有 .min() 方法z.string().transform(s => s.trim()).min(3)// ✅ 用 .pipe() 接入新 schemaz.string().transform(s => s.trim()).pipe(z.string().min(3))

异步校验误用 safeParse

包含 async 回调的 schema 必须用异步解析方法。

// ❌ 报错schema.safeParse(data)// ✅await schema.safeParseAsync(data)

跨信任边界用 interface + as T

// ❌ fetch 响应只靠类型断言,运行时无保护const data = await fetch('/api/users').then(r => r.json()) as User[];// ✅ 跨信任边界走 Zod 校验const data = UserArraySchema.parse(await fetch('/api/users').then(r => r.json()));

z.coerce.boolean() 的 truthy 陷阱

z.coerce.boolean().parse('false')  // → true!(非空字符串都是 truthy)z.coerce.boolean().parse('')       // → false// Boolean('false') === true,只有 Boolean('') === false

discriminatedUnion 的 discriminator 必须用 z.literal()

// ❌ discriminator 用了 z.string(),TypeScript 无法收窄类型z.discriminatedUnion('type', [  z.object({ type: z.string(), ... })])// ✅ 必须用 z.literal()z.discriminatedUnion('type', [  z.object({ type: z.literal('weather'), ... })])

跨字段校验用了 .refine() 而非 .superRefine()

// ❌ .refine() 无法指定错误路径到具体字段z.object({ password: z.string(), confirm: z.string() })  .refine(data => data.password === data.confirm'密码不一致')// ✅ .superRefine() 可以精确控制 addIssue() 的 pathz.object({ password: z.string(), confirm: z.string() })  .superRefine((data, ctx) => {if (data.password !== data.confirm) {      ctx.addIssue({ code: z.ZodIssueCode.custompath: ['confirm'], message'两次密码不一致' });    }  })

以为定义 schema 时就会验证

const schema = z.object({ name: z.string().min(2) });// ↑ 这里什么都没验证,只是创建了一个配置对象// 验证只发生在 .parse() / .safeParse() 调用时