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

推导类型与转换输入数据

一个"要么接受要么报错"的 schema 已经足够描述数据了。但要真正用它来搭建东西,还需要两样东西:一种在自己代码里使用这个数据形状的方式,以及一种不靠抛异常就能处理失败的方式。

从一个描述教师的 schema 开始:

js
import * as z from 'zod'

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

一个 schema,两份用处

在 TypeScript 里,你通常得把这个形状再写一遍:

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

这样一来,同一个形状就存在于两个地方,只要有人给其中一个加了字段,两边就会慢慢对不上。

z.infer 直接从 schema 上取出类型:

ts
type Teacher = z.infer<typeof teacherSchema>
// { name: string; age: number }

给 schema 加一个字段,类型会自动跟着变。schema 才是那份定义,TypeScript 只是读取它。

Juno一个 schema,两份用处 这里的 typeof 看起来有点奇怪,因为它不是你熟悉的 JavaScript 的 typeof。这是 TypeScript 自己的版本,它在类型层面问的是"这个值的类型是什么"。

你可以把整行话当成一句话来理解:把这个 schema 描述的类型给我。

Juno一个 schema,两份用处 实际的好处是这个类型永远不会过时。给 schema 加上 email,所有用到 Teacher 类型的函数都会立刻要求这个字段,编译器会把每一处需要修改的地方都指给你看。

要是手写类型,它会悄无声息地继续描述旧的形状——这比没有类型还糟糕:代码看着有保证,实际上早就没有了。

Juno一个 schema,两份用处 输入类型和输出类型可以不一样,一旦涉及默认值和类型转换,这一点就很重要。一个带 .default() 的 schema,可以接受一个没有该字段的对象,返回的对象却带着这个字段——所以你传进去的和拿回来的,其实是两种不同的形状。

z.infer 给你的是输出类型,几乎在所有地方你想要的都是这个,因为你该读取的是解析之后的值,而不是原始输入。真需要另一边的类型时——比如给调用方可能传入的内容加类型——那就是 z.input。要是你发现自己需要用它,通常说明原始值被用在了不该用的地方。

不靠异常处理失败

parse 会抛异常。这适合那种"请求一旦有问题就该整个中断"的边界场景,但要是你想看看到底哪里出了错,它就完全不合适。

safeParse 返回的是一个结果对象:

js
const result = teacherSchema.safeParse({ name: 'Jonathan', age: 21 })

console.log(result)
// { success: true, data: { name: 'Jonathan', age: 21 } }

失败时,返回的形状会变:

js
const result = teacherSchema.safeParse({ name: 'Jonathan', age: '21' })

console.log(result.success)
// false

成功时给你 success: truedata。失败时给你 success: falseerror。这样表单就能先判断结果,再决定怎么做:

js
if (result.success) {
  console.log('可以安全地继续发送:', result.data)
} else {
  console.log('把哪里出错展示给用户看:', result.error.issues)
}
Juno不靠异常处理失败 两种写法做的检查是一样的。区别在于验证不通过时,它们分别给你什么。

parse 会抛异常,除非你捕获它,否则整个流程都会中断。safeParse 返回一个带 success 标志的对象,你可以先判断,再继续往下走。

Juno不靠异常处理失败 按接下来该发生什么来选择。在一个路由处理函数里,如果请求体不合法就该返回 400、别的什么都不用做,那用 parse 配合错误中间件就很干净。但在表单场景里,失败意味着要在三个字段旁边分别显示三条提示信息,这时候该用 safeParse

有个习惯值得养成:读 result.data,永远别读原始对象。这两者现在看起来一样,但只要 schema 里一出现默认值或类型转换,它们就不再相同了。

Juno不靠异常处理失败 这个结果是一个可辨识联合类型(discriminated union),TypeScript 会替你做类型收窄。在 if (result.success) 分支里,result.data 是有类型的,而 result.error 根本不存在;在 else 分支里则正好相反。先判断 success 不是什么代码风格偏好,而是你能否访问到对应字段的关键。

值得知道的是,parsesafeParse 做的工作完全一样。safeParse 不是什么宽松模式,它捕获的是同样的失败,只是报告方式不同。两者之间也没有值得考虑的性能差异,所以完全可以只凭控制流的需要来选。

收窄你能接受的范围

类型只是一个粗粒度的过滤器。z.number()-49e99 一视同仁,可这两个都不像是教师的年龄。

一串方法可以链在 schema 上,把它收得更紧:

js
const teacherSchema = z.object({
  name: z.string(),
  age: z.number().min(18),
  isAmerican: z.boolean().optional(),
  id: z.number().default(() => Math.random()),
})
  • .min(18) 拒绝任何低于 18 的值。.gte().lte() 用明确的比较做同样的事。
  • .optional() 允许这个字段完全缺失。
  • .default() 在字段缺失时提供一个值,这样解析出来的对象总会有这个字段。

Zod 还内置了常见格式的检查,验证邮箱一个调用就够了:

js
const contactSchema = z.object({
  email: z.email(),
})

