Skip to content
This page has been auto-translated and may contain errors.View in English

Zod 基础

到目前为止出现的三个 bug,形状都一样:一个值传了进来,代码用了它,但没有人先检查一下。

在每个出问题的地方各自修一次也能凑效,但这样永远修不完。每多一个新的使用位置,就多一个要记住检查的地方。

另一种做法是:在值到达的那一刻,就用一份"你期望它是什么样"的描述去核对它。这份描述就是 schema(模式),本节要用来写它的库就是 Zod

什么是 schema

想象机场的安检扫描仪。你的包从一头放进去,机器已经设置好了规则:不能带利器,液体不能超过一定体积。符合规则的包会从另一头原样出来,不符合的就过不去。

schema 就是机器的这些设置,而 parse(解析)就是让包过一遍扫描仪的过程。

先安装它,再引入:

bash
npm install zod
js
import * as z from 'zod'

最简单的 schema 就是针对单个值的一条规则:

js
const teacherSchema = z.string()

const teacher = 'Jonathan'

console.log(teacherSchema.parse(teacher))
// 'Jonathan'

parse 把数据交给机器。数据符合规则,就会原样返回。

给它一个不符合规则的值,机器就会拦下:

js
teacherSchema.parse(12345)
// ZodError: Invalid input: expected string, received number

这就是整个模型:描述什么是可接受的,把值传进去,要么拿回这个值,要么拿到一个错误。

Juno什么是 schema 有两个词值得一开始就分清楚,因为它们经常被混着用,但在这里意思并不一样。

schema 是你会接受什么样数据的描述。parse(解析)是拿某样东西去和这份描述核对的动作。schema 只写一次,之后可以随便用它来 parse 多少次都行。

Juno什么是 schema 注意 parse 返回的是这个值本身,而不是一个真假判断。这是刻意设计的:它是一个数据要经过的检查点,而不是在旁边跑一遍的测试。

由此养成的习惯是:parse 之后就不再使用原始输入,而是改用返回的值。现在数据一模一样,等你开始加默认值和类型转换之后,这个习惯就意味着后续流转的一定是解析过的值。

Juno什么是 schema 这套 API 是不可变的,这一点安静得容易被忽略,也确实会导致真实的 bug。每个方法调用后返回的都是一个新的 schema,而不是修改原来那个,所以 schema.min(3) 是一个你必须接住的值。

如果你调用了它却把结果丢掉,原来那个 schema 不会变,约束条件就悄无声息地不存在了。

parse 会抛出异常,这适合"失败就应该终止请求"的边界场景,但完全不适合你想检查失败原因的场景。另一个不抛异常、而是返回一个结果对象的方案,是你在表单或路由处理函数里会想用的,下一章会讲到。

描述一个对象

一位老师很少只是一个字符串就能描述完的。给 schema 一个形状:

js
const teacherSchema = z.object({
  name: z.string(),
  age: z.number(),
})

const teacher = {
  name: 'Jonathan',
  age: 21,
}

console.log(teacherSchema.parse(teacher))
// { name: 'Jonathan', age: 21 }

z.object() 里的每一项都是一个键,它的值就是这个键对应的 schema。现在机器期望的是一个对象,其中 name 是任意字符串,age 是任意数字。

破坏其中任何一条,报错都会精确指出是哪一条:

js
teacherSchema.parse({ name: 'Jonathan', age: '21' })
// ZodError: Invalid input: expected number, received string

Zod 提供了你期望的那些基本类型,z.string()z.number()z.boolean(),而对象可以像你的数据一样,一层套一层地嵌套。

Juno描述一个对象 能让这套方法伸缩自如的正是嵌套这个特性。一个对象的 schema 是由它各个字段的 schema 拼出来的,而每个字段本身又可以是一个对象。

所以同一个思路既能描述一个两字段的表单,也能描述一个结构层层叠叠的 API 响应。你始终只是在一次描述一层。

Juno描述一个对象 有件事最好提前知道,免得以后被它绊一下:z.object() 会忽略你没有描述过的键。用只描述了 nameage 的 schema 去解析 { name: 'A', age: 1, extra: 'x' },得到的结果是 { name: 'A', age: 1 },没有报错,也没有 extra

这在边界处通常正是你想要的效果,因为这意味着攻击者没法把多余的字段偷偷夹带进你后续处理的对象里。但它同时也意味着,字段名写错了会悄悄失败——你以为会有的一个值,其实悄无声息地不见了。

Juno描述一个对象 如果你想要更严格的行为,z.strictObject() 会对未识别的键报错,而不是直接丢弃。在 Zod 4.5.4 上验证过:z.object() 会剥离多余字段,z.strictObject() 会抛出异常。

