Query校验
要点
- Query 参数来自 URL,经过 HTTP 协议传输后全部是字符串,不存在「天然数字」或「天然布尔」
z.coerce系列方法可以在校验的同时完成类型转换,省去手动Number()和Boolean()- 分页场景(page、limit、offset)适合用
.default()提供合理默认值,避免每次请求都要求客户端传全 - 数组参数(
?tag=js&tag=ts)在 Hono 中需要用.array()显式声明,否则只取到第一个值 - Query 校验和 Body 校验在语义上有一个核心区别:query 是 URL 的一部分,会被浏览器、CDN、日志系统记录,不适合传递敏感数据
zValidator('query', schema)和c.req.valid('query')的组合可以在一条链路里完成解析、校验和类型推导
1. Query 参数始终是字符串
打开浏览器访问 /posts?page=2&limit=10,服务端拿到的 page 和 limit 是 '2' 和 '10'——两个字符串。这不是 Hono 的特殊行为,而是 HTTP 协议的规定:URL 中的查询值只有字符串一种形态。
这意味着后续的类型转换是绕不开的步骤。如果跳过转换直接拿去做数学运算或数据库查询,轻则结果不符合预期,重则抛出运行时异常:
// src/index.ts
app.get('/posts', (c) => {
const page = c.req.query('page') // 类型是 string | undefined
// ❌ 字符串乘以 10 不会报错,但结果不是你要的
// '2' * 10 = 20,碰巧能跑
// 但 'abc' * 10 = NaN,静默传播
const offset = (page - 1) * 10 // 类型系统不会拦住你
return c.json({ offset })
})在 01 里介绍过 Zod 的基本用法。这一节的重点是把 Zod 的类型转换能力用到 query 场景里。
2. zValidator 校验 query
zValidator 的第一个参数改成 'query',就可以校验查询参数:
// src/index.ts
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
const querySchema = z.object({
page: z.coerce.number().int().positive(),
limit: z.coerce.number().int().positive().max(100),
})
app.get('/posts', zValidator('query', querySchema), (c) => {
const { page, limit } = c.req.valid('query')
// page: number, limit: number
// 类型已经从 string 转成了 number
return c.json({ page, limit })
})z.coerce.number() 做的事情等价于先接住字符串,再调用 Number() 转换,最后校验结果是否为合法数字。如果客户端传了 page=abc,转换结果是 NaN,Zod 校验不通过,中间件直接返回 400。
这里有一个细节值得注意:c.req.valid('query') 返回的对象和 c.req.query() 拿到的原始值是不同的。前者经过了 Zod 的 parse,类型已经被转换过;后者是 Hono 从 URL 直接解析出来的原始字符串。不要混用这两个来源。
3. 类型转换:z.coerce 的用法
Zod 提供了三个和 query 场景密切相关的 coerce 方法:
z.coerce.number()
把字符串转成数字。空字符串会转成 0,无法解析的字符串会得到 NaN(校验不通过)。
// src/index.ts
const schema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
minPrice: z.coerce.number().min(0).optional(),
})z.coerce.boolean()
把字符串转成布尔值。需要注意的是,Zod 的 coerce 布尔遵循 JavaScript 的 truthy/falsy 规则:空字符串 '' 和 '0' 会被转成 false,'1'、'true'、'yes' 都会被转成 true。
// src/index.ts
const schema = z.object({
published: z.coerce.boolean().optional(),
// ?published=1 → true
// ?published=0 → false
// 不传 → undefined
})如果你的业务要求只有 'true' 和 'false' 两个字符串才合法,z.coerce.boolean() 并不合适——它接受任何字符串。这种情况更适合用 enum 或自定义转换,第 8 节会介绍。
z.coerce.date()
把字符串转成 Date 对象。底层调用 new Date(value),所以支持的格式和 JavaScript Date 构造函数一致。
// src/index.ts
const schema = z.object({
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
// ?startDate=2026-01-01&endDate=2026-06-20
// startDate → Date(2026-01-01T00:00:00.000Z)Date 转换有个陷阱:new Date('not-a-date') 不会抛异常,而是返回 Invalid Date。Zod 的 z.coerce.date() 会检查 isNaN(date.getTime()),遇到无效日期会校验失败。
4. 默认值与分页模式
分页是 query 参数最典型的使用场景。客户端不传 page 和 limit 时,服务端需要有合理的默认值,而不是直接报错。
Zod 的 .default() 方法专门处理这种情况:
// src/index.ts
const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
// GET /posts
// → { page: 1, limit: 20 }
// GET /posts?page=3
// → { page: 3, limit: 20 }
// GET /posts?page=3&limit=50
// → { page: 3, limit: 50 }
// GET /posts?limit=200
// → 400(超过 max(100)).default() 和 .optional() 的区别在于:.optional() 允许字段不传,返回 undefined;.default() 在不传时自动填充一个值。分页参数通常更适合用 .default(),因为下游代码不需要处理 undefined。
有些接口使用 offset/limit 分页而不是 page/limit 分页:
// src/index.ts
const offsetPaginationSchema = z.object({
offset: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
// GET /posts?offset=40&limit=20
// → { offset: 40, limit: 20 }
// GET /posts
// → { offset: 0, limit: 20 }两种分页方式的 schema 结构一样,只是字段名和默认值不同。选哪种取决于前端和数据库的约定。
5. 必填与可选参数
Zod 的 object schema 默认要求所有字段都存在。在 query 场景里,大多数参数其实是可选的——客户端不传就不传,服务端给默认值或跳过相关逻辑。
// src/index.ts
const listPostsSchema = z.object({
// 必填:客户端必须提供
category: z.string().min(1),
// 可选:不传时为 undefined
tag: z.string().optional(),
// 可选 + 默认值:不传时自动填充
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
// GET /posts?category=tech
// → { category: 'tech', tag: undefined, page: 1, limit: 20 }
// GET /posts?category=tech&tag=zod&page=2
// → { category: 'tech', tag: 'zod', page: 2, limit: 20 }
// GET /posts
// → 400(缺少 category)判断一个 query 参数是必填还是可选,标准很简单:如果缺少这个参数,接口还能不能正常工作?分页参数有默认值,缺了也能跑,所以用 .default()。分类筛选如果业务要求必须指定,就声明为必填。
6. 枚举校验
排序字段和筛选类型通常只允许一组固定值。z.enum() 可以把这种约束直接写进 schema:
// src/index.ts
const sortFields = ['createdAt', 'updatedAt', 'title', 'views'] as const
const listPostsSchema = z.object({
sortBy: z.enum(sortFields).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
status: z.enum(['draft', 'published', 'archived']).optional(),
})
// GET /posts?sortBy=views&sortOrder=asc
// → { sortBy: 'views', sortOrder: 'asc' }
// GET /posts?sortBy=author
// → 400(author 不在 sortFields 里)z.enum() 接收一个字面量数组,校验时做严格字符串比较。客户端传了不在列表中的值,Zod 会报一个 invalid_enum_value 错误,消息里会列出所有合法选项。
枚举校验的好处是类型推导也很精确。c.req.valid('query').sortBy 的类型不是 string,而是 'createdAt' | 'updatedAt' | 'title' | 'views',后续写 switch 或对象映射时 TypeScript 能做穷举检查。
7. 数组参数
URL 里的数组参数有多种编码方式:
?tag=js&tag=ts // 重复 key
?tag=js,ts // 逗号分隔
?tag[]=js&tag[]=ts // 带方括号的 key
Hono 的 c.req.query('tag') 只返回第一个值。要拿到所有值,需要用 c.req.queries('tag')。而 zValidator('query', schema) 默认也是按单值处理的。
要让 Zod 校验重复 key 的数组参数,需要在 schema 中显式声明 .array():
// src/index.ts
const listPostsSchema = z.object({
tag: z.array(z.string()).optional(),
// ?tag=js&tag=ts → ['js', 'ts']
})
app.get('/posts', zValidator('query', listPostsSchema), (c) => {
const { tag } = c.req.valid('query')
// tag: string[] | undefined
return c.json({ tags: tag ?? [] })
})@hono/zod-validator 在处理 query 目标时,会检测 URL 中是否有重复的 key。如果有,自动把值收集成数组,再交给 Zod 校验。如果 schema 里声明了 z.array() 但客户端只传了一个值(?tag=js),Zod 也能正常接收——@hono/zod-validator 会把单值包装成 ['js']。
如果客户端用逗号分隔的方式传数组(?tag=js,ts),就需要在 schema 里做一次 split 转换:
// src/index.ts
const listPostsSchema = z.object({
tag: z.string()
.optional()
.transform((val) => (val ? val.split(',') : [])),
// ?tag=js,ts → ['js', 'ts']
})两种编码方式各有使用场景。重复 key 更符合 HTML 表单和 axios 等 HTTP 客户端的默认行为;逗号分隔更紧凑,适合参数值本身不含逗号的简单场景。
8. 日期校验
日期在 query 参数里通常以 YYYY-MM-DD 格式传递。z.coerce.date() 能处理,但它接受的格式太宽泛——2026/06/20、Jun 20, 2026 都能通过。
如果要严格限定格式,可以先做字符串格式校验,再转 Date:
// src/index.ts
const dateRegex = /^\d{4}-\d{2}-\d{2}$/
const listPostsSchema = z.object({
startDate: z.string()
.regex(dateRegex, '日期格式必须为 YYYY-MM-DD')
.transform((val) => new Date(val))
.optional(),
endDate: z.string()
.regex(dateRegex, '日期格式必须为 YYYY-MM-DD')
.transform((val) => new Date(val))
.optional(),
})
// GET /posts?startDate=2026-01-01&endDate=2026-06-20
// → startDate: Date, endDate: Date
// GET /posts?startDate=2026/1/1
// → 400(格式不匹配)
// GET /posts?startDate=not-a-date
// → 400(正则不通过)这里用 .transform() 而不是 .coerce.date(),是因为先走正则过滤了一层格式,再交给 new Date() 转换。这样能保证只有 YYYY-MM-DD 格式才进入转换步骤。
如果需要进一步校验 startDate 不能晚于 endDate,可以用 .refine():
// src/index.ts
const dateRangeSchema = z.object({
startDate: z.string().regex(dateRegex).transform((v) => new Date(v)).optional(),
endDate: z.string().regex(dateRegex).transform((v) => new Date(v)).optional(),
}).refine(
(data) => {
if (data.startDate && data.endDate) {
return data.startDate <= data.endDate
}
return true
},
{ message: 'startDate 不能晚于 endDate', path: ['endDate'] }
).refine() 可以在整个 schema 级别做跨字段校验。path: ['endDate'] 让错误信息指向 endDate 字段,方便前端定位。
9. 自定义校验与 refine
z.coerce 覆盖了大部分类型转换需求。但有些场景需要更精细的控制,比如前面提到的布尔值只接受 'true' 和 'false':
// src/index.ts
const strictBooleanSchema = z.string()
.refine((val) => val === 'true' || val === 'false', {
message: '必须是 "true" 或 "false"',
})
.transform((val) => val === 'true')
// ?active=true → true
// ?active=false → false
// ?active=1 → 400
// ?active=yes → 400再比如,搜索关键词需要限制最小长度和字符范围:
// src/index.ts
const searchSchema = z.object({
q: z.string()
.min(1, '搜索关键词不能为空')
.max(100, '搜索关键词最长 100 个字符')
.refine(
(val) => !/[<>]/.test(val),
'搜索关键词不能包含 < 或 > 字符'
),
}).refine() 接收一个返回布尔值的函数。返回 false 表示校验不通过。第二个参数可以是错误消息字符串,也可以是一个包含 message 和 path 的对象。
如果有异步校验需求(比如检查某个值是否在数据库中存在),Zod 提供了 .refine() 的异步版本和 .superRefine()。但在 query 校验场景里,不建议做异步操作——query 解析发生在每个请求的路由阶段,引入数据库查询会直接影响响应时间。
10. 错误信息的优化
Zod 的默认错误消息是英文的,比如 Required、Expected number, received nan。如果 API 的消费者是中文用户,可能需要定制错误消息。
最直接的方式是在 schema 定义时传入 errorMap 或使用 .min(1, '自定义消息') 的重载:
// src/index.ts
const listPostsSchema = z.object({
page: z.coerce
.number({ invalid_type_error: 'page 必须是数字' })
.int('page 必须是整数')
.positive('page 必须大于 0')
.default(1),
limit: z.coerce
.number({ invalid_type_error: 'limit 必须是数字' })
.int('limit 必须是整数')
.min(1, 'limit 最小为 1')
.max(100, 'limit 最大为 100')
.default(20),
category: z.string().min(1, 'category 不能为空'),
})如果想统一处理所有 query 校验的错误格式,可以结合 01 里介绍过的自定义 hook:
// src/middleware/validate-query.ts
import { zValidator } from '@hono/zod-validator'
import type { ZodSchema } from 'zod'
export function validateQuery(schema: ZodSchema) {
return zValidator('query', schema, (result, c) => {
if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
}))
return c.json(
{
code: 'QUERY_VALIDATION_ERROR',
errors,
},
400
)
}
})
}使用:
// src/index.ts
import { validateQuery } from './middleware/validate-query'
app.get('/posts', validateQuery(listPostsSchema), (c) => {
const query = c.req.valid('query')
return c.json(query)
})返回的错误格式:
// GET /posts?page=abc&limit=200
{
"code": "QUERY_VALIDATION_ERROR",
"errors": [
{ "field": "page", "message": "page 必须是数字" },
{ "field": "limit", "message": "limit 最大为 100" }
]
}这种格式对前端友好:每个错误都有明确的字段名和消息,前端可以按 field 把错误信息挂到对应的表单控件上。
11. 性能考量
Query 解析发生在每个 GET 请求的处理链路中。对于高频接口(比如每秒数千次的列表查询),query 解析的开销值得关注。
Hono 的 query 解析实现比较轻量——它不会预先把整个 query string 解析成对象,而是按需查找:
// hono/request.ts(简化)
query(key?: string): string | undefined | Record<string, string> {
// 只在调用时解析 URL searchParams
const params = new URLSearchParams(this.url.search)
return key ? params.get(key) ?? undefined : Object.fromEntries(params)
}zValidator('query', schema) 内部会调用 c.req.query() 拿到所有参数(不传 key 时返回完整对象),然后交给 Zod 的 schema.safeParse() 做校验。
几个和性能相关的实践建议:
- schema 定义放在路由外部。Zod 的
z.object()在定义时会编译内部校验逻辑,如果放在处理函数内部,每次请求都会重新编译。
// src/index.ts
// ✅ schema 在模块加载时创建一次,所有请求复用
const querySchema = z.object({
page: z.coerce.number().default(1),
})
app.get('/posts', zValidator('query', querySchema), (c) => {
// ...
})// ❌ 每次请求都重新创建 schema
app.get('/posts', zValidator('query', z.object({
page: z.coerce.number().default(1),
})), (c) => {
// ...
})- 不要在 query 校验中做异步操作。
.refine()的异步版本会让校验变成await,阻塞请求处理。 - 控制 schema 复杂度。一个包含十几个字段、多个
.refine()和.transform()的 schema,校验成本会比简单的几个.coerce.number()高。如果列表接口只需要分页参数,不要顺手把所有可能的筛选字段都加进去。
12. 组合模式:搜索 + 筛选 + 排序 + 分页
实际项目中的列表接口通常是多个查询维度的组合。把各部分拆开定义,再用 z.object() 组合到一起,schema 会更清晰:
// src/routes/posts/schema.ts
import { z } from 'zod'
// 分页
const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
// 搜索
const searchSchema = z.object({
q: z.string().max(100).optional(),
})
// 排序
const sortSchema = z.object({
sortBy: z.enum(['createdAt', 'updatedAt', 'title', 'views']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
})
// 筛选
const filterSchema = z.object({
category: z.string().optional(),
status: z.enum(['draft', 'published', 'archived']).optional(),
tag: z.array(z.string()).optional(),
})
// 组合
export const listPostsSchema = paginationSchema
.merge(searchSchema)
.merge(sortSchema)
.merge(filterSchema)
// 推导出的类型:
// {
// page: number
// limit: number
// q?: string
// sortBy: 'createdAt' | 'updatedAt' | 'title' | 'views'
// sortOrder: 'asc' | 'desc'
// category?: string
// status?: 'draft' | 'published' | 'archived'
// tag?: string[]
// }// src/routes/posts/index.ts
import { listPostsSchema } from './schema'
import { validateQuery } from '../../middleware/validate-query'
app.get('/posts', validateQuery(listPostsSchema), (c) => {
const query = c.req.valid('query')
// query 的类型已经完整推导出来
// 可以直接传给 service 层
return c.json({
data: [],
pagination: {
page: query.page,
limit: query.limit,
},
})
})拆分 schema 的好处是各部分可以独立测试和复用。如果另一个列表接口只需要分页和搜索,可以直接 paginationSchema.merge(searchSchema),不需要重新定义。
13. Query 校验与 Body 校验的差异
03 介绍了 Body 校验。两者在机制上用的是同一个 zValidator,但在语义和工程实践上有几个值得注意的区别:
| 维度 | Query 校验 | Body 校验 |
|---|---|---|
| 数据来源 | URL 的 ? 后面部分 | 请求体(JSON、FormData 等) |
| 数据类型 | 始终是字符串,需要 coerce | JSON 本身有数字、布尔等类型 |
| 可见性 | URL 暴露在浏览器地址栏、日志、CDN | 请求体通常不记录在日志中 |
| 大小限制 | 受 URL 长度限制(通常 2KB-8KB) | 受请求体大小限制,通常更宽松 |
| HTTP 方法 | 主要用于 GET | 用于 POST、PUT、PATCH |
| 敏感数据 | 不适合 | 相对适合(但仍需加密传输) |
| 缓存影响 | Query 不同会导致缓存键不同 | 不影响 URL 级缓存 |
一个常见的错误是把大量筛选条件放到请求体里,用 POST 代替 GET 做列表查询。这种做法会丢失 HTTP 缓存能力——CDN 和浏览器无法缓存 POST 请求的响应。除非筛选条件确实超出了 URL 长度限制,否则 GET + query 参数是列表接口的更优选择。
在 schema 定义上,两者的写法几乎一样。唯一的区别是 query 更频繁地使用 z.coerce 系列方法,因为所有值都是字符串;而 JSON body 里的数字和布尔值本身就是对应类型,通常不需要 coerce。
延伸阅读
- 01-为什么需要请求验证 —
zValidator的基本用法和自定义错误响应 - 03-Body校验 — 请求体校验的详细用法
- 04.02-动态路由参数 — query 和 param 在数据流上的区别
- Zod coerce 文档 —
z.coerce各类型的转换规则 - Hono Query 文档 —
c.req.query()和c.req.queries()的 API 说明
总结
Query 参数校验的核心在于类型转换。URL 里的值全部是字符串,z.coerce 系列方法让转换和校验合为一步,省掉了手动 Number() 加 isNaN() 检查的样板代码。
几个关键点:
- 分页用
.default():page和limit不传时有合理默认值,下游代码不需要处理undefined - 枚举用
z.enum():排序字段和状态筛选只允许固定值,类型推导也更精确 - 数组用
z.array():重复 key 的参数需要显式声明数组类型 - 跨字段校验用
.refine():比如startDate不能晚于endDate - schema 定义放路由外部:避免每次请求重复编译
- query 不适合传敏感数据:URL 会被日志、CDN、浏览器历史记录
下一篇讲 Path Params 校验——路由参数的校验方式和 query 有什么异同,zValidator('param', schema) 的使用场景和注意事项。