Path Params校验

要点

  • Path params 从 URL 中提取出来时始终是字符串,无论它们在 URL 里看起来像数字、UUID 还是其他类型
  • zValidator('param', schema) 可以对路径参数做运行时校验,同时把类型信息传导到处理函数
  • 路由定义里的参数名(:id)必须和 schema 里的 key 完全一致,否则校验会静默落空
  • 路由级正则约束(:id{\d+})和 Zod schema 校验解决的是不同层面的问题,可以组合使用
  • 不是所有参数都需要在 schema 层做格式校验——简单的数字 ID 交给数据库返回 404 也是一种合理策略
  • 多个参数的路由可以作为一个整体来校验,也可以对每个参数独立约束

1. 参数始终是字符串

04-hono-routing/02 里已经详细讲过路由参数的提取机制。这篇把注意力放到校验上。先看一个最基本的事实:

// src/index.ts
// GET /users/42
app.get('/users/:id', (c) => {
  const id = c.req.param('id')
  // id 的类型是 string,值为 '42',不是数字 42
  return c.json({ id, type: typeof id }) // { id: '42', type: 'string' }
})

URL 里的 42 在 HTTP 协议层面就是一段文本。Hono 路由器把它从 URL 路径中提取出来,放进 matchResult 的参数映射里,全程没有做任何类型转换。所以不管你写的是 /users/42/users/00042 还是 /users/3.14c.req.param('id') 拿到的都是字符串。

这个事实带来的直接后果是:如果你拿 id === 42 做比较,条件永远不会成立。更隐蔽的问题是,把字符串 id 直接传进数据库查询函数时,某些 ORM 或 SQL 驱动可能不会报错,但会产生类型不一致的查询。

2. zValidator 校验路径参数

01 介绍了 zValidator 的基本用法。校验路径参数时,第一个参数传 'param'

// src/routes/users.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
 
const app = new Hono()
 
const paramSchema = z.object({
  id: z.string().regex(/^\d+$/),
})
 
app.get('/users/:id', zValidator('param', paramSchema), (c) => {
  const { id } = c.req.valid('param')
  // id 的类型仍然是 string(schema 没有做 transform)
  // 但这里可以确定它一定是纯数字字符串
  return c.json({ id })
})
 
export default app

zValidator('param', paramSchema) 在执行时会调用 c.req.param() 取出所有路径参数,然后用 schema 做校验。校验不通过时,中间件直接返回 400 错误,处理函数不会执行。校验通过之后,c.req.valid('param') 返回的值类型由 schema 推导出来。

这里有一个关键点需要注意:schema 里的 key 必须和路由定义里的参数名完全一致。 路由写的是 :id,schema 里就要有 id 这个字段。如果路由写的是 :userId,schema 里却写了 id,校验不会报错,但 id 字段会收到 undefined——因为参数映射里根本没有这个 key。

// src/routes/users.ts
// ❌ 参数名不匹配——路由定义是 :userId,schema 里写的是 id
const paramSchema = z.object({
  id: z.string(),
})
 
app.get('/users/:userId', zValidator('param', paramSchema), (c) => {
  const data = c.req.valid('param')
  // data.id 是 undefined,因为参数映射里的 key 是 userId
  // 但 zValidator 默认不会阻止请求继续往下走
  return c.json({ data }) // { data: {} }
})

这个坑比较隐蔽,因为 zValidator 对缺失字段的处理取决于 schema 定义。如果 schema 里 idz.string()(非 optional),理论上 Zod 会拒绝 undefined,但实际行为可能因为 zValidator 的版本和参数传递方式而有差异。更稳妥的做法是让参数名始终保持一致,不要依赖框架帮你兜底。

3. 常见的参数校验模式

不同的参数类型有不同的校验策略。下面列出三种最常见的场景。

数字 ID 校验

最简单的情况——路径参数是一个自增整数 ID:

// src/routes/users.ts
const numericIdSchema = z.object({
  id: z.string().regex(/^\d+$/).transform(Number),
})
 
app.get('/users/:id', zValidator('param', numericIdSchema), (c) => {
  const { id } = c.req.valid('param')
  // id 的类型是 number(经过 transform)
  return c.json({ id })
})

z.string().regex(/^\d+$/) 先确保原始值是纯数字字符串,.transform(Number) 再把它转成 number 类型。经过这一步,c.req.valid('param') 返回的 id 已经是 number 了,后续代码不用再手动转换。

注意 regex 和 coerce 的区别。z.coerce.number() 也能把字符串转成数字,但它不会拒绝 ''(空字符串会被转成 0)和 '3.14abc'(会被转成 3.14)。如果你的业务要求 ID 必须是纯数字,用 regex 更严格。

