AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-04-18

1. 把前面 11 篇放进一个真实 API 里

前面 11 篇学的一切——基础类型、组合结构、refine、transform、input/output、派生 schema——都不是为了让你会写 schema,而是为了让你会设计一条从请求到响应都类型安全的接口链路

这一篇我们正式把 Zod 放到 Hono 里跑起来。Hono 章节已经讲过 zValidator 的基础用法,这里不重复那些,我们聚焦真实项目里更完整的实战模式

  • 请求校验:body / query / params / headers 四个位置全覆盖
  • 响应校验:为什么「防御自己的后端」一点都不多余
  • 统一的错误格式:把 ZodError 翻译成前端能直接用的结构
  • 共享 schema:前后端共用一份真相源
  • 端到端类型推导:让前端调用接口像调用本地函数一样

安装(如果上一章没装过):

index.bash
1
yarn add hono zod @hono/zod-validator

2. 请求校验:四个位置都不能漏

HTTP 请求里会出现用户数据的位置有四个:body / query / params / headers。真实接口里经常不止校验 body

@hono/zod-validator 通过 zValidator('<target>', schema) 分别处理每一个位置:

api/post.ts
01
import { Hono } from 'hono'
02
import { zValidator } from '@hono/zod-validator'
03
import { z } from 'zod'
04
05
const app = new Hono()
06
07
// 创建帖子
08
app.post(
09
'/posts/:userId',
10
// path params
11
zValidator('param', z.object({
12
userId: z.string().uuid(),
13
})),
14
// query string
15
zValidator('query', z.object({
16
draft: z.coerce.boolean().default(false),
17
})),
18
// headers
19
zValidator('header', z.object({
20
authorization: z.string().startsWith('Bearer '),
21
})),
22
// body
23
zValidator('json', z.object({
24
title: z.string().min(1).max(100),
25
content: z.string().min(1),
26
})),
27
async (c) => {
28
const { userId } = c.req.valid('param')
29
const { draft } = c.req.valid('query')
30
const body = c.req.valid('json')
31
// 到这一行,四个来源的数据都已经**类型安全且合法**
32
return c.json({ ok: true, userId, draft, title: body.title })
33
}
34
)

2.1 注意 query 的类型

query 从 URL 里来,所有值本质都是字符串。所以对非字符串字段一定要用 z.coerce.*z.string().transform(...),不能用 z.number()

