Skip to content

Zod基础

Zod解决了什么痛点?

TypeScript 能帮助我们在编译阶段发现类型错误,提升代码的健壮性。

但是有一个问题:运行时的数据并不会自动带上类型信息。例如 API 返回的数据、用户输入的表单、第三方库的结果,这些都是动态数据。

这就是痛点:TS 的类型信息在编译时有用,但到运行时就丢失了。

Zod 是一个 TypeScript 优先的 schema 声明与验证库。它的目标就是把 “类型定义” 和 “运行时校验” 结合在一起,让你写一次,就能同时获得:

Zod 的核心特点

Zod 具备以下几个关键特性:

快速上手

  1. 安装
  2. 定义 Schema
  3. 校验数据

常用Schema类型

  1. 原始类型:string, number, boolean, bigint, date, undefined, null, any, unknown, never
  2. 对象:z.object
  3. 数组与元组:z.array, z.tuple
  4. 集合类型:z.record, z.map, z.set
  5. 字面量与枚举:z.literal, z.enum, z.nativeEnum

组合与修饰

校验器

  1. 内置校验器

    • .min(len) / .max(len):长度限制

    • .email():邮箱格式

    • .url():URL 格式

    • .uuid():UUID 格式

    • .regex(regexp):正则匹配

  2. 自定义校验:当内置方法不够用时,可以用 .refine() 添加自定义校验逻辑。

    • 接收一个布尔返回的函数(true 表示校验通过)。
    • 可以自定义错误消息。
    const password = z.string().refine((val) => val.length >= 8, {
    message: '密码至少 8 位',
    })
    password.parse('12345678') // ✅
    password.parse('123') // ❌ 报错:密码至少 8 位

与 TS 的集成

每个 schema 都可以通过 z.infer 推断对应的 TypeScript 类型。

const User = z.object({
name: z.string(),
age: z.number(),
})
// 推断出静态类型
type UserType = z.infer<typeof User>
// 等价于:{ name: string; age: number }

Zod介绍#

在日常开发中,我们往往使用 TypeScript 来做静态类型检查。TypeScript 能帮助我们在编译阶段发现类型错误,提升代码的健壮性。但是有一个问题:运行时的数据并不会自动带上类型信息。例如 API 返回的数据、用户输入的表单、第三方库的结果,这些都是动态数据。

来看一个具体的例子。假设你在 TypeScript 里写了:

interface User {
name: string
age: number
}

那么这能够在编译时拦截住一些错误,例如:

const u: User = {
name: 42, // 报错,应该是 string
age: "hi", // 报错,应该是 number
};

但是,如果数据是运行时动态获取的,比如从接口返回、用户输入,通常你会这样写:

const user = JSON.parse('{"name": 42, "age": "hi"}')

注意:JSON.parse 的返回值在 TypeScript 里是 any。也就是说编译器根本不知道 user 里面具体是什么类型。

你甚至可以这样写:

const user: User = JSON.parse('{"name": 42, "age": "hi"}')

TypeScript 不会报错!它只会认为:“既然你告诉我这是 User,那我就信了”。但实际运行时,user.name42(数字),user.age"hi"(字符串),跟定义完全相反。

这就是痛点:TS 的类型信息在编译时有用,但到运行时就丢失了。

Zod 是一个 TypeScript 优先的 schema 声明与验证库。它的目标就是把 “类型定义” 和 “运行时校验” 结合在一起,让你写一次,就能同时获得:

官方文档的首页开宗明义地强调:

Zod is a TypeScript-first schema declaration and validation library. Its goal is to make it as easy as possible to validate runtime values with a well-typed, composable schema.

Zod 的核心特点

Zod 具备以下几个关键特性:

快速上手#

首先是安装,选择一个你喜欢的包管理器来安装它:

Terminal window
npm install zod

定义 Schema

Zod 的核心理念是:先定义 Schema,再用 Schema 去校验数据。Schema 通过 z.* 系列方法来构建。

例如:

import { z } from 'zod'
// 一个字符串 schema
const Name = z.string()
// 一个数字 schema
const Age = z.number()
// 一个对象 schema
const User = z.object({
name: Name,
age: Age,
})

此时 User 就代表“一个对象,里面有 name: stringage: number”。

校验数据