UUID 校验

当主键使用 UUID 时,参数校验的重点是格式:

// src/routes/users.ts
const uuidParamSchema = z.object({
  id: z.string().uuid(),
})
 
app.get('/users/:id', zValidator('param', uuidParamSchema), (c) => {
  const { id } = c.req.valid('param')
  // id 的类型是 string,但格式一定是合法的 UUID
  return c.json({ id })
})

z.string().uuid() 内部会检查值是否符合 UUID v4 的标准格式(xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)。格式不对的请求会在中间件层被拦截,返回 400。

如果项目使用的不是标准 UUID v4,而是其他版本的 UUID,或者用了 ULID、NanoID 这类替代方案,z.string().uuid() 可能过于严格或完全不适用。这时可以用 regex 自定义格式约束,或者干脆只做非空校验,把格式验证交给数据库层。

Slug 校验

博客文章、文档这类资源通常用 slug 做路径参数,比如 /posts/hello-world

// src/routes/posts.ts
const slugParamSchema = z.object({
  slug: z.string().min(1).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
})
 
app.get('/posts/:slug', zValidator('param', slugParamSchema), (c) => {
  const { slug } = c.req.valid('param')
  return c.json({ slug })
})

这里的 regex 约束了 slug 只能包含小写字母、数字和连字符,且不能以连字符开头或结尾。具体规则取决于你的 slug 生成策略——如果支持中文 slug 或 Unicode 字符,regex 需要相应调整。

4. 什么时候该校验格式,什么时候交给数据库

不是所有参数都需要在 schema 层做严格的格式校验。需要考虑的一个实际问题是:校验的目的是什么?

格式校验能提前拦截明显的无效请求。一个 UUID 格式都不对的字符串,查数据库肯定找不到,在中间件层就拒绝掉可以节省一次数据库查询。但格式校验不是万能的——一个格式正确的 UUID 对应的记录也可能不存在。所以格式校验和数据是否存在是两件事,前者替代不了后者。

实践中可以按这个思路划分边界:

  1. 参数格式有明确规范(如 UUID、纯数字 ID、特定前缀的编码):在 schema 层用 regex 或内置方法校验。不符合格式的直接返回 400,省去无意义的数据库查询。
  2. 参数格式比较灵活,但值必须存在(如 slug、用户名):schema 层只做基础的非空和长度约束,让数据库查询结果决定返回 404 还是正常数据。
  3. 参数本身是自由格式的字符串(如文件名、路径段):schema 层做最小约束,比如 z.string().min(1),更多规则交给业务逻辑判断。
// src/routes/users.ts
// 策略 1:格式有明确规范
const userIdSchema = z.object({
  id: z.string().regex(/^\d+$/).transform(Number),
})
 
// 策略 2:基础约束 + 数据库判定
const postSlugSchema = z.object({
  slug: z.string().min(1).max(200),
})
 
app.get('/posts/:slug', zValidator('param', postSlugSchema), async (c) => {
  const { slug } = c.req.valid('param')
  const post = await db.posts.findBySlug(slug)
 
  if (!post) {
    return c.json({ error: 'Post not found' }, 404)
  }
 
  return c.json(post)
})

两种策略不冲突,可以根据不同资源的特点混用。核心原则是:schema 校验负责拦截格式层面的无效输入,数据库查询负责回答数据是否存在。

5. 多参数路由的校验

一条路由可以包含多个参数。校验时,所有参数都写在同一个 schema 里:

// src/routes/orgs.ts
const orgMemberSchema = z.object({
  orgId: z.string().regex(/^\d+$/).transform(Number),
  memberId: z.string().regex(/^\d+$/).transform(Number),
})
 
app.get(
  '/orgs/:orgId/members/:memberId',
  zValidator('param', orgMemberSchema),
  (c) => {
    const { orgId, memberId } = c.req.valid('param')
    // orgId 和 memberId 都已经是 number 类型
    return c.json({ orgId, memberId })
  }
)

Zod 的 z.object 天然支持多字段校验,所有参数会在同一次 safeParse 调用中完成检查。如果 orgId 格式正确但 memberId 不符合,Zod 的错误信息里只会包含 memberId 的问题。

如果多个参数之间存在业务约束——比如 memberId 必须在 orgId 对应的组织下存在——这种跨参数的关联校验通常不适合放在 schema 里做。schema 擅长处理单个字段的格式约束,跨字段的业务规则放到处理函数或 service 层更合适。

6. 可选路径参数与校验挑战