index.ts
1
// ❌ 前端传 ?page=2 会校验失败
2
zValidator('query', z.object({ page: z.number() }))
3
4
// ✅ 正确写法
5
zValidator('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-urlencodedmultipart/form-data,把 target 换成 form

index.ts
1
zValidator('form', z.object({
2
name: z.string(),
3
file: z.instanceof(File),
4
}))

3. 响应校验:「防御自己的后端」

很多人只在入口校验请求,出口直接 return c.json(data) 就完事。这是一个被严重低估的坏习惯。

响应校验能在开发期抓住三类问题:

  1. 数据库字段改了,你忘了改响应结构(DTO 和 Entity 不一致)
  2. 业务层返回了敏感字段(密码、内部 token)
  3. LLM / 外部 API 返回了和你声明结构不一致的数据

写法很简单——用第 11 篇讲的 PublicUserSchema 派生 schema 做一次 parse

api/user.ts
01
import { UserSchema, PublicUserSchema } from '@shared/schemas/user'
02
03
app.get('/users/:id', async (c) => {
04
const user = await db.user.findUnique({ where: { id: c.req.param('id') } })
05
06
// ✅ 出口校验:只返回白名单字段,顺便挡住未来的「敏感字段泄露」
07
const safe = PublicUserSchema.parse(user)
08
09
return c.json(safe)
10
})

这一步免费给你三层保障:

  • 不会漏字段:如果 user 缺字段,parse 直接抛错
  • 不会多字段:.pick() 白名单只保留指定字段,password 永远不会被带出去
  • 类型收窄:TypeScript 确定你返回的是 PublicUser,下游调用方得到精确类型

一句话:请求校验防用户乱传,响应校验防自己乱写。

4. 统一 ZodError 的错误格式

默认情况下 zValidator 在校验失败时会返回 400,但错误结构不一定符合你前端想要的格式。真实项目里通常需要自己定义一个统一的 API 错误结构

index.json
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 的第三参数是一个回调,能拦截校验结果自定义响应:

lib/validator.ts
01
import { zValidator as zv } from '@hono/zod-validator'
02
import type { ZodSchema } from 'zod'
03
import type { ValidationTargets } from 'hono'
04
05
// 封装:所有接口用同一个错误格式
06
export const validate = <T extends ZodSchema>(
07
target: keyof ValidationTargets,
08
schema: T,
09
) => zv(target, schema, (result, c) => {
10
if (!result.success) {
11
return c.json({
12
ok: false,
13
code: 'VALIDATION_ERROR',
14
errors: result.error.issues.map(issue => ({
15
field: issue.path.join('.'),
16
message: issue.message,
17
})),
18
}, 400)
19
}
20
})

然后项目里所有路由都用这个封装:

index.ts
1
import { validate } from './lib/validator'
2
3
app.post('/register',
4
validate('json', RegisterBodySchema),
5
async (c) => {
6
const 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

index.ts
01
import { ZodError } from 'zod'
02
03
app.onError((err, c) => {
04
if (err instanceof ZodError) {
05
return c.json({
06
ok: false,
07
code: 'VALIDATION_ERROR',
08
errors: err.issues.map(i => ({
09
field: i.path.join('.'),
10
message: i.message,
11
})),
12
}, 400)
13
}
14
return c.json({ ok: false, code: 'INTERNAL', message: err.message }, 500)
15
})

这两层错误处理一起用,你整个后端只需要一套错误格式,前端也永远知道该期待什么。

5. 共享 schema:前后端一份真相源

真实项目里,schema 不应该只存在于后端。在 monorepo 里我们把它放到一个共享包:

text
1
packages/
2
├── shared/
3
│ └── src/schemas/ ← 所有 schema 都在这
4
│ ├── user.ts
5
│ ├── chat.ts
6
│ └── index.ts
7
├── server/ ← Hono 后端,导入 shared
8
└── web/ ← 前端,也导入 shared

然后:

packages/shared/src/schemas/user.ts
1
import { z } from 'zod'
2
3
export const RegisterBodySchema = z.object({
4
name: z.string().min(1).max(50),
5
email: z.string().email(),
6
password: z.string().min(8),
7
})
8
export type RegisterBody = z.infer<typeof RegisterBodySchema>
9
export type RegisterBodyInput = z.input<typeof RegisterBodySchema>

后端用它做服务端校验:

packages/server/routes/auth.ts
1
import { RegisterBodySchema } from '@shared/schemas/user'
2
import { validate } from '../lib/validator'
3
4
app.post('/register', validate('json', RegisterBodySchema), async (c) => {
5
const body = c.req.valid('json')
6
// ...
7
})

前端用它做表单校验:

packages/web/register.tsx
01
import { useForm } from 'react-hook-form'
02
import { zodResolver } from '@hookform/resolvers/zod'
03
import { RegisterBodySchema, type RegisterBodyInput } from '@shared/schemas/user'
04
05
export function RegisterForm() {
06
const form = useForm<RegisterBodyInput>({
07
resolver: zodResolver(RegisterBodySchema),
08
})
09
// 前后端校验规则完全一致,任意一端改 schema,另一端立刻报类型错
10
}

这是第 2 篇讲的 SSOT 原则在项目里最完整的落地形态。以后你改一个字段的校验规则,前端和后端会同时被迫跟上,不会再出现「前端放行,后端拒绝」的经典 bug。

6. RPC 式客户端:让调接口像调函数

Hono 有一个让人上瘾的能力叫 RPC client:它能把你的路由定义直接推导成客户端的类型。配合 Zod,端到端类型推导一步到位。

server/app.ts
01
import { Hono } from 'hono'
02
import { validate } from './lib/validator'
03
import { RegisterBodySchema, PublicUserSchema } from '@shared/schemas/user'
04
05
const app = new Hono()
06
.post('/register',
07
validate('json', RegisterBodySchema),
08
async (c) => {
09
const body = c.req.valid('json')
10
const user = await createUser(body)
11
return c.json(PublicUserSchema.parse(user))
12
}
13
)
14
15
// 把整个 app 的类型导出
16
export type AppType = typeof app
17
export default app

前端:

web/api.ts
01
import { hc } from 'hono/client'
02
import type { AppType } from '@shared/server-types'
03
04
export const api = hc<AppType>('http://localhost:8787')
05
06
// 调用
07
const res = await api.register.$post({
08
json: {
09
name: 'Alice',
10
email: 'alice@example.com',
11
password: 'password123',
12
},
13
})
14
15
if (res.ok) {
16
const user = await res.json()
17
// user 的类型是 PublicUser(由 PublicUserSchema.parse 推导而来)
18
}

关键的地方:

  1. 前端 api.register.$post入参类型RegisterBodySchema 决定(z.input)
  2. 前端 await res.json()返回类型PublicUserSchema 决定(z.output)
  3. 后端改任意一端的 schema,前端的类型提示立刻变化

一条链路三段:zod schema → hono 路由 → hono client → 前端代码,类型一以贯之。

这也是为什么专栏里一直强调「schema 要导出本体,不只是类型」——少了 schema 本体,Hono 的类型推导就断了。

7. 一个完整的 User CRUD 示例

把前面所有东西放进一个小而全的例子里:

packages/shared/src/schemas/user.ts
01
import { z } from 'zod'
02
03
export const UserSchema = z.object({
04
id: z.string().uuid(),
05
name: z.string().min(1).max(50),
06
email: z.string().email(),
07
password: z.string().min(8),
08
createdAt: z.date(),
09
})
10
export type User = z.infer<typeof UserSchema>
11
12
// 派生(第 11 篇的模式)
13
export const PublicUserSchema = UserSchema.pick({
14
id: true, name: true, email: true, createdAt: true,
15
})
16
export const CreateUserBodySchema = UserSchema.pick({
17
name: true, email: true, password: true,
18
})
19
export const UpdateUserBodySchema = UserSchema.pick({
20
name: true, email: true,
21
}).partial()
22
export const UserIdParamSchema = z.object({
23
id: z.string().uuid(),
24
})
25
export const ListUsersQuerySchema = z.object({
26
page: z.coerce.number().int().min(1).default(1),
27
pageSize: z.coerce.number().int().min(1).max(100).default(20),
28
})
packages/server/routes/users.ts
01
import { Hono } from 'hono'
02
import { validate } from '../lib/validator'
03
import {
04
PublicUserSchema,
05
CreateUserBodySchema,
06
UpdateUserBodySchema,
07
UserIdParamSchema,
08
ListUsersQuerySchema,
09
} from '@shared/schemas/user'
10
11
export const users = new Hono()
12
// 列表
13
.get('/',
14
validate('query', ListUsersQuerySchema),
15
async (c) => {
16
const { page, pageSize } = c.req.valid('query')
17
const list = await db.user.findMany({
18
skip: (page - 1) * pageSize,
19
take: pageSize,
20
})
21
return c.json(list.map(u => PublicUserSchema.parse(u)))
22
}
23
)
24
// 详情
25
.get('/:id',
26
validate('param', UserIdParamSchema),
27
async (c) => {
28
const user = await db.user.findUnique({ where: { id: c.req.valid('param').id } })
29
if (!user) return c.json({ ok: false, code: 'NOT_FOUND' }, 404)
30
return c.json(PublicUserSchema.parse(user))
31
}
32
)
33
// 创建
34
.post('/',
35
validate('json', CreateUserBodySchema),
36
async (c) => {
37
const created = await db.user.create({ data: c.req.valid('json') })
38
return c.json(PublicUserSchema.parse(created), 201)
39
}
40
)
41
// 更新
42
.patch('/:id',
43
validate('param', UserIdParamSchema),
44
validate('json', UpdateUserBodySchema),
45
async (c) => {
46
const updated = await db.user.update({
47
where: { id: c.req.valid('param').id },
48
data: c.req.valid('json'),
49
})
50
return c.json(PublicUserSchema.parse(updated))
51
}
52
)
53
// 删除
54
.delete('/:id',
55
validate('param', UserIdParamSchema),
56
async (c) => {
57
await db.user.delete({ where: { id: c.req.valid('param').id } })
58
return c.json({ ok: true })
59
}
60
)

整个 CRUD 里:

  • 每个接口的每个入口都被校验了(param / query / json)
  • 每个响应都经过 PublicUserSchema.parse,密码绝不会被带出
  • 所有 schema 都从 UserSchema 派生,不手写任何重复定义
  • 前端通过 Hono RPC 客户端拿到完整的入参出参类型,不需要额外写一行类型声明

这差不多是目前 TypeScript 生态里能做到的类型安全的天花板——而它的基础积木,就是你前面 11 篇学的东西。

8. 总结

这一篇把整个专栏推进到「真实可工作的 API 层」:

  1. 请求校验zValidator 覆盖 body / query / params / headers / form 五个位置
  2. 响应校验 — 用派生 schema 做出口 .parse,防字段漏、防敏感泄露
  3. 统一错误格式 — 封装 validate + 全局 onErrorZodError
  4. 共享 schema — monorepo 的 packages/shared/schemas,前后端同源
  5. Hono RPC 客户端 — 从 schema → 路由 → 客户端 → 调用处,类型一以贯之
  6. CRUD 模板 — 能直接搬到项目里用的完整例子

一张最值得收藏的原则表:

层级用 Zod 做什么
HTTP 入口校验 body/query/param/header
业务层不重复校验,相信入口已经校验
HTTP 出口用派生 schema 做 parse,防御敏感字段
前端 API 客户端从共享 schema 拿类型 + 响应再校验一次
前端表单zodResolver 用同一份 schema

一句话带走:

NOTE

把 schema 放到共享层——从那一刻起,你的前后端不再是两个项目,而是一条类型连通的流水线。

下一篇进入实战篇的第二弹:Zod + LLM:Structured Output 与 AI 响应校验。这是 AI 项目里最有价值、也最容易踩坑的一环——模型返回的 JSON 从来不像文档承诺的那么规整,Zod 是你唯一的护栏。