Body校验

要点

  • zValidator('json', schema) 把 JSON 请求体的校验和类型推导合并到一个中间件里
  • 请求头必须带 Content-Type: application/json,否则 zValidator 无法正确解析请求体
  • Zod 的 z.object() 默认丢弃未声明字段,z.strictObject() 会直接报错,z.passthrough() 原样保留
  • 嵌套对象和数组校验要把 schema 拆成可复用的子 schema,避免单个 schema 膨胀
  • z.transform() 可以在校验阶段完成数据转换,省去后续手动处理
  • 文件上传和 multipart 请求不能走 zValidator('json', ...),需要用 c.req.parseBody()
  • 空请求体、畸形 JSON、错误的 Content-Type 是三个最常见的踩坑点

1. JSON 请求体校验

上一节介绍了 zValidator 的基本用法。这一节把视线集中到请求体上,看看在实际业务里,Body 校验会遇到哪些情况。

最基础的用法:

// src/index.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
 
const app = new Hono()
 
const createUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().min(0).optional(),
})
 
app.post('/users', zValidator('json', createUserSchema), (c) => {
  const data = c.req.valid('json')
  // data 的类型:{ name: string; email: string; age?: number }
 
  return c.json({ id: 1, ...data }, 201)
})
 
export default app

zValidator('json', schema) 在内部调用 c.req.json() 解析请求体,再用 schema 做校验。如果校验失败,中间件直接返回 400,处理函数不会被调用。

Content-Type 必须正确

有一个容易忽略的前提:客户端发送请求时必须带上 Content-Type: application/json

# ✅ 正确
curl -X POST http://localhost:8787/users \
  -H 'Content-Type: application/json' \
  -d '{"name": "Alice", "email": "[email protected]"}'
 
# ❌ 缺少 Content-Type,c.req.json() 可能解析失败
curl -X POST http://localhost:8787/users \
  -d '{"name": "Alice", "email": "[email protected]"}'

有些 HTTP 客户端(比如 axios)会自动设置 Content-Type,但用 fetchcurl 时需要手动指定。如果客户端没带这个头,Hono 的 c.req.json() 在部分运行时环境下会抛异常或返回空值,zValidator 会把它当作校验失败处理。

如果你遇到过「明明传了 JSON,服务端却说校验不通过」的情况,先检查 Content-Type。

2. 嵌套对象校验

实际业务里的请求体很少是扁平结构。对应的 schema 需要把嵌套结构逐层定义出来,把子 schema 拆成独立变量:

// src/schemas/order.ts
import { z } from 'zod'
 
const addressSchema = z.object({
  city: z.string().min(1),
  street: z.string().min(1),
})
 
const orderItemSchema = z.object({
  productId: z.string().min(1),
  quantity: z.number().int().positive(),
  address: addressSchema,
})
 
const createOrderSchema = z.object({
  userId: z.string().min(1),
  items: z.array(orderItemSchema).min(1),
  payment: z.object({
    method: z.enum(['alipay', 'wechat', 'card']),
    amount: z.number().positive(),
  }),
})

拆成独立变量有两个好处:单个 schema 保持简短容易读;子 schema 可以在其他地方复用——addressSchema 同样可以用于用户地址管理接口。

校验通过后,c.req.valid('json') 的类型也是嵌套推导的:data.items[0].address.city 的类型是 stringdata.payment.method 的类型是 'alipay' | 'wechat' | 'card'

3. 数组校验

数组校验用 z.array() 包裹元素 schema。上一节的 items: z.array(orderItemSchema).min(1) 就是一个例子。

数组校验常见的约束:

// src/schemas/common.ts
 
// 至少 1 个元素,最多 100 个
const tagsSchema = z.array(z.string().min(1)).min(1).max(100)
 
// 二维数组
const matrixSchema = z.array(z.array(z.number()))

如果请求体直接传了一个数组而不是包裹在对象里,schema 要直接写 z.array(...) 而不是 z.object({...})

// src/index.ts
// 请求体:["tag1", "tag2", "tag3"]
const batchTagsSchema = z.array(z.string().min(1)).min(1)
 
app.post('/tags/batch', zValidator('json', batchTagsSchema), (c) => {
  const tags = c.req.valid('json')
  return c.json({ count: tags.length })
})

这种直接以数组作为请求体的设计在批量操作接口里比较实用。

4. 可选字段与默认值

请求体里的字段不一定都是必填的。Zod 用 .optional() 标记可选字段,用 .default() 提供默认值:

// src/schemas/posts.ts
import { z } from 'zod'
 
const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  status: z.enum(['draft', 'published']).default('draft'),
  tags: z.array(z.string()).default([]),
  publishedAt: z.string().datetime().optional(),
})
 
app.post('/posts', zValidator('json', createPostSchema), (c) => {
  const data = c.req.valid('json')
  // data.status 的类型是 string(有 default,一定有值)
  // data.publishedAt 的类型是 string | undefined(optional,可能不存在)
 
  return c.json(data, 201)
})