定义完 Schema 后,就可以用 .parse().safeParse() 校验数据。

  1. .parse() —— 抛异常方式

    User.parse({ name: 'Alice', age: 20 })
    // 返回 { name: "Alice", age: 20 }
    User.parse({ name: 42, age: 'hi' })
    // 直接抛出 ZodError

    来看一下这个 ZodError 的具体信息:

    ZodError: [
    {
    expected: 'string',
    code: 'invalid_type',
    path: ['name'],
    message: 'Invalid input: expected string, received number',
    },
    {
    expected: 'number',
    code: 'invalid_type',
    path: ['age'],
    message: 'Invalid input: expected number, received string',
    },
    ]
  2. .safeParse() —— 返回结果对象

    成功示例

    const result = User.safeParse({ name: '张三', age: 18 })

    返回内容:

    { success: true, data: { name: '张三', age: 18 } }

    失败示例:

    const result = User.safeParse({ name: 18, age: '张三' })

    返回内容:

    {
    success: false,
    error: ZodError: [
    {
    "expected": "string",
    "code": "invalid_type",
    "path": [
    "name"
    ],
    "message": "Invalid input: expected string, received number"
    },
    {
    "expected": "number",
    "code": "invalid_type",
    "path": [
    "age"
    ],
    "message": "Invalid input: expected number, received string"
    }
    ]
    }

常用 Schema 类型#

接下来我们来看一下 Zod 中提供的常见的 Schema 类型。总结起来如下:

  1. 原始类型:string, number, boolean, bigint, date, undefined, null, any, unknown, never
  2. 对象:z.object
  3. 数组与元组:z.array, z.tuple
  4. 集合类型:z.record, z.map, z.set
  5. 字面量与枚举:z.literal, z.enum, z.nativeEnum

1. 原始类型#

字符串

const str = z.string()
str.parse('hello') // 通过
str.parse(123) // 抛错:Expected string, received number

数字

const num = z.number()
num.parse(42) // 通过
num.parse('42') // 抛错

布尔值

const flag = z.boolean()
flag.parse(true) // 通过
flag.parse('true') // 抛错

BigInt

const big = z.bigint()
big.parse(100n) // 通过
big.parse(100) // 抛错

Date

const date = z.date()
date.parse(new Date()) // 通过
date.parse('2025-01-01') // 抛错

特殊类型

它们分别对应 JavaScript 的 undefinednull,以及 TypeScript 中的 any/unknown/never

2. 对象类型#

对象是 Zod 最常用的结构化类型:

const User = z.object({
name: z.string(),
age: z.number(),
})
User.parse({ name: 'Alice', age: 20 }) // 通过
User.parse({ name: 'Bob' }) // 抛错

对象类型还可以:

这些 API 可以参阅 官方文档的 Objects 部分。

3. 数组与元组#

数组

const numArray = z.array(z.number())
numArray.parse([1, 2, 3]) // 通过
numArray.parse(['a', 'b']) // 抛错

元祖

元组与数组不同,它定义了固定长度和类型顺序:

const tuple = z.tuple([z.string(), z.number()])
tuple.parse(['hello', 123]) // 通过
tuple.parse([123, 'hello']) // 抛错:顺序错误

4. 集合类型#

Record

键值对对象,键必须是字符串或 number,值由你指定一个 schema 来约束。换句话说,z.record() 就是一个“动态对象”,所有属性的类型都一样。

示例:

const NumKeyToStringVal = z.record(z.number(), z.string())

含义:

所以:

NumKeyToStringVal.parse({ 1: 'one', 2: 'two' })
// 合法
NumKeyToStringVal.parse({ 1: 100 })
// 报错:Expected string, received number

z.object() 的区别

Map

const stringToNumberMap = z.map(z.string(), z.number())
stringToNumberMap.parse(new Map([['a', 1]])) // 通过

Set

const numSet = z.set(z.number())
numSet.parse(new Set([1, 2, 3])) // 通过
numSet.parse(new Set(['a'])) // 抛错

5. 字面量与枚举#

字面量

const literal42 = z.literal(42)
literal42.parse(42) // 通过
literal42.parse(7) // 抛错

枚举

const Direction = z.enum(['North', 'South', 'East', 'West'])
Direction.parse('North') // 通过
Direction.parse('Other') // 抛错

原生枚举

可以直接校验 TypeScript 的 enum

enum Fruits {
Apple,
Banana,
}
const FruitEnum = z.nativeEnum(Fruits)
FruitEnum.parse(Fruits.Apple) // 通过
FruitEnum.parse(0) // 通过
FruitEnum.parse('Mango') // 抛错

这些常用的 Schema 类型是 Zod 的基石,后面的组合、校验、自定义逻辑,都是基于这些基础类型构建出来的。

组合与修饰#

在真实项目里,数据结构并不总是“简单的固定字段”。有时字段是可选的,有时允许为空,有时需要默认值,有时需要把多个 schema 组合在一起。Zod 提供了一系列组合与修饰的工具来应对这些情况。

