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

读懂验证错误

解析失败会返回一个错误对象。如果直接把它打印到控制台,看起来就是一大堆文字,让人无从下手。

其实不然。把提示信息挂到正确的表单字段上所需要的一切,都藏在这个错误对象里,而且它的结构本来就是设计给代码读取的。

从一个两个值都会出错的 schema 开始:

js
import * as z from 'zod'

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

const result = teacherSchema.safeParse({ name: 12345, age: 13 })

issues 数组

错误对象里真正有用的内容都在 .issues 上,它是一个数组,因为一次解析可能同时发现好几个问题:

js
console.log(result.error.issues.length)
// 2

两个值都出错了,所以有两条 issue。每一条都是一个描述单个问题的对象:

js
console.log(result.error.issues[0])
// {
//   expected: 'string',
//   code: 'invalid_type',
//   path: [ 'name' ],
//   message: 'Invalid input: expected string, received number'
// }

值得记住的四个字段:

字段它告诉你什么
code失败的种类,比如 invalid_typetoo_small
path哪个字段出了问题,以数组形式表示
message描述问题的一句话
expected在类型失败时,schema 原本期望的类型

用扫描仪来打比方依然成立。控制台里的提示信息,是机器打在显示屏上的摘要。issues 才是底下那份详细报告,真正拿来干活的是它。

Junoissues 数组 容易让人搞混的地方在于:打印错误时看到的是一条提示信息,于是你会以为整个错误就只有这一句话。

其实数组一直都在那里。用 .issues 取出来,你就能拿到结构化的版本,每一条问题占一个条目。

Junoissues 数组 它是数组这件事,带来两个结果。验证不会在第一个失败处就停下,所以表单可以一次性把所有问题都展示出来,而不是让用户每提交一次只能改一个字段。

issues[0] 是个你迟早会后悔的捷径。测试时只有一个字段出错,用第一个条目没问题;可一旦真实用户同时填错两个字段,剩下的问题就会被悄悄吞掉。

Junoissues 数组 当行为需要分支处理时,应该以 code 作为判断依据,因为它比提示信息稳定得多。密码字段上的 too_small,说明用户在自我纠正;而几乎每个字段都是 invalid_type,通常意味着客户端发错了内容类型,这种情况值得单独记录日志。

issue 对象会根据 code 携带额外的字段。经 Zod 4.5.4 验证,too_small 这类 issue 会带上 minimuminclusive,这样你只需写一次“年龄必须满18岁”这样的文案,具体数字直接从 issue 里读取,不用在提示信息里重复写死。

不过响应内容有一条铁律:给客户端展示的内容,和你记录到日志里的内容,应该是两份不同的东西。字段名和约束条件可以放心返回。但收到的原始值不行,因为被拒绝的输入里可能包含密码和令牌,而错误响应正是人们最容易忘记自己在往外发送数据的地方。

哪个字段出了问题

path 是一个数组而不是字符串,因为字段的层级可能是嵌套的:

js
const orderSchema = z.object({
  user: z.object({
    profile: z.object({
      email: z.email(),
    }),
  }),
})

const bad = orderSchema.safeParse({ user: { profile: { email: 'nope' } } })

console.log(bad.error.issues[0].path)
// [ 'user', 'profile', 'email' ]

对于扁平的表单来说,第一个元素就是字段名,这已经足够拼出表单需要的东西:

js
const errors = result.error.issues.map((issue) => ({
  field: issue.path[0],
  message: issue.message,
}))

console.log(errors)
// [
//   { field: 'name', message: 'Invalid input: expected string, received number' },
//   { field: 'age', message: 'Too small: expected number to be >=18' }
// ]

一个字段名配一句提示,每个问题各一条。表单需要的正是这种结构,好把每条提示放在对应输入框旁边。

Juno哪个字段出了问题 用数组来指代一个字段名,看起来有点小题大做——直到数据出现了层级结构。

['user', 'profile', 'email'] 就像一组路线指示:先进入 user,再进入 profile,最后是 email。普通字符串没法表达这种层级,除非你再手动把它拆开。

Juno哪个字段出了问题path[0] 用在扁平表单上没问题,但一旦出现任何嵌套结构就会出错:它会把同一个父对象下的所有字段都折叠成同一个键,提示信息就会跑错地方。

issue.path.join('.') 会得到 user.profile.email,能保持唯一性。从一开始就这么写是值得的,反正不费什么成本,遇到第一个嵌套对象时也不会翻车。

Juno哪个字段出了问题 数组的索引在 path 里会以数字形式出现,所以列表内部的失败可能长这样:['items', 2, 'quantity']。把它拼接起来会得到 items.2.quantity,这写进日志没问题,但不是表单库期望的格式;大多数表单库要的是 items[2].quantity。这个转换值得在把 issue 映射到表单的地方统一处理一次。