.optional().default() 的区别:

  • .optional():字段可以不存在,类型是 T | undefined,你需要在业务代码里处理 undefined 的情况
  • .default(value):字段可以不存在,但 Zod 会自动填入默认值,类型是 T(没有 undefined)

在写 schema 时可以先想一下:这个字段如果客户端不传,业务代码能不能接受 undefined?如果不能,就用 .default() 给一个合理的初始值。这样处理函数里不需要做额外的空值判断。

5. Union 类型与 Discriminated Union

有些接口的请求体结构取决于某个字段的值。比如发送通知,短信通知和邮件通知的字段完全不同:

// src/schemas/notifications.ts
import { z } from 'zod'
 
const smsNotificationSchema = z.object({
  type: z.literal('sms'),
  phone: z.string().regex(/^1[3-9]\d{9}$/),
  message: z.string().max(70),
})
 
const emailNotificationSchema = z.object({
  type: z.literal('email'),
  to: z.string().email(),
  subject: z.string().min(1),
  body: z.string().min(1),
})
 
const createNotificationSchema = z.discriminatedUnion('type', [
  smsNotificationSchema,
  emailNotificationSchema,
])

z.discriminatedUnion() 接收一个判别字段名和一组 schema。校验时会先看 type 字段的值,再选择对应的 schema 做完整校验。

使用 z.discriminatedUnion() 有两个好处。第一,TypeScript 能做类型收窄——判断了 data.type === 'sms' 之后,data.phone 自动可用,data.to 会被标记为不存在。第二,错误信息更精准——如果 typesms 但传了 to 字段,Zod 会报告 smsNotificationSchema 校验失败,而不是把所有子 schema 的错误混在一起。

如果没有判别字段,也可以用 z.union(),但它的类型收窄能力弱一些,错误信息也不如 discriminated union 清晰。只有在确实没有稳定判别字段时才退回到 z.union()

6. 处理未知字段

客户端可能传入 schema 里没有定义的字段。Zod 对这种行为提供了三种策略:

// src/schemas/strategies.ts
import { z } from 'zod'
 
const baseSchema = z.object({
  name: z.string(),
})
 
// 1. 默认行为(z.object 的 strip):静默丢弃未知字段
const stripSchema = baseSchema // 等同于 baseSchema.strip()
stripSchema.parse({ name: 'Alice', extra: 'ignored' })
// 结果:{ name: 'Alice' }
 
// 2. strict:存在未知字段时校验失败
const strictSchema = baseSchema.strict()
strictSchema.parse({ name: 'Alice', extra: 'boom' })
// 抛出 ZodError
 
// 3. passthrough:保留未知字段
const passthroughSchema = baseSchema.passthrough()
passthroughSchema.parse({ name: 'Alice', extra: 'kept' })
// 结果:{ name: 'Alice', extra: 'kept' }

在 API 校验场景里,默认行为(丢弃未知字段)通常是最安全的选择。客户端传了多余的字段不会影响业务逻辑,也不会把意外数据带到数据库层。

但有些场景需要严格模式。比如创建用户的接口,如果客户端传了 isAdmin: true 这种不该由前端控制的字段,strict() 会让校验直接失败:

// src/routes/users.ts
const strictCreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
}).strict()
 
// POST /users
// Body: { "name": "Alice", "email": "[email protected]", "isAdmin": true }
// → 400 校验失败:Unrecognized key(s) in object: 'isAdmin'

passthrough() 在 API 校验里用得少,但在做数据转发或日志记录时可能有用——保留客户端传的原始数据,后续自己决定怎么处理。

7. 文件上传与 multipart 请求

zValidator('json', schema) 只处理 application/json 的请求体。如果客户端发送的是 multipart/form-data(通常用于文件上传),需要换一种方式。

Hono 提供了 c.req.parseBody() 来解析 multipart 请求体:

// src/index.ts
import { Hono } from 'hono'
import { z } from 'zod'
 
const app = new Hono()
 
const uploadSchema = z.object({
  title: z.string().min(1),
  description: z.string().optional(),
  file: z.instanceof(File),
})
 
app.post('/upload', async (c) => {
  const body = await c.req.parseBody()
  // body 的类型:Record<string, string | File>
 
  const result = uploadSchema.safeParse(body)
  if (!result.success) {
    return c.json({ error: result.error.flatten().fieldErrors }, 400)
  }
 
  const data = result.data
  // data.file 是 File 对象,可以读取 data.file.name、data.file.size 等
 
  return c.json({
    filename: data.file.name,
    size: data.file.size,
  })
})

注意这里没有用 zValidator,而是手动调用 c.req.parseBody() 再用 safeParse 校验。原因是 @hono/zod-validatorzValidator('json', ...) 内部调用的是 c.req.json(),不能处理 multipart 格式。

如果上传多个同名文件,parseBody() 默认只返回最后一个。需要传 &#123; all: true &#125; 选项,此时同名字段的值会变成数组。

8. 请求体大小限制

如果客户端发送了一个很大的 JSON 请求体,服务端在 c.req.json() 阶段会把整个 body 读入内存再做解析。一个几百 MB 的 JSON 可以直接把服务打挂。

