Hono + Zod 验证 + RESTful API 笔记

3 阅读4分钟

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. 业务
)

按声明顺序执行。


九、参考资源


下一篇笔记会涵盖:PostgreSQL + Drizzle ORM 数据持久化、Migration 工作流、查询语法。