可选

.optional() 表示:这个值可以是指定类型,也可以是 undefined。适用于对象中 非必填字段 的场景。

const optionalString = z.string().optional()
optionalString.parse('hello') // ✅ "hello"
optionalString.parse(undefined) // ✅ undefined
optionalString.parse(42) // ❌ Expected string, received number

可空

.nullable() 允许一个值是 null 或指定类型:

const nullableString = z.string().nullable()
nullableString.parse('hi') // ✅ "hi"
nullableString.parse(null) // ✅ null
nullableString.parse(undefined) // ❌

.nullish() 允许一个值是 nullundefined 或指定类型:

const nullishString = z.string().nullish()
nullishString.parse('hi') // ✅ "hi"
nullishString.parse(null) // ✅ null
nullishString.parse(undefined) // ✅ undefined

默认值

.default() 为一个 schema 设置默认值:

const stringWithDefault = z.string().default('default')
stringWithDefault.parse('hello') // ✅ "hello"
stringWithDefault.parse(undefined) // ✅ "default"

.default() 仅在输入值为 undefined 时生效。如果输入是 null,依然会报错,除非你同时加了 .nullable()

联合类型

z.union 联合类型表示“值可以是几种类型之一”。

const stringOrNumber = z.union([z.string(), z.number()])
stringOrNumber.parse('hi') // ✅
stringOrNumber.parse(42) // ✅
stringOrNumber.parse(true) // ❌

交叉类型

z.intersection 交叉类型表示“必须同时满足两个 schema”。

const hasName = z.object({ name: z.string() })
const hasAge = z.object({ age: z.number() })
const Person = z.intersection(hasName, hasAge)
Person.parse({ name: 'Alice', age: 20 }) // ✅
Person.parse({ name: 'Bob' }) // ❌ 缺少 age

判别联合

当对象有一个“判别字段”时,可以用 z.discriminatedUnion 来更高效地验证。

那什么是“判别字段”呢?

假设你要描述一个“形状 (Shape)”,可能有 圆形 (Circle) 或 正方形 (Square)。这两种形状的数据结构不一样:

为了区分不同形状,我们常会加一个固定字段 kind

// 圆形
{ kind: "circle", radius: 10 }
// 正方形
{ kind: "square", side: 5 }

这里的 kind 就是“判别字段”,因为它的值决定了是哪种对象。

明白了“判别字段”的含义后,接下来我们来看一下如果不用判别联合会怎样。假设我们用普通的 z.union

const ShapeUnion = z.union([
z.object({ kind: z.literal('circle'), radius: z.number() }),
z.object({ kind: z.literal('square'), side: z.number() }),
])

它能区分,但在错误提示和性能上不太好。比如:

ShapeUnion.parse({ kind: 'triangle', base: 3 })

报错会很笼统:“不符合第一个 schema,也不符合第二个 schema”。

接下来我们改成用 z.discriminatedUnion("kind", [...])

const Circle = z.object({
kind: z.literal('circle'),
radius: z.number(),
})
const Square = z.object({
kind: z.literal('square'),
side: z.number(),
})
const Shape = z.discriminatedUnion('kind', [Circle, Square])
Shape.parse({ kind: 'circle', radius: 10 }) // ✅ 圆形
Shape.parse({ kind: 'square', side: 5 }) // ✅ 正方形
Shape.parse({ kind: 'triangle', base: 3 }) // ❌ 报错更清晰

报错信息会直接告诉你:kind 字段必须是 "circle""square",而不是 "triangle"。这样比普通 union 的报错更直观。

校验与错误处理#

目前我们了解了 Schema 和组合类型,实际项目里往往需要更复杂的数据校验规则,以及更清晰的错误处理方式。接下来我们就来看一下 Zod 的 内置校验器自定义校验 和 **错误对象 **。

内置校验器

Zod 在常见类型上提供了丰富的内置校验方法。

示例一:字符串示例

const username = z
.string()
.min(3)
.max(10)
.regex(/^[a-z]+$/)
username.parse('bob') // ✅
username.parse('a') // ❌ 长度过短
username.parse('HELLO') // ❌ 不符合正则

部分常用方法:

示例二:数字示例

const age = z.number().min(0).max(150).int()
age.parse(25) // ✅
age.parse(-1) // ❌ 小于 0
age.parse(200) // ❌ 大于 150
age.parse(3.14) // ❌ 不是整数

自定义校验

当内置方法不够用时,可以用 .refine() 添加自定义校验逻辑。