Hono 的可选参数 :param? 在注册阶段会被展开成多条路由(这在 04-hono-routing/02 第 4 节有详细解释)。这个机制对校验有一个直接影响:同一个 schema 需要同时处理参数存在和参数不存在两种情况。

// src/routes/articles.ts
const articleParamSchema = z.object({
  slug: z.string().min(1).optional(),
})
 
app.get('/articles/:slug?', zValidator('param', articleParamSchema), (c) => {
  const params = c.req.valid('param')
 
  if (params.slug) {
    // GET /articles/hello-world
    return c.json({ article: params.slug })
  }
 
  // GET /articles
  return c.json({ articles: [] })
})

这里 slug 必须是 optional(),否则请求 /articles 时(没有 slug 参数),schema 校验会因为缺少 slug 字段而失败。

可选参数的校验有一个容易忽略的问题:Hono 展开可选参数后,/articles/articles/:slug 是两条独立的路由,但它们共享同一个 zValidator 中间件。当请求 /articles 时,参数映射里根本没有 slug 这个 key,zValidator 拿到的是空对象 {}。如果 schema 里 slug 不是 optional,Zod 就会拒绝这个空对象。

对于多级可选参数(如 /api/:version?/users/:id?),情况会更复杂。展开后的每条路由拥有不同数量的参数,schema 需要把所有可能的参数都标成 optional,然后在处理函数里判断哪些参数实际存在。这种路由设计在实际项目中比较少见,如果业务上确实需要,建议在路由设计上做简化——拆成多条路由往往比用多级可选参数更清楚。

7. 路由级正则约束 vs Zod schema 校验

Hono 支持在路由定义里用 :param{regex} 语法约束参数格式(这在 04-hono-routing/02 第 5 节讲过):

// src/index.ts
// 路由级正则约束
app.get('/users/:id{\\d+}', (c) => {
  const id = c.req.param('id') // 一定是纯数字字符串
  return c.json({ id })
})

这和 Zod schema 校验看起来做了同一件事,但它们的机制不同:

  1. 路由级正则:在路由匹配阶段生效。不符合正则的请求根本不会命中这条路由,直接走 404。处理函数不会被调用。
  2. Zod schema 校验:在中间件阶段生效。请求已经命中了路由,但 zValidator 中间件发现参数不符合 schema 时返回 400。处理函数同样不会被调用。

两者的区别体现在错误响应上:路由级正则失败返回 404(「找不到这条路由」),Zod 校验失败返回 400(「参数格式不对」)。从 RESTful API 设计角度看,400 更准确——客户端传了错误的参数格式,应该得到明确的错误提示,而不是一条不存在的路由。

两种机制可以组合使用。路由级正则做一个粗略的格式过滤,Zod schema 做精确校验加类型转换:

// src/routes/users.ts
const paramSchema = z.object({
  id: z.string().transform(Number),
})
 
// 路由级正则先过滤非数字请求(返回 404)
// Zod schema 再做类型转换(string → number)
app.get(
  '/users/:id{\\d+}',
  zValidator('param', paramSchema),
  (c) => {
    const { id } = c.req.valid('param')
    // id 已经是 number 类型
    return c.json({ id })
  }
)

这种组合的好处是:路由级正则把明显不合法的请求(比如 /users/abc)直接挡在门外,Zod schema 负责把合法参数转成业务需要的类型。如果不想在两个地方重复写正则,也可以只用 Zod schema,把路由级正则去掉——Zod 的校验已经足够保证参数格式。

// src/routes/users.ts
// 只用 Zod schema,不需要路由级正则
const paramSchema = z.object({
  id: z.string().regex(/^\d+$/).transform(Number),
})
 
app.get('/users/:id', zValidator('param', paramSchema), (c) => {
  const { id } = c.req.valid('param')
  return c.json({ id })
})

对于多数项目来说,只用 Zod schema 校验就够了。路由级正则更适合作为一种补充手段,用于在路由匹配阶段就做粗粒度的流量过滤。

8. 常见陷阱

参数名不匹配

路由定义和 schema 里的参数名必须一致。这个坑在前面第 2 节已经提过,但它的变体值得再补充一种情况——重构路由时改了参数名但忘了改 schema:

// src/routes/users.ts
// 重构前::id → schema 里也是 id ✅
// 重构后::userId → schema 里还是 id ❌
 
const paramSchema = z.object({
  id: z.string().regex(/^\d+$/),
})
 
app.get('/users/:userId', zValidator('param', paramSchema), (c) => {
  const data = c.req.valid('param')
  // data.id 来自 schema 定义,但参数映射里只有 userId
  // data 可能是 {} 或 { userId: '42' }(取决于 zValidator 版本)
  return c.json({ data })
})