针对常见情况,Zod 自带了一个帮助函数:z.flattenError(result.error) 会返回 { formErrors, fieldErrors },其中 fieldErrors 把每个顶层字段名映射到一个提示信息字符串数组。经 Zod 4.5.4 验证。这是搭建表单最快的路径,但它本来就是靠“拍平”嵌套结构来实现的,所以适合扁平表单,不适合层级深的表单。要注意,在 Zod 3 里这是错误对象上的 .flatten() 方法;到了 Zod 4,它变成了顶层函数。

编写自己的提示信息

默认的提示信息描述的是类型系统本身,而不是你的表单。“Invalid input: expected string, received number”这句话很准确,但对任何一个真实用户来说都毫无意义。

几乎所有 Zod 方法都接受一个提示信息作为参数:

js
const teacherSchema = z.object({
  name: z.string('请输入你的姓名'),
  age: z.number().min(18, '教师年龄必须年满十八岁'),
})

const result = teacherSchema.safeParse({ name: 12345, age: 13 })

console.log(result.error.issues.map((issue) => issue.message))
// [ '请输入你的姓名', '教师年龄必须年满十八岁' ]

这条提示信息只替换该项检查的默认信息。每个约束都带着自己的提示,所以同一个字段在“没填”和“填得太短”这两种情况下可以显示不同的话。

Juno编写自己的提示信息 写这些提示信息时,就当作是在跟填表单的人说话。“请输入你的姓名”比任何提到“类型”的说法都好用。

看到这条提示的人不是在调试你的 schema,他们只是想注册一个账号而已。

Juno编写自己的提示信息 因为提示信息是按每项检查而不是按每个字段来设置的,一个带三条约束的字段就需要三条提示信息,漏掉一条,中间就会冒出一句默认信息,破坏你精心写好的文案。

多说“该怎么做”,少说“哪里错了”。“请至少输入12个字符”让人知道怎么改;“String must contain at least 12 character(s)”只会让人猜半天你到底是什么意思。

Juno编写自己的提示信息 自定义提示信息也是一个分水岭:从这以后,错误就不能再原样返回给客户端了。你自己写的提示信息是安全的;而默认信息其实是在描述你的 schema 长什么样,一个专门收集这些信息的客户端,能借此摸清你 API 的具体结构和约束条件。

在注册表单这种场景下,这个风险不算高,但在内部接口上就值得认真考虑了。能长期奏效的做法是:客户端应该看到的每项检查都配一条自定义提示,其余没配提示的检查一律返回一条通用响应。

至于多语言支持,把翻译字符串写在每次调用里是错的层级,因为那意味着你得把语言参数一路穿进每一个 schema 定义里。Zod 支持全局的错误映射(error map),这样翻译只需要在格式化 issue、生成响应的那一处统一处理即可。

动手试一试

给定下面这个 schema,以及一个同时违反两条规则的角色数据:

js
const characterSchema = z.object({
  name: z.string('每个角色都需要一个名字'),
  episode: z.number().min(1, '集数从第1集开始'),
})

const character = { name: 42, episode: 0 }

六处空白,用 ___ 标记。successresulterrorissuespathmessage 各自恰好对应其中一处:

js
const ___ = characterSchema.safeParse(character)

if (result.___) {
  console.log('All good')
} else {
  console.log(
    result.___.___.map((issue) => ({
      field: issue.___[0],
      message: issue.___,
    })),
  )
}
核对你的答案
js
const result = characterSchema.safeParse(character)

if (result.success) {
  console.log('All good')
} else {
  console.log(
    result.error.issues.map((issue) => ({
      field: issue.path[0],
      message: issue.message,
    })),
  )
}

按顺序梳理一遍:

  • 第一处空白是给 safeParse 的返回值命名的,下面全都写作 result,所以这里必须是 result
  • result.success 是决定走哪个分支的布尔值。
  • result.error 只在失败的分支上才存在,这就是为什么它出现在 else 里面。
  • .issues 是那个数组,所以 .map 需要用它。
  • issue.path[0] 是字段名,因为 path 是数组,所以要用下标取值。
  • issue.message 就是那句提示信息。

这一串串起来其实就是一句话:这次结果的错误里,每条 issue 都有一个 path 和一条 message。

两条规则都被违反了,所以会打印出两条记录:

js
// [
//   { field: 'name', message: '每个角色都需要一个名字' },
//   { field: 'episode', message: '集数从第1集开始' }
// ]

接下来去哪儿

这三章的 Zod 内容,全都是你手动拿虚构对象跑出来的。schema 本身确实在做实实在在的工作,但目前还没有跟真正的应用程序连接起来。

验证该放在哪里 会把它接入应用程序,把这层检查放在传入的请求和处理请求的代码之间。