特点:

例如:

const password = z.string().refine((val) => val.length >= 8, {
message: '密码至少 8 位',
})
password.parse('12345678') // ✅
password.parse('123') // ❌ 报错:密码至少 8 位

跨字段校验

如果需要在对象内部做多个字段之间的校验,可以用 .superRefine()

特点:

const PasswordSchema = z
.object({
pwd: z.string(),
confirm: z.string(),
})
.superRefine((data, ctx) => {
if (data.pwd !== data.confirm) {
ctx.addIssue({
code: 'custom',
message: '两次密码输入不一致',
path: ['confirm'], // 指定错误位置
})
}
})

.superRefine() 本质上比 .refine() 更灵活,能访问整个对象。

自定义错误消息

大多数内置校验方法可以传入 message 参数:

const username = z.string().min(3, { message: '用户名至少 3 个字符' })
username.parse('a')
// 报错信息:用户名至少 3 个字符

错误格式化

Zod 提供了 error.format() 方法,可以把复杂的错误转换成更容易消费的格式。

举个例子:当你用 .parse().safeParse() 校验失败时,拿到的原始错误结构是一个 ZodError,里面的 issues 是数组,每个错误都是一条记录

const Form = z.object({
name: z.string().min(3),
age: z.number().min(18),
})
const result = Form.safeParse({ name: 'Al', age: 10 })
if (!result.success) {
console.log(result.error.issues)
}

输出如下:

;[
{
code: 'too_small',
minimum: 3,
type: 'string',
inclusive: true,
message: 'String must contain at least 3 character(s)',
path: ['name'],
},
{
code: 'too_small',
minimum: 18,
type: 'number',
inclusive: true,
message: 'Number must be greater than or equal to 18',
path: ['age'],
},
]

输出格式上是数组形式,每个错误单独一条,包含很多元信息(codeminimumpath 等),这种结构比较全,但不太适合直接用在表单 UI。

Zod 提供了 format() 方法,把错误按字段名组织起来,更直观。

if (!result.success) {
console.log(result.error.format())
}

输出类似这样:

{
"name": {
"_errors": ["String must contain at least 3 character(s)"]
},
"age": {
"_errors": ["Number must be greater than or equal to 18"]
},
"_errors": []
}

输出格式变成了对象形式,每个字段对应一个键(nameage),具体错误放在 ._errors 数组里,最外层的 _errors 保存“全局错误”。这种格式更容易在前端表单中绑定,比如直接取 error.format().name._errors[0] 就能拿到 “用户名太短” 这条提示。

输出输入控制#

Zod 除了能做运行时校验,还提供了控制 输入值的转换Schema 输出的加工 的功能。常用的有:

这些工具让我们在接收“脏输入”或需要调整数据格式时更灵活。

强制类型转换 (coercion)

Zod 提供了 z.coerce.* 系列方法,可以把输入强制转换成目标类型。

举个例子:

const coercedNumber = z.coerce.number()
coercedNumber.parse('42') // ✅ 42 (string → number)
coercedNumber.parse(42) // ✅ 42
coercedNumber.parse(true) // ❌ 报错

支持的类型包括:

典型应用场景:从 URL 查询参数、表单输入中获取的数据通常是字符串,需要转成 number、date 等。

变换 (transform)

.transform() 允许你在校验通过后对值做进一步处理。也就是说,分为下面 2 个步骤:

  1. 输入先经过 schema 校验
  2. 校验通过后进入 .transform() 回调,返回新值
const trimmedString = z.string().transform((val) => val.trim())
trimmedString.parse(' hello ') // ✅ "hello"

你可以用它做各种映射,比如格式化字符串、计算新字段等。

管道 (pipe)

.pipe() 用于把多个 schema 串联起来,相当于一个“管道”。

工作流程:

  1. 输入先经过前一个 schema 校验
  2. 结果再传给下一个 schema
  3. 最终得到输出

.transform() 的区别:

const numberToString = z.number().pipe(z.string())
numberToString.parse(123) // ✅ "123"

类型推断与 TS 集成#

Zod 最大的优势之一,就是它与 TypeScript 深度集成。你只需定义一次 Schema,就能同时获得运行时校验和编译时类型检查。这避免了“写两遍类型”的重复劳动。

1. 基础推断:z.infer

每个 schema 都可以通过 z.infer 推断对应的 TypeScript 类型。

特点:

const User = z.object({
name: z.string(),
age: z.number(),
});
// 推断出静态类型
type UserType = z.infer<typeof User>;
// 等价于:{ name: string; age: number }

