Zod 集成实践
要点
@hono/zod-validator本质是一个返回MiddlewareHandler的工厂函数,它把 Zod 的safeParse包进 Hono 的中间件链路- Schema 组合(
extend、pick、omit、partial)可以把同一份基础定义拆出 create / update / response 等多种变体 - Zod 默认错误结构是扁平的 issue 数组,面向前端 API 时通常需要转换成按字段分组的形式
- Schema 对象在定义时完成内部结构构建,调用
parse时才执行校验管线——放在模块顶层定义,不要在函数内重复构建 refine和superRefine可以承接领域级约束,但它们的校验逻辑不会反映到 TypeScript 类型里- 从手动校验迁移到 Zod 可以逐路由推进,不需要一次性改写
1. Zod 基础回顾
01 已经介绍过 Zod 的基本用法,这里只做概念对齐。
Zod 的核心思路是 schema 即类型——运行时定义 schema 的同时,推导出编译时类型:
// src/schemas/user.ts
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0).optional(),
})
type CreateUserInput = z.infer<typeof createUserSchema>
// { name: string; email: string; age?: number | undefined }safeParse 是 Zod 推荐的校验入口——不抛异常,返回 { success, data } 或 { success: false, error } 的联合类型。zValidator 内部调用的就是 safeParse,这决定了校验失败时由中间件直接返回 400,而不是抛异常中断请求。
2. @hono/zod-validator 内部机制
@hono/zod-validator 的源码很短(不到 100 行),值得读一遍。它的核心流程可以归纳为三步:
- 数据提取:按
target参数('json'/'query'/'param'/'header')调用对应的c.req.xxx()方法,取出原始数据。数据解析行为完全由 Hono 控制。 - 校验执行:对取出的数据调用
schema.safeParse(value)。失败时调 hook 或返回默认 400 响应。 - 类型注入:成功时通过
c.req.addValidatedData(target, result.data)把数据存入 Context。后续处理函数用c.req.valid('json')取出的就是经过类型推导的数据。
hook 函数是唯一的扩展点。自定义错误格式、记日志、校验失败时的额外处理,都通过第三个参数 hook 来做。hook 返回 Response 就短路请求,返回 void 就走默认逻辑。
理解这三层分工之后,遇到「JSON 解析错误没走 Zod error」或「c.req.valid() 类型不对」这类问题,就知道该看哪一层。
3. Schema 组合模式
Zod 提供了一组方法,可以从已有 schema 派生出新 schema。这些方法在处理「同一实体的多种变体」时特别实用。
// src/schemas/user.ts
const baseUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
// extend:添加字段
const createUserSchema = baseUserSchema.extend({
password: z.string().min(8),
})
// pick / omit:选取或排除字段
const loginSchema = createUserSchema.pick({ email: true, password: true })
const userResponseSchema = createUserSchema.omit({ password: true })
// partial:所有字段变可选(适合 PATCH 接口)
const patchUserSchema = baseUserSchema.partial()
// 也可以只对部分字段做可选
const patchFieldsSchema = baseUserSchema.pick({ name: true, email: true }).partial()
// merge:合并两个 schema,相同字段以后者为准
const timestampsSchema = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
const fullUserSchema = baseUserSchema.merge(timestampsSchema)4. 可复用 Schema 模式
实际项目里,同一个资源通常需要三种 schema:创建输入、更新输入、响应输出。用一个基础 schema 派生变体,可以避免重复定义:
// src/schemas/article.ts
import { z } from 'zod'
// 基础 schema
const articleBaseSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
status: z.enum(['draft', 'published', 'archived']),
tags: z.array(z.string()).default([]),
})
export const createArticleSchema = articleBaseSchema
export const updateArticleSchema = articleBaseSchema
.partial()
.extend({ id: z.string().uuid() })
export const listArticlesSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
status: z.enum(['draft', 'published', 'archived']).optional(),
})
export const articleResponseSchema = articleBaseSchema.extend({
id: z.string().uuid(),
authorId: z.string().uuid(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})这种模式的好处是,字段含义的变更只需要改 articleBaseSchema 一个地方,其他派生 schema 都会跟着变。
5. 自定义类型与 Refine
遇到领域特定约束时,Zod 提供三种扩展方式。
z.custom() 用于 Zod 内置类型覆盖不了的场景(如 branded type):
// src/schemas/custom.ts
type UserId = string & { __brand: 'UserId' }
const userIdSchema = z.custom<UserId>(
(val) => typeof val === 'string' && /^[0-9a-f-]{36}$/.test(val as string),
{ message: '无效的 UserId 格式' }
)
// 注意:z.custom 的类型推导依赖显式泛型参数,不传会退化成 unknownrefine() 用于跨字段约束:
// src/schemas/event.ts
const eventDateRangeSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
}).refine(
(data) => new Date(data.endDate) > new Date(data.startDate),
{ message: '结束时间必须晚于开始时间', path: ['endDate'] }
)superRefine() 用于需要报多个错误的场景——refine 只能报一个,superRefine 可以多次调用 ctx.addIssue:
// src/schemas/event.ts
const eventSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
title: z.string().min(1),
}).superRefine((data, ctx) => {
if (new Date(data.endDate) <= new Date(data.startDate)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '结束时间必须晚于开始时间',
path: ['endDate'],
})
}
if (data.title.includes(data.startDate.slice(0, 10))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '标题不能包含开始日期',
path: ['title'],
})
}
})需要注意:refine 和 superRefine 的校验逻辑不会反映到 TypeScript 类型里。如果需要收窄类型,应该用 transform。
6. Zod 错误结构转换
Zod 的 ZodError 包含一个 issues 数组,每个 issue 有 code、path、message 三个核心字段。这个结构对机器友好,但对前端不够直观。常见的转换方式:
// src/utils/format-zod-error.ts
import type { ZodError } from 'zod'
interface FormattedIssue {
path: string
message: string
code: string
}
export function formatZodIssues(error: ZodError): FormattedIssue[] {
return error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
code: issue.code,
}))
}ZodError.flatten() 也可以按字段分组,但局限是 path 只支持一层——嵌套对象的深层路径会被截断。手动遍历 issues 的方式保留了完整路径,更适合嵌套结构。
封装成统一的 validate 中间件后,所有路由的错误格式保持一致:
// src/middleware/validate.ts
import { zValidator } from '@hono/zod-validator'
import type { ValidationTargets } from 'hono'
import type { ZodSchema } from 'zod'
import { formatZodIssues } from '../utils/format-zod-error'
export function validate<T extends keyof ValidationTargets>(
target: T,
schema: ZodSchema
) {
return zValidator(target, schema, (result, c) => {
if (!result.success) {
return c.json(
{ code: 'VALIDATION_ERROR', issues: formatZodIssues(result.error) },
400
)
}
})
}7. 性能考量
几个值得留意的点:
- Schema 定义放模块顶层。Zod 在定义 schema 时完成内部结构构建,后续
safeParse只执行校验逻辑。在函数内部每次重建 schema 会浪费性能。 safeParse和parse速度一致。区别只在错误处理——zValidator选safeParse是因为中间件链路不适合用 try/catch。refine/transform是同步执行。不要在内部做异步操作(如查数据库),异步校验放在 schema 校验之后的业务层。- 包体积。Zod 压缩后约 13KB。在 edge runtime 或 serverless 场景下,如果对包体积敏感,可以考虑 07 的 Valibot(按需导入约 1KB)。
8. Zod vs 手动校验
什么时候值得引入 Zod,什么时候手写 if 就够了:
适合 Zod:字段数超过 5 个;需要同时产出运行时校验和编译时类型;校验规则复杂(嵌套对象、跨字段约束、类型转换);多人协作时 schema 作为单一事实来源。
适合手动:只有一个字段要检查;校验规则依赖运行时配置;对包体积有严格限制。
实际项目里两种写法通常共存——关键路由(创建、更新)用 Zod,简单查询参数手动取就行。
9. 测试 Zod Schema
Schema 本身是纯函数,很适合单元测试。不需要 mock HTTP 请求,直接调 safeParse 检查输入输出:
// tests/schemas/user.test.ts
import { describe, expect, it } from 'vitest'
import { createUserSchema } from '../../src/schemas/user'
describe('createUserSchema', () => {
it('接受合法输入', () => {
const result = createUserSchema.safeParse({
name: 'Alice',
email: '[email protected]',
})
expect(result.success).toBe(true)
})
it('拒绝空 name,错误挂在正确字段上', () => {
const result = createUserSchema.safeParse({ name: '', email: '[email protected]' })
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].path).toEqual(['name'])
}
})
it('缺少必填字段时返回所有缺失字段的 path', () => {
const result = createUserSchema.safeParse({})
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((i) => i.path[0])
expect(paths).toContain('name')
expect(paths).toContain('email')
}
})
})测试 refine 约束时,重点关注错误是否挂在预期的 path 上、message 是否符合预期。这比在集成测试里覆盖校验逻辑稳定得多。
10. 从手动校验迁移到 Zod
已有项目不需要一次性全部迁移,逐路由替换更稳妥。
迁移前:
// src/routes/old-users.ts
app.post('/users', async (c) => {
const body = await c.req.json()
if (!body.name || typeof body.name !== 'string') {
return c.json({ error: 'name is required' }, 400)
}
if (!body.email || !body.email.includes('@')) {
return c.json({ error: 'valid email is required' }, 400)
}
return c.json(await createUser(body), 201)
})迁移后:
// src/routes/users.ts
import { zValidator } from '@hono/zod-validator'
import { createUserSchema } from '../schemas/user'
app.post('/users', zValidator('json', createUserSchema), (c) => {
const data = c.req.valid('json')
// data 已经是 { name: string; email: string },不需要 typeof 检查和 as 断言
return c.json(createUser(data), 201)
})渐进迁移的三个要点:先从校验入口最少的路由开始;先定好错误输出格式(formatZodIssues)确保前端适配;如果旧接口已有前端依赖,用 zValidator 的 hook 输出和旧格式一致的错误,避免前端同步改动。
11. 常用 Schema 模式
几个在实际项目中反复出现的 schema 片段,可以提前封装复用:
// src/schemas/common.ts
import { z } from 'zod'
// 分页
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
})
// 日期范围(带跨字段约束)
export const dateRangeSchema = z.object({
from: z.coerce.date(),
to: z.coerce.date(),
}).refine((data) => data.to >= data.from, {
message: '截止日期不能早于起始日期',
path: ['to'],
})
// UUID 路由参数
export const uuidParamSchema = z.object({
id: z.string().uuid(),
})
// 可选筛选
export const optionalFilterSchema = z.object({
keyword: z.string().min(1).optional(),
status: z.enum(['active', 'inactive']).optional(),
sortBy: z.enum(['createdAt', 'updatedAt', 'name']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
})这些模式可以用 merge 组合使用:
// src/routes/articles.ts
import { paginationSchema, optionalFilterSchema } from '../schemas/common'
const listQuerySchema = paginationSchema.merge(optionalFilterSchema)
app.get('/articles', zValidator('query', listQuerySchema), (c) => {
const query = c.req.valid('query')
// { page: number; limit: number; keyword?: string; status?: string; ... }
return c.json({ items: [], ...query })
})12. Zod 与 TypeScript Strict Mode
在 "strict": true 下使用 Zod 时,几个容易遇到的问题:
z.infer 与 optional:.optional() 推导为 T | undefined。如果区分输入类型和输出类型(比如有 transform 时),用 z.input<T> 和 z.output<T> 分别提取。
未知字段处理:z.object() 默认 strip 未声明字段。需要保留未知字段用 .passthrough(),需要拒绝未知字段用 .strict():
// src/schemas/user.ts
const strictUserSchema = z.object({ name: z.string() }).strict()
// { name: 'Alice', extra: true } → 校验失败,报 Unrecognized key(s)
const passthroughSchema = z.object({ name: z.string() }).passthrough()
// { name: 'Alice', extra: true } → 校验通过,data 包含 extra泛型约束:把 schema 作为参数传给通用函数时,用 z.ZodSchema 做约束:
// src/utils/validate.ts
export function parseOrThrow<T extends z.ZodSchema>(
schema: T,
data: unknown
): z.infer<T> {
const result = schema.safeParse(data)
if (!result.success) throw new Error(result.error.message)
return result.data
}延伸阅读
- 01-为什么需要请求验证 — zValidator 基本用法和错误 hook
- 02-参数校验设计 — 校验策略的整体设计思路
- 07-Valibot 集成实践 — 另一种 schema 校验库的对比,tree-shaking 友好、更小的包体积
- Zod 官方文档 — 完整的 API 参考和错误处理指南
- @hono/zod-validator 源码 — 源码不到 100 行,适合通读
总结
Zod 和 Hono 的集成可以归纳为三层结构:
- Schema 层:用
z.object()定义数据结构,用extend/pick/omit/partial组合出多种变体,用refine/superRefine表达跨字段约束。 - 中间件层:
zValidator(target, schema, hook?)把 schema 接入 Hono 的请求处理链路,负责数据提取、校验执行、类型注入。 - 错误处理层:
ZodError的 issues 数组经过格式化后输出给前端,封装formatZodIssues保证项目内错误格式统一。
掌握这三层之后,大部分校验需求可以通过组合 schema + 复用 validate 中间件来解决。遇到 Zod 内置类型覆盖不了的场景,z.custom() 和 superRefine() 提供了扩展空间。
下一篇介绍 Valibot 的集成方式,看看它在 tree-shaking、包体积和 API 设计上和 Zod 有哪些差异。