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 appzValidator('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,但用 fetch 或 curl 时需要手动指定。如果客户端没带这个头,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 的类型是 string,data.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 会被标记为不存在。第二,错误信息更精准——如果 type 是 sms 但传了 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-validator 的 zValidator('json', ...) 内部调用的是 c.req.json(),不能处理 multipart 格式。
如果上传多个同名文件,parseBody() 默认只返回最后一个。需要传 { all: true } 选项,此时同名字段的值会变成数组。
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)
})transform 和 default 可以组合使用。比如客户端可能传字符串也可能传数字:z.union([z.number(), z.string()]).transform(Number) 可以兼容两种输入。
需要注意,transform 里如果抛出异常,Zod 不会自动捕获。比如 new Date('invalid') 不会抛异常但产出 Invalid Date。如果你需要在转换前做格式校验,先 refine 再 transform:
// 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。
延伸阅读
- 01-为什么需要请求验证 — zValidator 的基本用法和校验不同位置的数据
- 02-Zod Schema 基础 — Zod 的基础类型和 schema 组合方式
- 04.02-动态路由参数 — 路由参数校验和 Body 校验的组合使用
- Hono Request 文档 —
c.req.json()和c.req.parseBody()的完整说明 - Zod 官方文档 - transform — transform、refine、preprocess 的详细用法
总结
Body 校验的核心链路可以归纳为三步:
- 解析:根据 Content-Type 选择解析方式——
application/json走c.req.json(),multipart/form-data走c.req.parseBody() - 校验:
zValidator('json', schema)处理 JSON 请求体;multipart 需要手动safeParse - 转换:
z.transform()在校验阶段完成类型转换,处理函数拿到的是可以直接使用的数据
几个关键决策点:
- 嵌套对象和数组拆成子 schema,保持可读性和复用性
- 可选字段优先用
.default()而不是.optional(),减少下游空值判断 - 需要类型收窄时用
z.discriminatedUnion(),不要退回到手动 if 判断 - 大请求体在中间件层做大小限制,不要等到解析完再判断
- 文件上传不走
zValidator('json', ...),改用c.req.parseBody()+safeParse
下一篇讲 Query 校验——URL 查询参数的类型转换、数组参数和嵌套对象的校验方式,以及它们与 Body 校验在设计上的区别。