1. 为什么需要数据校验
用户提交的数据永远不可信。少一个字段、类型不对、格式错误,这些情况每天都在发生。
手动校验长这样:
01app.post('/users', async (c) => {02const body = await c.req.json()0304if (!body.name || typeof body.name !== 'string') {05return c.json({ error: 'name is required' }, 400)06}07if (!body.email || typeof body.email !== 'string') {08return c.json({ error: 'email is required' }, 400)09}10if (!body.email.includes('@')) {11return c.json({ error: 'invalid email' }, 400)12}1314// 终于可以写业务逻辑了...15})
问题很明显:又丑又容易漏,字段一多就失控。而且校验完之后,TypeScript 依然不知道 body 的类型是什么,你还得手动断言。
Hono + Zod 的方案:用 schema 声明数据结构,校验和类型推导一步到位。
什么是 schema
如果你第一次接触后端校验,可以先把 schema 理解成一份数据规则说明书。它不是实际的数据,而是用来描述「什么样的数据才算合法」
比如下面这段数据:
1{2"name": "Alice",3"email": "alice@example.com"4}
它对应的 schema 想表达的是:
name这个字段必须存在name必须是字符串email这个字段必须存在email必须是合法邮箱格式
也就是说,schema 回答的不是「数据是什么」,而是「数据应该长什么样」
在没有 schema 的时候,这些规则通常散落在很多 if 判断里;有了 schema,这些规则就被集中写到一个地方,代码会更清楚。
用 Zod 写出来就是这样:
1const createUserSchema = z.object({2name: z.string().min(1),3email: z.string().email(),4})
这段 schema 可以直接读成自然语言:
- 整体是一个对象
z.object({...}) name是字符串,而且不能为空email是字符串,而且必须符合邮箱格式
所以后面你看到 schema 这个词时,不要把它想得太玄。你就把它当成一份「输入数据的规则定义」就行
2. 安装依赖
1npm install zod @hono/zod-validator
- zod:TypeScript-first 的数据校验库,定义 schema 的同时自动生成类型
- @hono/zod-validator:Hono 官方的 Zod 集成,把 Zod schema 变成路由中间件
3. 基础用法
01import { Hono } from 'hono'02import { zValidator } from '@hono/zod-validator'03import { z } from 'zod'0405const app = new Hono()0607const createUserSchema = z.object({08name: z.string().min(1),09email: z.string().email(),10})1112app.post('/users', zValidator('json', createUserSchema), (c) => {13const data = c.req.valid('json')14// data 的类型自动推导为 { name: string; email: string }15return c.json({ id: 1, ...data }, 201)16})1718export default app
zValidator('json', schema) 做了两件事:
- 校验:请求体不符合 schema 时,中间件会直接拦截请求,默认返回一份 400 错误响应,请求根本不会进入你的处理函数
- 类型推导:
c.req.valid('json')的返回类型由 schema 自动推导,不需要手写 interface
校验不通过时,默认返回的错误格式:
01{02"success": false,03"error": {04"issues": [05{06"code": "too_small",07"minimum": 1,08"message": "String must contain at least 1 character(s)",09"path": ["name"]10}11]12}13}
4. 校验不同位置的数据
HTTP 请求的数据可能出现在不同位置,zValidator 的第一个参数指定校验哪里的数据:
校验请求体(JSON)
1const bodySchema = z.object({2title: z.string().min(1),3content: z.string(),4})56app.post('/posts', zValidator('json', bodySchema), (c) => {7const body = c.req.valid('json')8return c.json(body, 201)9})
这里有一个很容易踩的坑:如果你要校验 JSON,请求头里要正确带上 Content-Type: application/json。否则服务端可能根本不会按 JSON 的方式去解析请求体,最后你会觉得“明明传了数据,怎么校验不对”。
校验查询参数
01const querySchema = z.object({02page: z.coerce.number().int().positive().default(1),03limit: z.coerce.number().int().positive().default(10),04})0506app.get('/posts', zValidator('query', querySchema), (c) => {07const { page, limit } = c.req.valid('query')08// page 和 limit 已经是 number 类型09return c.json({ page, limit })10})
注意:查询参数从 URL 解析出来本来都是字符串,这里用 z.coerce.number() 的意思是“先接收字符串,再自动帮你转成 number”。对新手来说,这种写法通常比 z.string().transform(Number) 更直观。
校验路由参数
1const paramSchema = z.object({2id: z.string().regex(/^\d+$/),3})45app.get('/users/:id', zValidator('param', paramSchema), (c) => {6const { id } = c.req.valid('param')7return c.json({ id })8})
校验请求头
1const headerSchema = z.object({2'x-api-key': z.string().min(1),3})45app.get('/admin/stats', zValidator('header', headerSchema), (c) => {6const headers = c.req.valid('header')7return c.json({ key: headers['x-api-key'] })8})
5. 自定义错误响应
默认的错误格式可能不符合你的 API 规范。zValidator 的第三个参数是一个 hook 函数,让你自定义错误响应:
01app.post(02'/users',03zValidator('json', createUserSchema, (result, c) => {04if (!result.success) {05return c.json(06{07code: 'VALIDATION_ERROR',08errors: result.error.flatten().fieldErrors,09},1040011)12}13}),14(c) => {15const data = c.req.valid('json')16return c.json({ id: 1, ...data }, 201)17}18)
result.error.flatten() 会把 Zod 的错误信息整理成更易读的格式:
1{2"code": "VALIDATION_ERROR",3"errors": {4"name": ["String must contain at least 1 character(s)"],5"email": ["Invalid email"]6}7}
如果你的每个接口都需要统一的错误格式,可以封装一个工具函数:
这一段已经属于进阶写法了。如果你刚开始学,先会直接写 zValidator('json', schema) 就够了。下面这个封装的重点,不是让你背泛型,而是让你知道:同一种错误响应格式,是可以抽成工具复用的。
01import type { ValidationTargets } from 'hono'02import type { ZodSchema } from 'zod'0304function validate<T extends keyof ValidationTargets>(target: T, schema: ZodSchema) {05return zValidator(target, schema, (result, c) => {06if (!result.success) {07return c.json(08{09code: 'VALIDATION_ERROR',10errors: result.error.flatten().fieldErrors,11},1240013)14}15})16}1718// 使用19app.post('/users', validate('json', createUserSchema), (c) => {20const data = c.req.valid('json')21return c.json(data, 201)22})
6. 组合多个校验器
一个路由可以同时校验多个位置的数据,把多个 zValidator 当中间件依次挂上去就行:
01const paramSchema = z.object({02id: z.string().regex(/^\d+$/),03})0405const bodySchema = z.object({06name: z.string().min(1),07email: z.string().email(),08})0910const headerSchema = z.object({11'x-api-key': z.string().min(1),12})1314app.put(15'/users/:id',16zValidator('param', paramSchema),17zValidator('json', bodySchema),18zValidator('header', headerSchema),19(c) => {20const { id } = c.req.valid('param')21const body = c.req.valid('json')22// 三个位置的数据都校验通过,类型都自动推导23return c.json({ id, ...body })24}25)
校验顺序就是中间件的排列顺序。第一个校验不通过,后面的不会执行。
7. 类型推导的价值
传统做法是手写一个 interface,然后在运行时另外写一套校验逻辑。两边要保持同步,改了一边忘了另一边就是 bug。
Zod 的方案是 schema 即类型。如果你需要在其他地方使用校验后的类型,用 z.infer 提取:
01const createUserSchema = z.object({02name: z.string().min(1),03email: z.string().email(),04age: z.number().int().min(0).optional(),05})0607// 从 schema 推导出类型,不需要手写 interface08type CreateUserInput = z.infer<typeof createUserSchema>09// { name: string; email: string; age?: number | undefined }1011function saveUser(input: CreateUserInput) {12// ...13}1415app.post('/users', zValidator('json', createUserSchema), (c) => {16const data = c.req.valid('json')17saveUser(data) // 类型完全匹配,不需要任何转换18return c.json({ success: true }, 201)19})
一个 schema,同时解决校验和类型两个问题。改 schema 的时候 TypeScript 会自动提示所有受影响的地方。
8. 总结
zValidator 把数据校验从业务逻辑中剥离出来,变成声明式的中间件。schema 定义一次,校验和类型推导都有了。
核心用法就三个:
zValidator('json' | 'query' | 'param' | 'header', schema)— 校验指定位置的数据- 第三个参数 hook 函数 — 自定义错误响应
c.req.valid('json')— 拿到校验后的数据,类型自动推导
下一篇讲错误处理,看看怎么统一处理校验错误、业务错误和未知异常。