选用哪一种是个实实在在的决定,而不只是风格偏好。对面向公众的接口来说,剥离多余字段是更安全的默认做法,因为客户端总会加字段,你不希望因此把它们弄坏。

严格模式适合内部服务之间的调用,因为出现一个意料之外的键,通常说明两边已经出现了偏差。及时发现这一点,总比一周后才去排查一个被悄悄丢弃的字段要好。

剥离多余字段唯一会咬人的地方是"批量赋值"式的思维。它保护的是解析出来的那个对象,对那些绕过解析、直接读取 req.body 的代码毫无帮助。解析一次之后,就再也不要去看原始输入了。

为什么这项检查必须在运行时进行

TypeScript 也能描述形状:

ts
type Teacher = {
  name: string
  age: number
}

这看起来很像 schema,做的事却完全不同。TypeScript 是在你写代码的时候检查类型。编译成 JavaScript 之后,所有类型注解都会被抹掉,没有任何东西能留到运行中的程序里。

这对于你自己代码产生的值来说没问题,但对于不是你代码产生的值就没用了。请求体、API 响应、表单提交:这些都是在程序运行时才到达的,而那时类型早就不存在了。TypeScript 假设你的数据是对的,Zod 才会去检查。

schema 描述的是一种类型,而且这种描述在数据真正出现的那一刻依然有效。

Juno为什么这项检查必须在运行时进行 这一点容易让人犯迷糊,因为两者看起来都像是在检查同一件事,可到了关键时刻,只有其中一个还在场。

TypeScript 是你写代码时和你之间的对话。schema 是程序运行时和数据之间的对话。

Juno为什么这项检查必须在运行时进行 给请求体加类型标注,是这个问题最常出现的地方。用 req.body as SignupFields 把它断言成你写的某个类型,编译能顺利通过,却什么都没检查,因为类型断言其实是你在告诉编译器"别再问了"。

之后你读取的每一个字段都只是一个假设。改用 schema 去解析请求体,这个假设才会变成事实。

Juno为什么这项检查必须在运行时进行 值得划清的边界是:任何跨越了进程边界的东西都是无类型的,不管你的类型注解怎么声称。请求体、查询字符串、环境变量、从磁盘读出的 JSON、你团队自己维护的服务返回的响应、来自某张你还没跑过迁移脚本的数据表的行数据。

第三方 API 响应是大家最容易漏掉的一类,理由通常是"提供方文档里写清楚了形状"。可提供方会发布变更,会在故障期间返回不完整的对象,会给一个从来不为 null 的字段加上 null。在这个边界处放一个 schema,能把深藏在你代码里的一次让人摸不着头脑的失败,变成入口处一次清清楚楚的失败。

Zod 在这方面的体积完全值得:没有依赖,在 Node 和浏览器里运行方式一致,所以同一个 schema 既能用在路由处理函数里,也能用在向它提交数据的那个表单上。

动手试一试

从头写一遍,不要照抄 teacher 那个例子。

  1. 引入 Zod。
  2. 创建一个 characterSchema,包含两个键:name(字符串)和 episode(数字)。
  3. 定义一个 character 对象,name 为 'Luke Skywalker',episode 为 4
  4. 用这个 schema 校验 character,并把结果打印出来。
  5. episode 改成字符串 '4',在运行之前先猜猜会发生什么。
对照一下你的答案
js
import * as z from 'zod'

const characterSchema = z.object({
  name: z.string(),
  episode: z.number(),
})

const character = {
  name: 'Luke Skywalker',
  episode: 4,
}

console.log(characterSchema.parse(character))
// { name: 'Luke Skywalker', episode: 4 }

两个键意味着要用 z.object(),而不是单一的基本类型,每个键都有自己对应的 schema。

episode'4' 时,parse 会抛出 ZodError: Invalid input: expected number, received string。字符串 '4' 不是数字,Zod 不会悄悄帮你转换。如果要有意地转换,那是另外一条指令,下一章会讲到。

顺带一提,第 4 集是对的。卢克·天行者第一次出现是在 1977 年最初那部《星球大战》电影里,后来这部电影被编号为第四集。

接下来的方向

现在的 schema 只做一件事:接受一个值,或者抛出异常。这足以描述数据,但还不足以用来搭建系统。

类型推断与输入转换 会补上让它变得实用的两块拼图。同一个 schema 也能把类型交给 TypeScript,形状只需要写一次。而解析失败时,你能拿到一个可供检查的结果,而不是必须捕获的异常。