TypeScript 的类型推导在这里不一定能帮到你,因为 zValidator 的类型推导依赖 schema 定义,而 schema 里的 key 是 id 不是 userId。要发现这个问题,最好靠代码审查和命名规范——参数名始终使用能反映业务含义的名称,改路由定义时同步改 schema。

忘记添加校验

如果路由定义了 :id 但没有挂 zValidator,c.req.param('id') 拿到的就是一个未经验证的字符串。直接拿它去查数据库,可能不会报错,但会让非法参数流入业务逻辑层。

// src/routes/users.ts
// 没有校验——任何字符串都会进入处理函数
app.get('/users/:id', async (c) => {
  const id = c.req.param('id') // 'abc', '../../etc/passwd', ''... 都能进来
  const user = await db.users.findById(id) // 可能抛出意外错误
  return c.json(user)
})

对于内部工具或原型,不做校验也许可以接受。但对于面向外部的 API,建议每条带参数的路由都挂上对应的 zValidator。

对参数类型做错误假设

即使加了 Zod 校验,在处理函数里也要注意类型。如果 schema 里 id 定义的是 z.string()c.req.valid('param') 返回的 id 就是 string。不要想当然地以为它已经是 number:

// src/routes/users.ts
const paramSchema = z.object({
  id: z.string().regex(/^\d+$/), // 注意:没有 .transform(Number)
})
 
app.get('/users/:id', zValidator('param', paramSchema), (c) => {
  const { id } = c.req.valid('param')
  // id 的类型是 string,不是 number
  // 如果后续需要数值运算,必须手动转换或给 schema 加 transform
  return c.json({ id })
})

如果需要 id 是 number,在 schema 里加上 .transform(Number) 是最直接的做法。这样类型推导会自动把 id 标记为 number,后续代码不用再做任何转换。

校验性能和开销

Path param 校验的执行开销很低。参数在路由匹配阶段就已经被提取到 matchResult 里了,zValidator 只是从 matchResult 里取出参数对象做一次 Zod safeParse。对于典型的 1-3 个参数的路由,这个操作的耗时在微秒级别。

不需要担心参数校验会成为性能瓶颈。如果你的 API 响应时间偏长,瓶颈更可能在数据库查询、外部 API 调用或序列化环节。

9. 完整示例

把前面讲到的内容组合成一个完整的例子,展示一个典型的路径参数校验配置:

// src/routes/index.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
 
const app = new Hono()
 
// 用户资源——数字 ID
const userIdParam = z.object({
  userId: z.string().regex(/^\d+$/).transform(Number),
})
 
app.get('/users/:userId', zValidator('param', userIdParam), (c) => {
  const { userId } = c.req.valid('param')
  return c.json({ userId, type: typeof userId })
  // { userId: 42, type: 'number' }
})
 
// 文章资源——slug,格式灵活,交给数据库判定
const postSlugParam = z.object({
  slug: z.string().min(1).max(200),
})
 
app.get('/posts/:slug', zValidator('param', postSlugParam), async (c) => {
  const { slug } = c.req.valid('param')
  // 查询数据库,找不到返回 404
  return c.json({ slug })
})
 
// 嵌套资源——多参数校验
const orgMemberParam = z.object({
  orgId: z.string().regex(/^\d+$/).transform(Number),
  memberId: z.string().regex(/^\d+$/).transform(Number),
})
 
app.get(
  '/orgs/:orgId/members/:memberId',
  zValidator('param', orgMemberParam),
  (c) => {
    const { orgId, memberId } = c.req.valid('param')
    return c.json({ orgId, memberId })
  }
)
 
// UUID 参数
const uuidParam = z.object({
  id: z.string().uuid(),
})
 
app.get('/tasks/:id', zValidator('param', uuidParam), (c) => {
  const { id } = c.req.valid('param')
  return c.json({ id })
})
 
export default app

延伸阅读

总结

Path param 校验要解决的核心问题是:从 URL 里提取出来的参数始终是字符串,而业务逻辑通常需要特定格式或类型的值。

zValidator('param', schema) 把这件事做成了声明式的:schema 定义参数格式,中间件负责校验和类型转换,处理函数直接拿到正确类型的值。路由定义里的参数名和 schema 里的 key 必须一致,否则校验会静默落空。

路由级正则约束(:id{\d+})和 Zod schema 校验可以组合使用,但对于多数项目,只用 Zod 就够了。格式校验负责拦截无效输入,数据是否存在交给数据库回答——这两层的边界分清楚,校验逻辑就不会越写越臃肿。

下一篇讲 Zod 集成实践,看看如何在项目层面组织 schema、封装复用逻辑、处理嵌套数据结构校验。