Zod 集成实践

要点

  • @hono/zod-validator 本质是一个返回 MiddlewareHandler 的工厂函数,它把 Zod 的 safeParse 包进 Hono 的中间件链路
  • Schema 组合(extendpickomitpartial)可以把同一份基础定义拆出 create / update / response 等多种变体
  • Zod 默认错误结构是扁平的 issue 数组,面向前端 API 时通常需要转换成按字段分组的形式
  • Schema 对象在定义时完成内部结构构建,调用 parse 时才执行校验管线——放在模块顶层定义,不要在函数内重复构建
  • refinesuperRefine 可以承接领域级约束,但它们的校验逻辑不会反映到 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 推荐的校验入口——不抛异常,返回 &#123; success, data &#125;&#123; success: false, error &#125; 的联合类型。zValidator 内部调用的就是 safeParse,这决定了校验失败时由中间件直接返回 400,而不是抛异常中断请求。

2. @hono/zod-validator 内部机制

@hono/zod-validator 的源码很短(不到 100 行),值得读一遍。它的核心流程可以归纳为三步:

  1. 数据提取:按 target 参数('json' / 'query' / 'param' / 'header')调用对应的 c.req.xxx() 方法,取出原始数据。数据解析行为完全由 Hono 控制。
  2. 校验执行:对取出的数据调用 schema.safeParse(value)。失败时调 hook 或返回默认 400 响应。
  3. 类型注入:成功时通过 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 的类型推导依赖显式泛型参数,不传会退化成 unknown

refine() 用于跨字段约束:

// 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'],
    })
  }
})

需要注意:refinesuperRefine 的校验逻辑不会反映到 TypeScript 类型里。如果需要收窄类型,应该用 transform

6. Zod 错误结构转换

Zod 的 ZodError 包含一个 issues 数组,每个 issue 有 codepathmessage 三个核心字段。这个结构对机器友好,但对前端不够直观。常见的转换方式:

// 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. 性能考量

几个值得留意的点:

  1. Schema 定义放模块顶层。Zod 在定义 schema 时完成内部结构构建,后续 safeParse 只执行校验逻辑。在函数内部每次重建 schema 会浪费性能。
  2. safeParseparse 速度一致。区别只在错误处理——zValidatorsafeParse 是因为中间件链路不适合用 try/catch。
  3. refine / transform 是同步执行。不要在内部做异步操作(如查数据库),异步校验放在 schema 校验之后的业务层。
  4. 包体积。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&lt;T&gt;z.output&lt;T&gt; 分别提取。

未知字段处理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
}

延伸阅读

总结

Zod 和 Hono 的集成可以归纳为三层结构:

  1. Schema 层:用 z.object() 定义数据结构,用 extend / pick / omit / partial 组合出多种变体,用 refine / superRefine 表达跨字段约束。
  2. 中间件层zValidator(target, schema, hook?) 把 schema 接入 Hono 的请求处理链路,负责数据提取、校验执行、类型注入。
  3. 错误处理层ZodError 的 issues 数组经过格式化后输出给前端,封装 formatZodIssues 保证项目内错误格式统一。

掌握这三层之后,大部分校验需求可以通过组合 schema + 复用 validate 中间件来解决。遇到 Zod 内置类型覆盖不了的场景,z.custom()superRefine() 提供了扩展空间。

下一篇介绍 Valibot 的集成方式,看看它在 tree-shaking、包体积和 API 设计上和 Zod 有哪些差异。