contactSchema.parse({ email: '[email protected]' })  // 没问题
contactSchema.parse({ email: 'jabbahuttcorp.com' })   // ZodError
Juno收窄你能接受的范围 这几行代码你要是念出声来,每一行都像一句人话:一个数字,至少 18。一个布尔值,可选。一个数字,缺省时给个默认值。

这就是这个库所说的"声明式":你描述想要的结果,检查的活儿它替你干。

Juno收窄你能接受的范围 最大值是大家最容易忘的,而这恰恰是本节开头那种滥用场景里最要紧的东西。一个只有最小值、没有最大值的字段,照样能接受一千万个字符。

给每个要存储的字符串都加上 .max()。这是应对超长输入问题最省事的办法,而且它该写在 schema 里,而不是零零散散地分布在各个处理函数中。

Juno收窄你能接受的范围.default() 有个陷阱,几乎每个人第一次写例子都会一头栽进去。写成 .default(Math.random()),这个函数只会在 schema 构建时被调用一次,于是这个进程存活期间的每一次解析,拿到的都是同一个值。在 Zod 4.5.4 上验证过:对一个空对象解析两次,返回的是同一个数字。

改成传一个函数,.default(() => Math.random()),它才会在每次解析时都被求值。解析两次,就是两个不同的数字。Date.now() 和任何生成的 id 都是同样的道理,而且这种失败很隐蔽,因为一个永远不变的默认值,看起来仍然像是个默认值。

关于 z.email():它只是格式检查,没有任何正则表达式能判断一个地址是否真的能收信。把它当作过滤明显不合法地址的手段就好,真正确认地址是否真实存在,得靠确认邮件链接。

转换传入的数据

HTML 表单字段发送的都是字符串。每一个都是,包括那个标着"年龄"、旁边还带着数字调节按钮的字段。

所以一个期待 z.number() 的 schema 会拒绝完全合理的表单输入,因为 '13' 是个字符串。类型转换会先转换,再验证:

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

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

进去的是字符串,出来的是数字,这个字段之后的任何检查都是针对这个数字进行的。

Juno转换传入的数据 这里的顺序很关键,而且很可能和你猜的相反。先做类型转换,再对转换的结果做检查。

所以 z.coerce.number().min(18) 是先把文本转成数字,再判断这个数字是否至少是 18。

Juno转换传入的数据 在传输过程会丢失类型信息的边界上用类型转换:表单数据、查询字符串、环境变量、CSV 行。这些地方一切都以文本形式到达,总得有个环节负责转换。

而在你自己的服务之间,JSON 本来就能承载真正的数字和布尔值,这时候类型转换大多只会掩盖 bug。如果某个服务本该传数字,却发来了 "42",你应该想知道这件事,而不是被悄悄转换掉。

Juno转换传入的数据 类型转换用的是 JavaScript 自身的转换规则,这套规则比"转换"这个词听起来要松散得多。以下两个结论都在 Zod 4.5.4 上验证过,都值得记住。

z.coerce.number() 会接受空字符串,并把它转换成 0,而且视为成功。表单里一个没填的数字字段提交的就是 "",于是一个本该必填的金额会悄悄变成零,而不是验证失败。要么把类型转换和范围检查配合使用,要么在解析前就拒绝空字符串。

z.coerce.boolean() 更糟:它内部用的是 Boolean(),所以字符串 "false" 会变成 true"0" 和任何非空字符串也是一样。对复选框或查询参数来说,这几乎从来都不是你想要的结果。老老实实匹配你真正预期的那两个字符串,自己手动做映射。

动手试试

从下面这个 schema 和对象出发:

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

const character = {
  name: 'Jabba the Hutt',
  episode: '6',
}

做四处修改:

  1. 创建一个从 schema 推导出的 Character 类型,并用它给这个对象做类型标注。
  2. 给 schema 加一个可选的布尔字段 isJedi
  3. episode 能接受字符串 '6',并把它存成数字。
  4. 把验证是否通过的结果,作为一个单独的布尔值打印出来。
对照一下你的答案
ts
type Character = z.infer<typeof characterSchema>

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

const character: Character = {
  name: 'Jabba the Hutt',
  episode: '6',
}

console.log(characterSchema.safeParse(character).success)
// true

针对这四处修改,有四点说明:

  • z.infer<typeof characterSchema> 直接从 schema 上读取类型,所以加上 isJedi 之后类型会自动更新,不用再改第二个地方。
  • .optional() 表示这个字段可以缺失,这也是为什么 character 不需要 isJedi 字段照样能通过验证。
  • z.coerce.number() 先转换再检查,所以字符串 '6' 会变成数字 6
  • .success 就是那个布尔值。单独打印 safeParse(...) 会输出整个结果对象,而 parse 给你的要么是数据要么是异常,都不是布尔值。

有一点值得留意:由于 episode 做了类型转换,标注的类型说是 number,而对象字面量里存的却是 '6'。这正是输入类型和输出类型不一致的地方,也是为什么该读 result.data 而不是原始对象的原因。

接下来去哪儿

safeParse 失败时会返回一个 error,但到目前为止,我们只是确认了它的存在,还没真正去读它。

读懂验证错误 会把它打开来看:哪个字段出了问题,问题出在哪,以及怎么把它变成用户能看懂、能照着行动的提示信息。