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 体积是选型时绕不开的因素,特别是在边缘部署场景下:

  1. 边缘环境对冷启动敏感。Cloudflare Workers 有 CPU time 限制,Deno Deploy 和 Vercel Edge Functions 同样关注冷启动延迟。更小的 bundle 意味着更快的模块加载。
  2. 客户端共享 schema 时体积会被放大。如果同一份 schema 既在服务端做校验,又在客户端做表单校验(比如配合 React Hook Form),Valibot 的体积优势会在客户端体现得更明显。
  3. 按需引入是 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 版本对比,结构几乎一模一样:

功能ZodValibot
中间件工厂zValidator()vValidator()
第一个参数校验位置('json''query' 等)相同
第二个参数schemaschema
第三个参数错误 hook错误 hook
取值方式c.req.valid('json')相同

校验不同位置的数据(queryparamheader)用法完全对称,这里不再重复。可以回看 01 和 04 的写法,把 zValidator 替换成 vValidator、把 schema 定义换成 Valibot 语法就行。

4. Schema 定义对比

Valibot v1.0 之后全面采用了 pipe 风格。基础类型直接用 v.string()v.number() 创建,但一旦要加约束(长度、范围、格式),就需要用 v.pipe() 把多个校验步骤串起来。

常用类型对照

校验目标ZodValibot
非空字符串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 的场景下,InferInputInferOutput 的结果一致。一旦 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)
  }
)

注意两个细节:

  1. Zod 的错误对象是 result.error,Valibot 的是 result.issues。访问方式不同,但 hook 的整体结构一样。
  2. 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 ~较慢          ~中等

实际项目中,单次校验的延迟差异通常在微秒级别。真正能感知到差别的场景是:

  1. 边缘环境。CPU 时间有严格限制(Cloudflare Workers 免费方案只有 10ms),校验密集的路由可能受益。
  2. 高频校验。如果同一个请求要校验多个 schema(比如同时校验 header、query、body),累计差异会变得可测量。
  3. 大 payload 校验。包含大量字段的对象或深层嵌套结构,差异会更明显。

对于大多数常规 API,两种库的性能都足够。不建议仅因为性能差异做迁移决策。

9. 选型建议

适合选 Valibot 的场景

  1. bundle 体积有硬约束。比如要求客户端共享 schema 时总体积不超过 5kB,Zod 的 13kB 全量引入放不下,Valibot 按需引入可以做到 1-2kB。
  2. 边缘部署。Cloudflare Workers、Deno Deploy、Vercel Edge Functions 对冷启动时间敏感,更小的 bundle 直接转化为更低的冷启动延迟。
  3. 偏好函数式风格v.pipe() 的组合方式更接近函数式编程的管道思想,如果团队习惯这种风格,Valibot 的 API 会更自然。

适合继续用 Zod 的场景

  1. 生态成熟度。Zod 的社区方案更多——zod-to-json-schemazodiostrpcreact-hook-form 的集成都更完善。如果项目已经依赖这些工具,切换成本不低。
  2. 复杂的类型转换需求。Zod 的 .transform().catch().preload() 等高级 API 更成熟,Valibot 在这方面的覆盖还在追赶。
  3. 团队已经熟悉 Zod。如果项目里没有 bundle 压力,切换的学习成本——pipe 风格、错误结构差异、工具类型名称变化——未必划算。
  4. 项目已经大规模使用 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,可以按以下步骤逐步进行:

  1. 安装 @hono/valibot-validatorvalibot。不需要卸载 Zod 相关依赖。
  2. 从新路由开始用 Valibot。已上线的路由不要动,避免引入不必要的回归风险。
  3. 逐步替换低频路由。先挑校验逻辑简单的路由(只有基础类型和必填约束),验证 Valibot 的错误处理符合预期。
  4. 统一错误格式。这一步是关键——确保 Valibot 路由和 Zod 路由返回相同结构的错误响应。可以在中间件层封装一个通用的错误格式化函数。

反向迁移(Valibot → Zod)步骤类似,方向反过来就行。

不建议一次性全量替换。校验层改动涉及的测试和回归验证成本容易被低估。

延伸阅读

总结

Valibot 给 Hono 项目提供了一个 bundle 更小的校验方案。它的 pipe 风格和 Zod 的链式调用在表达力上对等,但实现机制不同——Valibot 的每个校验函数都是独立模块,打包工具可以移除未使用的部分。

选型时可以参考这几个维度:

  1. bundle 体积:Valibot 按需引入 1-2kB vs Zod 全量 13kB,在边缘部署和客户端共享 schema 场景下差距明显。
  2. API 风格:Valibot 是函数式 pipe,Zod 是链式调用。两种风格都能表达同一组校验规则,但写法习惯需要适应。
  3. 生态成熟度:Zod 的社区集成和高级 API 更完善,Valibot 在这方面还在追赶。
  4. 共存可行性:两者可以在同一个 Hono 项目中混合使用,但错误处理层需要分别适配。

无论选择哪个库,最终都要面对同一个问题——校验失败时怎样返回一致的错误响应。不同库的错误结构差异会给统一错误格式带来额外工作。

下一篇(08 错误消息规范化)会讨论怎样在校验层之上封装一个统一的错误处理机制,让 Zod 和 Valibot 的错误都能被转换成同一套 API 错误格式。