1. 把前面 11 篇放进一个真实 API 里
前面 11 篇学的一切——基础类型、组合结构、refine、transform、input/output、派生 schema——都不是为了让你会写 schema,而是为了让你会设计一条从请求到响应都类型安全的接口链路。
这一篇我们正式把 Zod 放到 Hono 里跑起来。Hono 章节已经讲过 zValidator 的基础用法,这里不重复那些,我们聚焦真实项目里更完整的实战模式:
- 请求校验:body / query / params / headers 四个位置全覆盖
- 响应校验:为什么「防御自己的后端」一点都不多余
- 统一的错误格式:把
ZodError翻译成前端能直接用的结构 - 共享 schema:前后端共用一份真相源
- 端到端类型推导:让前端调用接口像调用本地函数一样
安装(如果上一章没装过):
1yarn add hono zod @hono/zod-validator
2. 请求校验:四个位置都不能漏
HTTP 请求里会出现用户数据的位置有四个:body / query / params / headers。真实接口里经常不止校验 body。
@hono/zod-validator 通过 zValidator('<target>', schema) 分别处理每一个位置:
01import { Hono } from 'hono'02import { zValidator } from '@hono/zod-validator'03import { z } from 'zod'0405const app = new Hono()0607// 创建帖子08app.post(09'/posts/:userId',10// path params11zValidator('param', z.object({12userId: z.string().uuid(),13})),14// query string15zValidator('query', z.object({16draft: z.coerce.boolean().default(false),17})),18// headers19zValidator('header', z.object({20authorization: z.string().startsWith('Bearer '),21})),22// body23zValidator('json', z.object({24title: z.string().min(1).max(100),25content: z.string().min(1),26})),27async (c) => {28const { userId } = c.req.valid('param')29const { draft } = c.req.valid('query')30const body = c.req.valid('json')31// 到这一行,四个来源的数据都已经**类型安全且合法**32return c.json({ ok: true, userId, draft, title: body.title })33}34)
2.1 注意 query 的类型
query 从 URL 里来,所有值本质都是字符串。所以对非字符串字段一定要用 z.coerce.* 或 z.string().transform(...),不能用 z.number():
1// ❌ 前端传 ?page=2 会校验失败2zValidator('query', z.object({ page: z.number() }))34// ✅ 正确写法5zValidator('query', z.object({ page: z.coerce.number().int().min(1).default(1) }))
这是最常见的 Hono + Zod bug——所以第 4 篇专门留了 coerce 一节。
2.2 form 也有对应 target
如果前端发的是 application/x-www-form-urlencoded 或 multipart/form-data,把 target 换成 form:
1zValidator('form', z.object({2name: z.string(),3file: z.instanceof(File),4}))
3. 响应校验:「防御自己的后端」
很多人只在入口校验请求,出口直接 return c.json(data) 就完事。这是一个被严重低估的坏习惯。
响应校验能在开发期抓住三类问题:
- 数据库字段改了,你忘了改响应结构(DTO 和 Entity 不一致)
- 业务层返回了敏感字段(密码、内部 token)
- LLM / 外部 API 返回了和你声明结构不一致的数据
写法很简单——用第 11 篇讲的 PublicUserSchema 派生 schema 做一次 parse:
01import { UserSchema, PublicUserSchema } from '@shared/schemas/user'0203app.get('/users/:id', async (c) => {04const user = await db.user.findUnique({ where: { id: c.req.param('id') } })0506// ✅ 出口校验:只返回白名单字段,顺便挡住未来的「敏感字段泄露」07const safe = PublicUserSchema.parse(user)0809return c.json(safe)10})
这一步免费给你三层保障:
- 不会漏字段:如果
user缺字段,parse直接抛错 - 不会多字段:
.pick()白名单只保留指定字段,password永远不会被带出去 - 类型收窄:TypeScript 确定你返回的是
PublicUser,下游调用方得到精确类型
一句话:请求校验防用户乱传,响应校验防自己乱写。
4. 统一 ZodError 的错误格式
默认情况下 zValidator 在校验失败时会返回 400,但错误结构不一定符合你前端想要的格式。真实项目里通常需要自己定义一个统一的 API 错误结构:
1{2"ok": false,3"code": "VALIDATION_ERROR",4"errors": [5{ "field": "email", "message": "Invalid email" },6{ "field": "password", "message": "String must contain at least 8 character(s)" }7]8}
4.1 zValidator 的第三参数
zValidator 的第三参数是一个回调,能拦截校验结果自定义响应:
01import { zValidator as zv } from '@hono/zod-validator'02import type { ZodSchema } from 'zod'03import type { ValidationTargets } from 'hono'0405// 封装:所有接口用同一个错误格式06export const validate = <T extends ZodSchema>(07target: keyof ValidationTargets,08schema: T,09) => zv(target, schema, (result, c) => {10if (!result.success) {11return c.json({12ok: false,13code: 'VALIDATION_ERROR',14errors: result.error.issues.map(issue => ({15field: issue.path.join('.'),16message: issue.message,17})),18}, 400)19}20})
然后项目里所有路由都用这个封装:
1import { validate } from './lib/validator'23app.post('/register',4validate('json', RegisterBodySchema),5async (c) => {6const body = c.req.valid('json')7// ...8}9)
一旦统一格式,前端就可以写一个通用的错误处理工具,把 errors 数组直接塞进 react-hook-form 的 setError——UI 层几乎不用写逻辑。
4.2 全局错误兜底
除了 zValidator,业务代码里也可能直接抛 ZodError(比如你用 schema.parse(externalData) 校验 LLM 返回)。统一兜底用 Hono 的 app.onError:
01import { ZodError } from 'zod'0203app.onError((err, c) => {04if (err instanceof ZodError) {05return c.json({06ok: false,07code: 'VALIDATION_ERROR',08errors: err.issues.map(i => ({09field: i.path.join('.'),10message: i.message,11})),12}, 400)13}14return c.json({ ok: false, code: 'INTERNAL', message: err.message }, 500)15})
这两层错误处理一起用,你整个后端只需要一套错误格式,前端也永远知道该期待什么。
5. 共享 schema:前后端一份真相源
真实项目里,schema 不应该只存在于后端。在 monorepo 里我们把它放到一个共享包:
1packages/2├── shared/3│ └── src/schemas/ ← 所有 schema 都在这4│ ├── user.ts5│ ├── chat.ts6│ └── index.ts7├── server/ ← Hono 后端,导入 shared8└── web/ ← 前端,也导入 shared
然后:
1import { z } from 'zod'23export const RegisterBodySchema = z.object({4name: z.string().min(1).max(50),5email: z.string().email(),6password: z.string().min(8),7})8export type RegisterBody = z.infer<typeof RegisterBodySchema>9export type RegisterBodyInput = z.input<typeof RegisterBodySchema>
后端用它做服务端校验:
1import { RegisterBodySchema } from '@shared/schemas/user'2import { validate } from '../lib/validator'34app.post('/register', validate('json', RegisterBodySchema), async (c) => {5const body = c.req.valid('json')6// ...7})
前端用它做表单校验:
01import { useForm } from 'react-hook-form'02import { zodResolver } from '@hookform/resolvers/zod'03import { RegisterBodySchema, type RegisterBodyInput } from '@shared/schemas/user'0405export function RegisterForm() {06const form = useForm<RegisterBodyInput>({07resolver: zodResolver(RegisterBodySchema),08})09// 前后端校验规则完全一致,任意一端改 schema,另一端立刻报类型错10}
这是第 2 篇讲的 SSOT 原则在项目里最完整的落地形态。以后你改一个字段的校验规则,前端和后端会同时被迫跟上,不会再出现「前端放行,后端拒绝」的经典 bug。
6. RPC 式客户端:让调接口像调函数
Hono 有一个让人上瘾的能力叫 RPC client:它能把你的路由定义直接推导成客户端的类型。配合 Zod,端到端类型推导一步到位。
01import { Hono } from 'hono'02import { validate } from './lib/validator'03import { RegisterBodySchema, PublicUserSchema } from '@shared/schemas/user'0405const app = new Hono()06.post('/register',07validate('json', RegisterBodySchema),08async (c) => {09const body = c.req.valid('json')10const user = await createUser(body)11return c.json(PublicUserSchema.parse(user))12}13)1415// 把整个 app 的类型导出16export type AppType = typeof app17export default app
前端:
01import { hc } from 'hono/client'02import type { AppType } from '@shared/server-types'0304export const api = hc<AppType>('http://localhost:8787')0506// 调用07const res = await api.register.$post({08json: {09name: 'Alice',10email: 'alice@example.com',11password: 'password123',12},13})1415if (res.ok) {16const user = await res.json()17// user 的类型是 PublicUser(由 PublicUserSchema.parse 推导而来)18}
关键的地方:
- 前端
api.register.$post的入参类型由RegisterBodySchema决定(z.input) - 前端
await res.json()的返回类型由PublicUserSchema决定(z.output) - 后端改任意一端的 schema,前端的类型提示立刻变化
一条链路三段:zod schema → hono 路由 → hono client → 前端代码,类型一以贯之。
这也是为什么专栏里一直强调「schema 要导出本体,不只是类型」——少了 schema 本体,Hono 的类型推导就断了。
7. 一个完整的 User CRUD 示例
把前面所有东西放进一个小而全的例子里:
01import { z } from 'zod'0203export const UserSchema = z.object({04id: z.string().uuid(),05name: z.string().min(1).max(50),06email: z.string().email(),07password: z.string().min(8),08createdAt: z.date(),09})10export type User = z.infer<typeof UserSchema>1112// 派生(第 11 篇的模式)13export const PublicUserSchema = UserSchema.pick({14id: true, name: true, email: true, createdAt: true,15})16export const CreateUserBodySchema = UserSchema.pick({17name: true, email: true, password: true,18})19export const UpdateUserBodySchema = UserSchema.pick({20name: true, email: true,21}).partial()22export const UserIdParamSchema = z.object({23id: z.string().uuid(),24})25export const ListUsersQuerySchema = z.object({26page: z.coerce.number().int().min(1).default(1),27pageSize: z.coerce.number().int().min(1).max(100).default(20),28})
01import { Hono } from 'hono'02import { validate } from '../lib/validator'03import {04PublicUserSchema,05CreateUserBodySchema,06UpdateUserBodySchema,07UserIdParamSchema,08ListUsersQuerySchema,09} from '@shared/schemas/user'1011export const users = new Hono()12// 列表13.get('/',14validate('query', ListUsersQuerySchema),15async (c) => {16const { page, pageSize } = c.req.valid('query')17const list = await db.user.findMany({18skip: (page - 1) * pageSize,19take: pageSize,20})21return c.json(list.map(u => PublicUserSchema.parse(u)))22}23)24// 详情25.get('/:id',26validate('param', UserIdParamSchema),27async (c) => {28const user = await db.user.findUnique({ where: { id: c.req.valid('param').id } })29if (!user) return c.json({ ok: false, code: 'NOT_FOUND' }, 404)30return c.json(PublicUserSchema.parse(user))31}32)33// 创建34.post('/',35validate('json', CreateUserBodySchema),36async (c) => {37const created = await db.user.create({ data: c.req.valid('json') })38return c.json(PublicUserSchema.parse(created), 201)39}40)41// 更新42.patch('/:id',43validate('param', UserIdParamSchema),44validate('json', UpdateUserBodySchema),45async (c) => {46const updated = await db.user.update({47where: { id: c.req.valid('param').id },48data: c.req.valid('json'),49})50return c.json(PublicUserSchema.parse(updated))51}52)53// 删除54.delete('/:id',55validate('param', UserIdParamSchema),56async (c) => {57await db.user.delete({ where: { id: c.req.valid('param').id } })58return c.json({ ok: true })59}60)
整个 CRUD 里:
- 每个接口的每个入口都被校验了(param / query / json)
- 每个响应都经过
PublicUserSchema.parse,密码绝不会被带出 - 所有 schema 都从
UserSchema派生,不手写任何重复定义 - 前端通过 Hono RPC 客户端拿到完整的入参出参类型,不需要额外写一行类型声明
这差不多是目前 TypeScript 生态里能做到的类型安全的天花板——而它的基础积木,就是你前面 11 篇学的东西。
8. 总结
这一篇把整个专栏推进到「真实可工作的 API 层」:
- 请求校验 —
zValidator覆盖 body / query / params / headers / form 五个位置 - 响应校验 — 用派生 schema 做出口
.parse,防字段漏、防敏感泄露 - 统一错误格式 — 封装
validate+ 全局onError兼ZodError - 共享 schema — monorepo 的
packages/shared/schemas,前后端同源 - Hono RPC 客户端 — 从 schema → 路由 → 客户端 → 调用处,类型一以贯之
- CRUD 模板 — 能直接搬到项目里用的完整例子
一张最值得收藏的原则表:
| 层级 | 用 Zod 做什么 |
|---|---|
| HTTP 入口 | 校验 body/query/param/header |
| 业务层 | 不重复校验,相信入口已经校验 |
| HTTP 出口 | 用派生 schema 做 parse,防御敏感字段 |
| 前端 API 客户端 | 从共享 schema 拿类型 + 响应再校验一次 |
| 前端表单 | zodResolver 用同一份 schema |
一句话带走:
把 schema 放到共享层——从那一刻起,你的前后端不再是两个项目,而是一条类型连通的流水线。
下一篇进入实战篇的第二弹:Zod + LLM:Structured Output 与 AI 响应校验。这是 AI 项目里最有价值、也最容易踩坑的一环——模型返回的 JSON 从来不像文档承诺的那么规整,Zod 是你唯一的护栏。