Hono 本身没有在 JSON 解析层做大小限制,但可以在中间件层做拦截。一种做法是检查 Content-Length 头:

// src/middleware/body-size-limit.ts
import { createMiddleware } from 'hono/factory'
 
const MAX_BODY_SIZE = 1024 * 1024 // 1MB
 
export const bodySizeLimit = createMiddleware(async (c, next) => {
  const contentLength = c.req.header('content-length')
  if (contentLength && Number(contentLength) > MAX_BODY_SIZE) {
    return c.json({ error: 'Request body too large' }, 413)
  }
  await next()
})

这个方案只检查了声明的长度,实际 body 可能和 Content-Length 不一致。更严格的做法是手动读取流并计数——用 c.req.raw.body.getReader() 逐块读取,超过阈值就中断连接。这种方式不会把整个 body 一次性读入内存,适合对普通 JSON 接口做统一的大小限制。

9. 校验阶段做数据转换

Zod 的 .transform() 可以在校验的同时完成数据转换。转换后的值会替代原始值,TypeScript 类型也会更新。

// src/schemas/users.ts
import { z } from 'zod'
 
const createUserSchema = z.object({
  // 字符串转数字
  age: z.string().transform(Number),
 
  // 字符串转 Date
  birthday: z.string().transform((str) => new Date(str)),
 
  // 邮箱转小写
  email: z.string().email().transform((str) => str.toLowerCase()),
 
  // 数组字符串转数组
  tags: z.string().transform((str) =>
    str.split(',').map((s) => s.trim()).filter(Boolean)
  ),
})
 
app.post('/users', zValidator('json', createUserSchema), (c) => {
  const data = c.req.valid('json')
  // data.age 的类型是 number(不是 string)
  // data.birthday 的类型是 Date
  // data.email 的类型是 string(已转小写)
  // data.tags 的类型是 string[]
 
  return c.json(data)
})

transformdefault 可以组合使用。比如客户端可能传字符串也可能传数字:z.union([z.number(), z.string()]).transform(Number) 可以兼容两种输入。

需要注意,transform 里如果抛出异常,Zod 不会自动捕获。比如 new Date('invalid') 不会抛异常但产出 Invalid Date。如果你需要在转换前做格式校验,先 refinetransform

// src/schemas/dates.ts
const dateSchema = z.string().refine(
  (str) => !Number.isNaN(new Date(str).getTime()),
  { message: 'Invalid date string' }
).transform((str) => new Date(str))

先校验格式合法性,再做转换,不会出现 Invalid Date 泄漏到业务代码的情况。

10. 常见陷阱

空请求体

客户端发了 POST 请求但 body 为空。c.req.json() 会抛异常,zValidator 捕获后返回 400,错误信息通常不太友好(比如 Unexpected end of JSON input)。可以在 zValidator 之前加一个中间件检查 Content-Length,在 body 为空时提前返回更友好的错误提示。

畸形 JSON

客户端传了 Content-Type: application/json 但 body 不是合法 JSON(比如少了引号、多了逗号)。和空请求体类似,JSON.parse 会抛异常,zValidator 返回 400。默认错误信息对客户端调试帮助不大。可以用 zValidator 的第三个参数(自定义错误 hook)统一返回结构化的错误响应:

// src/middleware/validate.ts
import { zValidator as zv } from '@hono/zod-validator'
import type { ZodSchema } from 'zod'
 
export function validateJson<T extends ZodSchema>(schema: T) {
  return zv('json', schema, (result, c) => {
    if (!result.success) {
      return c.json({
        code: 'VALIDATION_ERROR',
        errors: result.error.flatten().fieldErrors,
      }, 400)
    }
  })
}

错误的 Content-Type

客户端用 fetch 发送 JSON 时忘了设置 Content-Type,浏览器默认会用 text/plain。Hono 的 c.req.json() 在某些运行时下对 text/plain 的请求体也能正常解析(因为底层只是调 JSON.parse),但在其他运行时下可能不会。这种行为在不同部署环境之间不一致,不要依赖它。始终确保客户端发送正确的 Content-Type: application/json

延伸阅读

总结

Body 校验的核心链路可以归纳为三步:

  1. 解析:根据 Content-Type 选择解析方式——application/jsonc.req.json()multipart/form-datac.req.parseBody()
  2. 校验zValidator('json', schema) 处理 JSON 请求体;multipart 需要手动 safeParse
  3. 转换z.transform() 在校验阶段完成类型转换,处理函数拿到的是可以直接使用的数据

几个关键决策点:

  • 嵌套对象和数组拆成子 schema,保持可读性和复用性
  • 可选字段优先用 .default() 而不是 .optional(),减少下游空值判断
  • 需要类型收窄时用 z.discriminatedUnion(),不要退回到手动 if 判断
  • 大请求体在中间件层做大小限制,不要等到解析完再判断
  • 文件上传不走 zValidator('json', ...),改用 c.req.parseBody() + safeParse

下一篇讲 Query 校验——URL 查询参数的类型转换、数组参数和嵌套对象的校验方式,以及它们与 Body 校验在设计上的区别。