2. 区分输入与输出:z.inputz.output

有些 schema 会做转换 (transform/pipe),导致输入和输出类型不同。这种情况下,可以用 z.inputz.output 明确区分。

const Schema = z.string().transform((val) => val.length);
type In = z.input<typeof Schema>; // string
type Out = z.output<typeof Schema>; // number

In 表示 parse 之前的数据类型,Out 表示 parse 之后返回的类型。

Zod 与 TypeScript 的紧密结合,使它不仅是一个运行时校验库,更是一个类型驱动开发的利器。

其它特性#

在掌握了基础类型、组合与校验之后,Zod 还提供了一些其它使用的特性,方便在复杂场景下保持代码清晰与灵活。常用的特性有:

1. 递归类型

在 TypeScript 里,可能需要定义树形或链表结构。该情况下 schema 需要自我引用,这就可以使用 z.lazy()。它接收一个返回 schema 的函数,用于解决“类型尚未定义完成”时的自我引用问题。

例如:

const Category: z.ZodType<any> = z.object({
name: z.string(),
children: z.array(z.lazy(() => Category)),
});
Category.parse({
name: "Root",
children: [
{ name: "Child 1", children: [] },
{ name: "Child 2", children: [] },
],
}); // ✅

2. 描述信息

给 schema 添加人类可读的描述信息。描述不会影响校验逻辑,但在错误信息或文档生成(如 JSON Schema)时很有用。

例如:

const User = z.object({
name: z.string().describe('用户名'),
age: z.number().describe('用户年龄'),
})

3. 对象模式修饰

对象 schema 默认只校验声明过的字段,多余字段会被保留。Zod 提供了三种模式修饰:

.strict():禁止额外字段。

const User = z.object({ name: z.string() }).strict()
User.parse({ name: 'Alice' }) // ✅
User.parse({ name: 'Bob', age: 20 }) // ❌ 不允许额外字段

.passthrough():允许额外字段并保留。

const User = z.object({ name: z.string() }).passthrough()
User.parse({ name: 'Bob', age: 20 })
// ✅ { name: "Bob", age: 20 }

.strip():允许额外字段,但自动去掉。

const User = z.object({ name: z.string() }).strip()
User.parse({ name: 'Bob', age: 20 })
// ✅ { name: "Bob" } (age 被去掉)

与生态集成#

Zod 除了能在代码中做数据校验,还能与其他工具链结合使用,常用于 接口文档、API 校验、表单校验 等场景。这里简单举两个生态集成的例子。

1. 导出 JSON Schema

Zod v4 开始,内置了 toJSONSchema 功能,可以把 schema 转换为 JSON Schema。

import * as z from 'zod'
const User = z.object({ name: z.string(), age: z.number().min(18) })
const jsonSchema = z.toJSONSchema(User)
console.log(JSON.stringify(jsonSchema, null, 2))

常用于:

2. 与表单库结合

Zod 也常用于前端表单校验,比如配合 React Hook Form

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">提交</button>
</form>
)
}

上面代码 zodResolver 直接把 Zod schema 嵌入表单校验流程,错误信息会自动传递到 formState.errors,方便 UI 展示。

Zod 4 与 Mini#

Zod 在 v4 版本进行了重要更新,同时官方也提供了一个极简版本 Zod Mini

先来看一下 v4 相比 v3 的变化:

官方迁移文档明确说明:大多数 v3 代码可以无缝迁移到 v4,只有少数 API 需要更新。

接下来说一下 Zod Mini,这是 Zod 的一个“精简子集”:

适用场景:

写在最后#

学习 Zod,不仅仅是掌握了一个数据校验库的 API。更重要的是,它让我们重新思考了“类型”与“数据”之间的关系。

TypeScript 给了我们编译期的安全感,但运行时的数据往往来自不可控的环境:接口、用户输入、配置文件……这些地方的数据总是“不听话”。Zod 的价值,就在于它把这两个世界桥接了起来:写一次定义,编译时有类型,运行时能验证。

在实际开发中,这种一致性带来的不仅是代码质量的提升,更是一种心态上的踏实。你不用再担心后端返回的数据有没有字段缺失,也不用害怕表单输入乱七八糟。因为有了 Zod,你能把“不确定”关在门外,把“确定”握在手中。

当然,Zod 不是银弹。它无法替你决定业务逻辑的正确性,但它能让你的代码更清晰,让错误更早暴露,让协作更顺畅。这已经足够让它成为现代前端与全栈开发中不可或缺的一环。

如果说 TypeScript 让我们写得安心,那么 Zod 则让我们运行得放心。