Hono + Zod 验证 + RESTful API 笔记
在 Hono 项目里加入 Zod 输入验证、错误处理、RESTful 路由设计。把"能跑的 demo"升级成"有验证、有类型、有错误处理"的生产级骨架。
目录
一、为什么需要输入验证
1. 没有验证的代码长这样
app.post('/chats', async (c) => {
const body = await c.req.json()
// body 是 any 类型,完全没有类型保证
// 用户传啥就接受啥
return c.json({ id: 1, ...body })
})
问题:
- 用户传
{title: ''}也接受 — 空标题 - 用户传
{title: 123}也接受 — 类型不对 - 用户传 10000 字标题也接受 — 数据库炸
- 业务代码里要写一堆
if检查 — 重复且易遗漏
2. 加上 Zod 验证
const CreateChatSchema = z.object({
title: z.string().min(1).max(200),
})
app.post('/chats', zValidator('json', CreateChatSchema), (c) => {
const body = c.req.valid('json') // 类型自动是 { title: string }
// 不合法的请求已经被中间件拦截,业务代码不用关心
})
好处:
- 类型自动推导
- 验证失败自动返回 400
- 业务代码只处理"合法数据"
- Schema 即文档
二、Zod 基础
1. 安装
pnpm add zod @hono/zod-validator
zod— 验证库本体@hono/zod-validator— Hono 官方的 Zod 中间件
2. 定义 Schema
import { z } from 'zod'
// 对象 Schema
const UserSchema = z.object({
name: z.string().min(1).max(50),
age: z.number().int().positive(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
bio: z.string().optional(), // 可选
isActive: z.boolean().default(true), // 有默认值
})
// 自动推导出 TS 类型
type User = z.infer<typeof UserSchema>
// = {
// name: string
// age: number
// email: string
// role: 'admin' | 'user' | 'guest'
// bio?: string
// isActive: boolean
// }
3. 常用类型与约束
| Schema | 用途 |
|---|---|
z.string() | 字符串 |
z.number() | 数字 |
z.boolean() | 布尔 |
z.date() | 日期 |
z.array(z.string()) | 字符串数组 |
z.object({...}) | 对象 |
z.enum(['a', 'b']) | 枚举 |
z.union([z.string(), z.number()]) | 联合类型 |
z.literal('exact-value') | 字面量 |
z.null() / z.undefined() | null / undefined |
4. 字符串常用约束
z.string()
.min(1, '不能为空') // 最短
.max(100, '太长') // 最长
.email('邮箱格式错') // 邮箱
.url('URL 格式错') // URL
.uuid('UUID 格式错') // UUID
.regex(/^[a-z]+$/, '只能小写') // 正则
.startsWith('https://') // 前缀
.endsWith('.com') // 后缀
.trim() // 自动去空格
.toLowerCase() // 自动转小写
5. 数字常用约束
z.number()
.int() // 整数
.positive() // 正数(> 0)
.nonnegative() // 非负(>= 0)
.min(1).max(100) // 范围
.multipleOf(5) // 5 的倍数
.finite() // 有限数(排除 Infinity)
6. 修饰符
z.string().optional() // 可选(可不传)
z.string().nullable() // 可为 null
z.string().nullish() // 可为 null 或 undefined
z.string().default('hello') // 默认值
7. 自定义错误消息
z.string({
required_error: '标题是必填项',
invalid_type_error: '标题必须是字符串',
}).min(1, '标题不能为空').max(200, '标题不能超过 200 字')
三、Hono + Zod 集成
1. zValidator 中间件
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const CreateChatSchema = z.object({
title: z.string().min(1).max(200),
systemPrompt: z.string().optional(),
})
app.post(
'/chats',
zValidator('json', CreateChatSchema), // ← 验证中间件
(c) => {
const body = c.req.valid('json') // ← 已验证的数据,类型自动推导
return c.json({ data: body })
}
)
2. 不同位置的验证
| 验证位置 | 用途 |
|---|---|
zValidator('json', schema) | 验证 JSON body |
zValidator('query', schema) | 验证 query 参数 |
zValidator('param', schema) | 验证 URL 参数 |
zValidator('header', schema) | 验证 header |
zValidator('form', schema) | 验证 form-data |
3. 完整示例
const QuerySchema = z.object({
page: z.string().regex(/^\d+$/).transform(Number).default('1'),
pageSize: z.string().regex(/^\d+$/).transform(Number).default('20'),
})
const ParamSchema = z.object({
id: z.string().uuid(),
})
app.get(
'/users/:id',
zValidator('param', ParamSchema),
zValidator('query', QuerySchema),
(c) => {
const { id } = c.req.valid('param') // string (UUID)
const { page, pageSize } = c.req.valid('query') // number, number
return c.json({ id, page, pageSize })
}
)
4. 验证失败的默认行为
zValidator 默认返回 400,响应体格式:
{
"success": false,
"error": {
"issues": [
{
"code": "too_small",
"minimum": 1,
"type": "string",
"inclusive": true,
"message": "不能为空",
"path": ["title"]
}
]
}
}
5. 自定义错误响应
app.post(
'/chats',
zValidator('json', CreateChatSchema, (result, c) => {
if (!result.success) {
return c.json({
error: 'Validation failed',
details: result.error.issues,
}, 400)
}
}),
(c) => { /* ... */ }
)
四、完整 CRUD 接口
1. 标准 RESTful 设计
GET /chats # 列表
POST /chats # 创建
GET /chats/:id # 详情
PATCH /chats/:id # 更新
DELETE /chats/:id # 删除
2. 完整代码模板
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
// Schema 定义
const CreateChatSchema = z.object({
title: z.string().min(1, '标题不能为空').max(200),
systemPrompt: z.string().optional(),
})
const UpdateChatSchema = z.object({
title: z.string().min(1).max(200).optional(),
systemPrompt: z.string().optional(),
})
const IdParamSchema = z.object({
id: z.string().uuid(),
})
// 临时数据存储(实际项目用数据库)
type Chat = z.infer<typeof CreateChatSchema> & {
id: string
createdAt: string
}
const chats: Chat[] = []
function generateId() {
return crypto.randomUUID()
}
// 列表
app.get('/chats', (c) => {
return c.json({
data: chats,
total: chats.length,
})
})
// 创建
app.post(
'/chats',
zValidator('json', CreateChatSchema),
(c) => {
const body = c.req.valid('json')
const newChat: Chat = {
id: generateId(),
...body,
createdAt: new Date().toISOString(),
}
chats.push(newChat)
return c.json({ data: newChat }, 201)
}
)
// 详情
app.get(
'/chats/:id',
zValidator('param', IdParamSchema),
(c) => {
const { id } = c.req.valid('param')
const chat = chats.find((c) => c.id === id)
if (!chat) {
return c.json({ error: 'Chat not found' }, 404)
}
return c.json({ data: chat })
}
)
// 更新
app.patch(
'/chats/:id',
zValidator('param', IdParamSchema),
zValidator('json', UpdateChatSchema),
(c) => {
const { id } = c.req.valid('param')
const body = c.req.valid('json')
const chat = chats.find((c) => c.id === id)
if (!chat) {
return c.json({ error: 'Chat not found' }, 404)
}
Object.assign(chat, body)
return c.json({ data: chat })
}
)
// 删除
app.delete(
'/chats/:id',
zValidator('param', IdParamSchema),
(c) => {
const { id } = c.req.valid('param')
const index = chats.findIndex((c) => c.id === id)
if (index === -1) {
return c.json({ error: 'Chat not found' }, 404)
}
chats.splice(index, 1)
return c.json(null, 204)
}
)
3. PUT vs PATCH 的区别
| 方法 | 含义 | 适用场景 |
|---|---|---|
PUT | 全量替换 | 整个对象都传 |
PATCH | 部分更新 | 只传变化的字段 |
实践:用 PATCH 更灵活,大部分场景都用 PATCH。
五、错误处理
1. Hono 的全局错误处理
app.onError((err, c) => {
console.error('[Error]', err)
return c.json(
{
error: err.message || 'Internal Server Error',
},
500
)
})
捕获所有未处理的异常,返回统一格式。
2. 404 处理
app.notFound((c) => {
return c.json(
{ error: 'Not Found', path: c.req.path },
404
)
})
3. 自定义错误类
// errors.ts
export class HTTPError extends Error {
constructor(public status: number, message: string) {
super(message)
}
}
export class NotFoundError extends HTTPError {
constructor(resource: string) {
super(404, `${resource} not found`)
}
}
export class ValidationError extends HTTPError {
constructor(message: string) {
super(400, message)
}
}
// 业务代码
import { NotFoundError } from './errors'
app.get('/chats/:id', (c) => {
const chat = chats.find(/* ... */)
if (!chat) throw new NotFoundError('Chat')
return c.json({ data: chat })
})
// 全局错误处理
app.onError((err, c) => {
if (err instanceof HTTPError) {
return c.json({ error: err.message }, err.status)
}
console.error(err)
return c.json({ error: 'Internal Server Error' }, 500)
})
好处:业务代码只 throw,不用每个地方写 if-return。
六、类型安全的全链路
1. Schema → 类型推导链
// 1. 定义 Schema
const CreateChatSchema = z.object({
title: z.string().min(1),
systemPrompt: z.string().optional(),
})
// 2. 推导出 TS 类型(自动)
type CreateChatInput = z.infer<typeof CreateChatSchema>
// 3. 在 handler 里直接用
app.post('/chats', zValidator('json', CreateChatSchema), (c) => {
const body = c.req.valid('json')
// body 类型已经是 CreateChatInput
// 改 schema → body 类型自动跟着变
})
2. 这套架构的优势
Schema 是唯一真相来源(Single Source of Truth)
↓
推导出 TS 类型(编译期检查)
↓
zValidator 验证(运行时检查)
↓
业务代码安全使用
改 Schema 一处,类型/验证/文档全部跟着变。
七、Zod 进阶技巧
1. transform 转换数据
const QuerySchema = z.object({
// query 是字符串,但我们要数字
page: z.string()
.regex(/^\d+$/)
.transform(Number)
.default('1'),
})
2. refine 自定义验证
const PasswordSchema = z.string()
.min(8)
.refine(
(val) => /[A-Z]/.test(val) && /[0-9]/.test(val),
'密码必须包含大写字母和数字'
)
3. pick / omit 复用 Schema
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
password: z.string(),
createdAt: z.date(),
})
// 创建用户:不需要 id 和 createdAt
const CreateUserSchema = UserSchema.omit({
id: true,
createdAt: true,
})
// 公开返回:不要 password
const PublicUserSchema = UserSchema.omit({
password: true,
})
// 只更新 name 和 email
const UpdateUserSchema = UserSchema.pick({
name: true,
email: true,
}).partial()
4. partial 全部字段变可选
const UpdateSchema = CreateSchema.partial()
// 等同于把每个字段都加 .optional()
5. merge 合并 Schema
const BaseSchema = z.object({ id: z.string() })
const ExtendedSchema = BaseSchema.merge(
z.object({ name: z.string() })
)
// 结果:{ id: string, name: string }
6. discriminatedUnion 区分联合
const EventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('keypress'), key: z.string() }),
])
// TS 自动推导出类型守卫
const event = EventSchema.parse(data)
if (event.type === 'click') {
event.x // ✅ TS 知道这里有 x
}
7. Schema 转 JSON Schema(给 LLM 用)
import { zodToJsonSchema } from 'zod-to-json-schema'
const schema = z.object({
query: z.string(),
limit: z.number().optional(),
})
const jsonSchema = zodToJsonSchema(schema)
// 可以直接传给 Claude / OpenAI 的 tool_use
八、关键细节与坑
1. c.req.json() vs c.req.valid('json')
// ❌ 不推荐:类型是 any
const body = await c.req.json()
// ✅ 推荐:类型自动推导,已经验证过
const body = c.req.valid('json')
只有用了 zValidator 中间件,才能用 c.req.valid('json') 。
2. Zod 验证返回的不是同一个对象
const schema = z.object({
name: z.string().trim(), // 自动去空格
})
const input = { name: ' hello ' }
const result = schema.parse(input)
// result.name === 'hello'(已 trim)
// input.name === ' hello '(原对象未变)
Zod 会返回新对象,不修改原对象。
3. optional() vs nullable() vs nullish()
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
JS 习惯用 undefined,数据库习惯用 null,Zod 都支持。
4. 验证失败时,所有错误都返回
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(18),
})
// 输入:{ name: '', email: 'invalid', age: 10 }
// 默认会返回所有 3 个错误,不是只报第一个
前端可以一次性显示所有错误。
5. crypto.randomUUID() 是内置的
const id = crypto.randomUUID()
// e.g. 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
Node 19+ 全局可用,不需要装 uuid 包。
6. 路由顺序很重要
// ❌ 错误顺序:'/chats/new' 会被 ':id' 路由拦截
app.get('/chats/:id', ...)
app.get('/chats/new', ...)
// ✅ 正确顺序:具体路径在前
app.get('/chats/new', ...)
app.get('/chats/:id', ...)
7. 中间件顺序
app.post(
'/users',
authMiddleware, // 1. 先鉴权
zValidator('json', CreateUserSchema), // 2. 再验证
rateLimitMiddleware, // 3. 限流
(c) => { /* handler */ } // 4. 业务
)
按声明顺序执行。
九、参考资源
- Zod 官方文档:zod.dev
- Hono Zod Validator:hono.dev/middleware/…
- Conventional Commits:www.conventionalcommits.org/
下一篇笔记会涵盖:PostgreSQL + Drizzle ORM 数据持久化、Migration 工作流、查询语法。