Valibot集成实践
要点
- Valibot 是一个模块化、可 tree-shaking 的 TypeScript 数据校验库,未使用体积可控制在 2kB 以内
@hono/valibot-validator提供与@hono/zod-validator对称的 API,接入方式几乎一致- Valibot v1.0 之后采用
v.pipe()函数式风格,和 Zod 的链式调用在写法上有明显差异 - 类型推导拆成了
InferInput(输入类型)和InferOutput(输出类型)两个工具类型 - 在边缘部署场景(Cloudflare Workers、Deno Deploy)中,Valibot 的 bundle 优势比较突出
- Valibot 和 Zod 可以在同一个 Hono 项目中共存,但错误处理层需要分别适配
1. Valibot 是什么
Valibot 是 Fabian Hiller 创建的一个 TypeScript 数据校验库。它的核心设计和 Zod 有方向上的不同:每个校验函数都是独立的模块,按需引入,不用的部分可以被打包工具彻底移除。
先看一个最简单的例子,感受 Valibot 和 Zod 在写法上的差别:
// valibot 写法
import * as v from 'valibot'
const schema = v.object({
name: v.pipe(v.string(), v.minLength(1)),
email: v.pipe(v.string(), v.email()),
})// zod 写法(01 里已经出现过)
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
})两者的 schema 表达的是同一组规则:name 是非空字符串,email 必须符合邮箱格式。区别在于组织方式。Zod 把校验方法挂在 schema 对象上,通过链式调用组合。Valibot 把每个校验器做成独立函数,用 v.pipe() 串起来。
这种设计带来的直接影响是打包体积。Zod 的核心包大约 13kB(gzip),无论用了多少功能都需要全量引入。Valibot 只引入实际使用到的校验函数,一个典型的对象校验 schema 打包后约 1-2kB(gzip)。
2. 为什么可以考虑 Valibot
bundle 体积是选型时绕不开的因素,特别是在边缘部署场景下:
- 边缘环境对冷启动敏感。Cloudflare Workers 有 CPU time 限制,Deno Deploy 和 Vercel Edge Functions 同样关注冷启动延迟。更小的 bundle 意味着更快的模块加载。
- 客户端共享 schema 时体积会被放大。如果同一份 schema 既在服务端做校验,又在客户端做表单校验(比如配合 React Hook Form),Valibot 的体积优势会在客户端体现得更明显。
- 按需引入是 Valibot 的核心机制。打包工具可以移除所有未引用的校验函数,而 Zod 的模块化粒度做不到这个程度。
不过 bundle 体积只是选型的一个维度。API 风格、错误处理、生态成熟度同样影响日常开发体验。下一节先看接入方式,后面再展开对比。
3. @hono/valibot-validator 接入
Hono 官方提供了 @hono/valibot-validator 包,和 @hono/zod-validator 属于同一套设计思路。
安装:
// terminal
npm install valibot @hono/valibot-validator基础用法:
// src/index.ts
import { Hono } from 'hono'
import { vValidator } from '@hono/valibot-validator'
import * as v from 'valibot'
const app = new Hono()
const createUserSchema = v.object({
name: v.pipe(v.string(), v.minLength(1)),
email: v.pipe(v.string(), v.email()),
})
app.post('/users', vValidator('json', createUserSchema), (c) => {
const data = c.req.valid('json')
// data 的类型自动推导为 { name: string; email: string }
return c.json({ id: 1, ...data }, 201)
})
export default app和 01 里的 Zod 版本对比,结构几乎一模一样:
| 功能 | Zod | Valibot |
|---|---|---|
| 中间件工厂 | zValidator() | vValidator() |
| 第一个参数 | 校验位置('json'、'query' 等) | 相同 |
| 第二个参数 | schema | schema |
| 第三个参数 | 错误 hook | 错误 hook |
| 取值方式 | c.req.valid('json') | 相同 |
校验不同位置的数据(query、param、header)用法完全对称,这里不再重复。可以回看 01 和 04 的写法,把 zValidator 替换成 vValidator、把 schema 定义换成 Valibot 语法就行。
4. Schema 定义对比
Valibot v1.0 之后全面采用了 pipe 风格。基础类型直接用 v.string()、v.number() 创建,但一旦要加约束(长度、范围、格式),就需要用 v.pipe() 把多个校验步骤串起来。
常用类型对照
| 校验目标 | Zod | Valibot |
|---|---|---|
| 非空字符串 | z.string().min(1) | v.pipe(v.string(), v.minLength(1)) |
| 邮箱 | z.string().email() | v.pipe(v.string(), v.email()) |
| 正整数 | z.number().int().positive() | v.pipe(v.number(), v.integer(), v.minValue(1)) |
| 字符串转数字 | z.coerce.number() | v.pipe(v.string(), v.transform(Number)) |
| 可选字段 | .optional() | v.optional(schema) |
| 默认值 | .default(value) | v.optional(schema, default) |
| 正则匹配 | z.string().regex(/^\d+$/) | v.pipe(v.string(), v.regex(/^\d+$/)) |
嵌套对象
两者在嵌套对象上的写法基本对应:
// valibot
const addressSchema = v.object({
street: v.pipe(v.string(), v.minLength(1)),
city: v.pipe(v.string(), v.minLength(1)),
zip: v.pipe(v.string(), v.regex(/^\d{6}$/)),
})
const userSchema = v.object({
name: v.pipe(v.string(), v.minLength(1)),
address: addressSchema,
tags: v.array(v.pipe(v.string(), v.minLength(1))),
})// zod
const addressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
zip: z.string().regex(/^\d{6}$/),
})
const userSchema = z.object({
name: z.string().min(1),
address: addressSchema,
tags: z.array(z.string().min(1)),
})一个需要注意的差异:Valibot 的 v.optional() 是包裹式写法,不是方法链。v.optional(v.string()) 等价于 Zod 的 z.string().optional()。如果带了默认值,v.optional(v.string(), 'guest') 对应 z.string().optional().default('guest')。
5. 类型推导
Zod 用一个 z.infer 提取校验后的类型。Valibot 把它拆成了两个工具类型:
// valibot
import type { InferInput, InferOutput } from 'valibot'
const schema = v.object({
name: v.pipe(v.string(), v.minLength(1)),
email: v.pipe(v.string(), v.email()),
})
type Input = InferInput<typeof schema>
// { name: string; email: string }
type Output = InferOutput<typeof schema>
// { name: string; email: string }在没有 transform 的场景下,InferInput 和 InferOutput 的结果一致。一旦 schema 里包含数据转换,两者就会出现分歧:
// valibot
const schema = v.object({
age: v.pipe(v.string(), v.transform(Number)),
})
type Input = InferInput<typeof schema>
// { age: string } — 转换前的类型
type Output = InferOutput<typeof schema>
// { age: number } — 转换后的类型// zod 作为对照
const schema = z.object({
age: z.coerce.number(),
})
type RawInput = z.input<typeof schema>
// { age: string | number }
type Output = z.infer<typeof schema>
// { age: number }这种区分在数据转换场景中比较实用。比如处理 HTTP 请求时,查询参数进来是字符串,经过 transform 变成数字。用 InferInput 标注请求侧的类型,用 InferOutput 标注业务逻辑侧的类型,可以在类型层面把两个阶段分开。
在 Hono 路由里,c.req.valid('json') 返回的是 InferOutput 类型——也就是经过所有 transform 之后的最终类型。
6. 自定义校验
Valibot 自定义校验
Valibot 提供了 v.check() 用于自定义校验逻辑:
// src/index.ts
const passwordSchema = v.pipe(
v.string(),
v.minLength(8),
v.check(
(input: string) => /[A-Z]/.test(input) && /[0-9]/.test(input),
'密码必须包含至少一个大写字母和一个数字'
)
)v.check() 的第一个参数是一个返回 boolean 的函数,第二个参数是校验失败时的错误消息。如果校验逻辑比较简单,也可以用内联箭头函数。
自定义 pipe 步骤
如果需要更复杂的校验——比如错误消息要依赖输入值——可以用 v.pipe() 配合自定义校验动作:
// src/index.ts
import { v } from 'valibot'
const uniqueUsernameSchema = v.pipe(
v.string(),
v.minLength(3),
v.checkAsync(
async (input: string) => {
// 查数据库是否已存在
const exists = await db.users.findByUsername(input)
return !exists
},
'该用户名已被注册'
)
)v.checkAsync 支持异步校验,适合需要查数据库或调外部 API 的场景。注意异步校验必须用 v.safeParseAsync() 或 v.parseAsync() 来执行,不能用同步版本。
和 Zod 的 custom 对比
// zod
const schema = z.string().refine(
(val) => /[A-Z]/.test(val) && /[0-9]/.test(val),
{ message: '密码必须包含至少一个大写字母和一个数字' }
)两者的能力对等。差异主要在 API 风格上:Zod 用 .refine() 挂在 schema 对象上,Valibot 用 v.check() 作为 pipe 中的一个独立步骤。
错误结构
校验失败时,Valibot 返回的 issue 结构:
// response.json
{
"issues": [
{
"message": "Invalid email",
"path": [
{ "key": "email", "type": "object" }
]
}
]
}和 Zod 的错误结构不同。Zod 的错误带有 code 字段(如 "too_small"、"invalid_string"),方便程序按错误类型分支处理。Valibot 的 issue 结构更扁平,主要依赖 message 字段。如果需要按错误类型做分支,可以在自定义校验时附带一个 abort 标志或额外的元数据。
7. 错误处理差异
vValidator 的错误 hook 签名和 zValidator 保持一致:
// src/index.ts
app.post(
'/users',
vValidator('json', createUserSchema, (result, c) => {
if (!result.success) {
return c.json(
{
code: 'VALIDATION_ERROR',
errors: result.issues.map((issue) => ({
field: issue.path?.map((p) => p.key).join('.') ?? '',
message: issue.message,
})),
},
400
)
}
}),
(c) => {
const data = c.req.valid('json')
return c.json({ id: 1, ...data }, 201)
}
)注意两个细节:
- Zod 的错误对象是
result.error,Valibot 的是result.issues。访问方式不同,但 hook 的整体结构一样。 issue.path是一个对象数组,每个元素包含key(字段名)和type(校验类型)。Zod 的path是直接的值数组(如['address', 'zip'])。提取字段路径时需要做适配。
如果项目里已经有一套统一的错误格式,无论用 Zod 还是 Valibot,都要在 hook 里做一次转换。01 里封装的 validate() 工具函数在 Valibot 侧需要重新写一份,因为错误对象的访问路径不同。
自定义错误消息
Valibot 允许在 pipe 的每个步骤上设置独立的错误消息:
// src/index.ts
const schema = v.pipe(
v.string(),
v.minLength(8, '密码长度不能少于 8 位'),
v.maxLength(64, '密码长度不能超过 64 位'),
v.check(
(input: string) => /[A-Z]/.test(input),
'密码必须包含至少一个大写字母'
)
)Zod 也支持自定义消息,写法是 .min(8, '密码长度不能少于 8 位')。两者在这方面的能力对等。
8. 性能特征
Valibot 的官方基准测试显示它的解析速度比 Zod 快,原因和两者的架构设计有关。
Zod 在创建 schema 时就把整个校验链构建成一个完整对象,所有方法链在 schema 定义阶段就已经确定。这意味着即使某些校验步骤在实际数据上不会触发,它们的元数据和逻辑也已经被预先构建。
Valibot 的 pipe 是一个函数数组,运行时按顺序依次调用。每个步骤都是一个纯函数,没有额外的元数据构建开销。
基准测试参考(数据来自 Valibot 官方 README,具体数值会因环境而异):
操作 Zod Valibot
──────────────────────────────────────
string parse ~较快 ~更快
object parse ~中等 ~较快
complex schema ~较慢 ~中等
实际项目中,单次校验的延迟差异通常在微秒级别。真正能感知到差别的场景是:
- 边缘环境。CPU 时间有严格限制(Cloudflare Workers 免费方案只有 10ms),校验密集的路由可能受益。
- 高频校验。如果同一个请求要校验多个 schema(比如同时校验 header、query、body),累计差异会变得可测量。
- 大 payload 校验。包含大量字段的对象或深层嵌套结构,差异会更明显。
对于大多数常规 API,两种库的性能都足够。不建议仅因为性能差异做迁移决策。
9. 选型建议
适合选 Valibot 的场景
- bundle 体积有硬约束。比如要求客户端共享 schema 时总体积不超过 5kB,Zod 的 13kB 全量引入放不下,Valibot 按需引入可以做到 1-2kB。
- 边缘部署。Cloudflare Workers、Deno Deploy、Vercel Edge Functions 对冷启动时间敏感,更小的 bundle 直接转化为更低的冷启动延迟。
- 偏好函数式风格。
v.pipe()的组合方式更接近函数式编程的管道思想,如果团队习惯这种风格,Valibot 的 API 会更自然。
适合继续用 Zod 的场景
- 生态成熟度。Zod 的社区方案更多——
zod-to-json-schema、zodios、trpc、react-hook-form的集成都更完善。如果项目已经依赖这些工具,切换成本不低。 - 复杂的类型转换需求。Zod 的
.transform()、.catch()、.preload()等高级 API 更成熟,Valibot 在这方面的覆盖还在追赶。 - 团队已经熟悉 Zod。如果项目里没有 bundle 压力,切换的学习成本——pipe 风格、错误结构差异、工具类型名称变化——未必划算。
- 项目已经大规模使用 Zod。迁移收益和成本不成比例。
10. 混合使用与迁移
混合使用
在 Hono 项目中,Zod 和 Valibot 可以共存。校验逻辑是按路由挂载的中间件,两条校验链路互不影响:
// src/index.ts
import { zValidator } from '@hono/zod-validator'
import { vValidator } from '@hono/valibot-validator'
import { z } from 'zod'
import * as v from 'valibot'
const zodSchema = z.object({ name: z.string().min(1) })
const valibotSchema = v.object({
name: v.pipe(v.string(), v.minLength(1)),
})
// 同一项目里两条路由各用各的
app.post('/users', zValidator('json', zodSchema), (c) => {
return c.json(c.req.valid('json'), 201)
})
app.post('/posts', vValidator('json', valibotSchema), (c) => {
return c.json(c.req.valid('json'), 201)
})但混合使用时需要注意一个问题:错误格式不统一。Zod 和 Valibot 的错误结构不同,如果在 hook 里各自做了一套错误转换,最终 API 返回的错误格式可能不一致。更好的做法是在 hook 层做一层抽象,把两种库的错误都转换成项目统一的错误格式。这部分内容会在 08 展开。
迁移建议
如果打算从 Zod 迁移到 Valibot,可以按以下步骤逐步进行:
- 安装
@hono/valibot-validator和valibot。不需要卸载 Zod 相关依赖。 - 从新路由开始用 Valibot。已上线的路由不要动,避免引入不必要的回归风险。
- 逐步替换低频路由。先挑校验逻辑简单的路由(只有基础类型和必填约束),验证 Valibot 的错误处理符合预期。
- 统一错误格式。这一步是关键——确保 Valibot 路由和 Zod 路由返回相同结构的错误响应。可以在中间件层封装一个通用的错误格式化函数。
反向迁移(Valibot → Zod)步骤类似,方向反过来就行。
不建议一次性全量替换。校验层改动涉及的测试和回归验证成本容易被低估。
延伸阅读
- 01-为什么需要请求验证 — Zod 集成的基础用法和错误处理
- 06-Zod集成实践 — Zod 在项目中的完整实践
- Valibot 官方文档 — 完整的 API 参考和 pipe 概念说明
- @hono/valibot-validator — 中间件源码和用法示例
- Zod vs Valibot 对比 — Valibot 官方的对比文章
总结
Valibot 给 Hono 项目提供了一个 bundle 更小的校验方案。它的 pipe 风格和 Zod 的链式调用在表达力上对等,但实现机制不同——Valibot 的每个校验函数都是独立模块,打包工具可以移除未使用的部分。
选型时可以参考这几个维度:
- bundle 体积:Valibot 按需引入 1-2kB vs Zod 全量 13kB,在边缘部署和客户端共享 schema 场景下差距明显。
- API 风格:Valibot 是函数式 pipe,Zod 是链式调用。两种风格都能表达同一组校验规则,但写法习惯需要适应。
- 生态成熟度:Zod 的社区集成和高级 API 更完善,Valibot 在这方面还在追赶。
- 共存可行性:两者可以在同一个 Hono 项目中混合使用,但错误处理层需要分别适配。
无论选择哪个库,最终都要面对同一个问题——校验失败时怎样返回一致的错误响应。不同库的错误结构差异会给统一错误格式带来额外工作。
下一篇(08 错误消息规范化)会讨论怎样在校验层之上封装一个统一的错误处理机制,让 Zod 和 Valibot 的错误都能被转换成同一套 API 错误格式。