服务端类型推导
要点
zValidator把 Zod schema 注入到处理函数的c对象类型里,c.req.valid('json')的返回类型直接从 schema 推导HonoRequest<Input>的Input泛型记录了每个校验目标对应的类型,valid()从中按 key 查找- 多个校验器串联时,Hono 用交叉类型合并每一层的
Input,c对象上同时能看到所有校验结果 Env泛型负责给c.set()/c.get()的上下文变量加类型约束,跨中间件传递数据时不再返回any- 路由参数和查询参数在没有校验器时只有
string | undefined,接入zValidator后才能得到精确类型 app.route()和basePath()都保留了子路由的类型信息,但前提是返回值必须赋给变量再导出- TypeScript 对大量路由的交叉类型、动态注册等场景存在推导限制,需要用实践模式补偿
1. 从 Schema 到 Handler 的类型传导
03 讲了前端怎样通过 hc 拿到类型安全的调用接口。这篇把视线收回到服务端,看这些类型是从哪里产生的。
// server.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
const route = app.post(
'/users',
zValidator('json', createUserSchema),
(c) => {
const data = c.req.valid('json')
// data 的类型是 { name: string; email: string }
return c.json({ user: { id: 1, ...data } }, 201)
}
)
export type AppType = typeof route这里没有写一行 interface。data 的类型完全来自 createUserSchema——运行时做校验,编译时推导类型。多传字段编译报错,少传必填字段运行时返回 400。整个过程归纳为一条链路:
Zod schema -> zValidator 注入类型 -> c.req.valid() 取出类型 -> 处理函数拿到精确类型
zValidator 是一个工厂函数,接收目标位置和 Zod schema,返回 Hono 中间件。它把 schema 推导出的类型注入到中间件链中,让后续处理函数能通过 c.req.valid() 取到。
2. c.req.valid() 的推导机制
c.req.valid() 定义在 HonoRequest 类上。HonoRequest 接受一个泛型参数 Input,记录前面所有校验器注入的类型信息:
// Hono 源码(简化)
class HonoRequest<Input = {}> {
valid<T extends keyof Input[T]>(target: T): Input[T]
}调用 c.req.valid('json') 时,TypeScript 把 'json' 代入泛型 T,返回类型就是 Input['json']。如果前面没有 zValidator('json', ...) 注入过类型,Input 里不存在 'json' 键,TypeScript 直接报错。
两个推论:
valid()只能取到校验过的目标。没加zValidator('query', schema)就调用c.req.valid('query')会编译失败。- 返回类型精确对应 schema 推导结果。Zod 的
z.object({ name: z.string() })推导出{ name: string },valid('json')返回的就是这个类型。
3. 中间件链中的类型传递
一条路由上可以挂载多个中间件,每个中间件往 c 上注入类型信息,TypeScript 把每一层的类型累积起来:
// server.ts
const route = app.post(
'/articles',
zValidator('json', createArticleSchema),
zValidator('query', z.object({ draft: z.string().optional() })),
zValidator('header', z.object({ 'x-author-id': z.string() })),
(c) => {
const body = c.req.valid('json') // createArticleSchema 推导的类型
const query = c.req.valid('query') // { draft?: string }
const headers = c.req.valid('header') // { 'x-author-id': string }
return c.json({ body, query, headers })
}
)三个校验器各自注入了 json、query、header 三个目标的类型。Hono 使用交叉类型合并每一层中间件的 Input,最终处理函数拿到的 c 对象类型包含所有校验结果,互不干扰。
4. Env 泛型与上下文变量类型
c.set() / c.get() 在处理函数之间共享数据,不标注类型 c.get() 返回 any。Env 泛型为这组操作加类型约束:
// server.ts
type Env = {
Variables: { userId: string; role: 'admin' | 'user' }
Bindings: { MY_KV: KVNamespace; DB: D1Database }
}
const app = new Hono<Env>()
app.use('*', async (c, next) => {
c.set('userId', 'user-123') // ✅
c.set('userId', 123) // ❌ 类型不匹配
await next()
})
app.get('/profile', (c) => {
const userId = c.get('userId') // string
const kv = c.env.MY_KV // KVNamespace
return c.json({ userId })
})Bindings 来自运行环境(Cloudflare Workers 的 KV、D1 等);Variables 来自中间件的 c.set()。两者在 Env 里各自独立。
5. 各类参数的类型推导
路由参数
Hono 根据路径定义自动推导路由参数类型,但返回 string | undefined——TypeScript 无法确定运行时 URL 是否一定包含该段。配合 zValidator 做参数校验后,类型会被收窄:
// server.ts
const paramsSchema = z.object({
id: z.string().regex(/^\d+$/).transform(Number),
})
app.get(
'/users/:id',
zValidator('param', paramsSchema),
(c) => {
const { id } = c.req.valid('param') // number(经 transform 转换)
return c.json({ id })
}
)经过校验后,id 从 string | undefined 变成 number,校验和类型转换合并在一步完成。
查询参数
没有校验器时,c.req.query('page') 返回 string | undefined。用 zValidator 接管后:
// server.ts
const querySchema = z.object({
page: z.string().optional().transform((v) => v ? Number(v) : 1),
limit: z.string().optional().transform((v) => v ? Number(v) : 20),
})
app.get(
'/articles',
zValidator('query', querySchema),
(c) => {
const { page, limit } = c.req.valid('query') // page: number, limit: number
return c.json({ page, limit })
}
)请求体
请求体的 schema 通常更复杂——嵌套对象、可选字段、数组等。Zod 的类型推导能覆盖这些场景:
// server.ts
const createOrderSchema = z.object({
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().positive(),
})),
note: z.string().optional(),
})
app.post(
'/orders',
zValidator('json', createOrderSchema),
(c) => {
const order = c.req.valid('json')
// order.items[0].productId: string
// order.items[0].quantity: number
// order.note: string | undefined
return c.json({ orderId: 'ORD-001', ...order })
}
)前端通过 hc 调用时,json 参数的类型自动从后端 schema 推导。传错字段会编译报错。这个流程在 03 里从客户端角度讲过,这里补充了服务端的来源。
6. 组合多个校验器
同一路由上组合多个校验器时,除了类型合并,还需要留意两个边界:
// server.ts
app.put(
'/users/:id',
zValidator('param', z.object({ id: z.string().regex(/^\d+$/) })),
zValidator('json', updateUserSchema),
zValidator('header', z.object({ 'x-request-id': z.string() })),
(c) => {
const params = c.req.valid('param')
const body = c.req.valid('json')
const headers = c.req.valid('header')
return c.json({ id: params.id, body, requestId: headers['x-request-id'] })
}
)校验顺序影响执行顺序。Hono 按中间件注册顺序执行。param 校验失败时返回 400,后续的 json 和 header 校验不会执行。把校验成本低、失败概率高的放前面,可以减少不必要的请求体解析。
类型合并的边界。多个校验器的 schema 之间存在引用关系时,交叉类型可能变得复杂。建议让每个校验器只负责自己的目标。另外,两个校验器注入同一个目标(比如都校验 'json')时,后一个的类型会覆盖前一个,这种写法应当避免。
7. 路由挂载与类型
实际项目中路由按模块拆分。app.route() 挂载子路由时保留完整的类型信息:
// users.ts
const users = new Hono()
.get('/', (c) => c.json({ users: [] }))
.post('/', zValidator('json', createUserSchema), (c) => {
return c.json({ user: { id: 1, ...c.req.valid('json') } }, 201)
})
.get('/:id', (c) => c.json({ user: { id: c.req.param('id') } }))
export default users// server.ts
const app = new Hono()
const route = app.route('/users', users) // 返回值赋给变量
export type AppType = typeof routeAppType 包含 /users、/users/:id 等所有路由的参数和响应类型。前端调用时 client.users.$get() 和 client.users[':id'].$get() 都能拿到正确的类型。
basePath() 的处理类似,它给一组路由加统一前缀:
// server.ts
const api = new Hono().basePath('/api/v1')
const route = api
.get('/users', (c) => c.json({ users: [] }))
.get('/posts', (c) => c.json({ posts: [] }))
export type AppType = typeof route
// 前端调用:client.api.v1.users.$get()两者的共同前提:返回值必须赋给变量再导出。直接 export type AppType = typeof app 拿到的只是 Hono 基础类型,不包含注册过的路由信息。
8. 推导的限制与边界
大量路由的类型膨胀
一条链上注册了几十个路由时,TypeScript 需要计算的交叉类型层级很深,IDE 类型检查会变慢。应对方式是按模块拆分,每个子应用单独导出类型,主入口只做组合:
// server.ts
const usersRoute = new Hono().get('/users', ...)
const postsRoute = new Hono().get('/posts', ...)
const route = new Hono()
.route('/users', usersRoute)
.route('/posts', postsRoute)
export type AppType = typeof route动态路由注册的类型丢失
在循环或条件分支里注册路由,TypeScript 无法推导:
// server.ts
// ❌ 动态注册,类型丢失
const modules = ['/users', '/posts', '/comments']
modules.forEach((path) => {
app.get(path, (c) => c.json({ path }))
})
// ❌ 条件注册同样不识别
if (process.env.ENABLE_ADMIN) {
app.get('/admin/stats', (c) => c.json({ stats: {} }))
}Hono 的类型推导依赖链式调用在编译期展开所有路由信息。动态注册和条件注册绕过了这个机制,这些路由不会出现在 AppType 中。
自定义中间件的类型断裂
自定义中间件不标注返回类型时,c.set() 注入的变量可能不被 TypeScript 识别:
// middleware/auth.ts
import type { MiddlewareHandler } from 'hono'
type AuthEnv = { Variables: { userId: string } }
// ❌ 没有标注泛型,后续 c.get('userId') 可能返回 any
export function authMiddleware() {
return async (c, next) => {
c.set('userId', 'user-123')
await next()
}
}
// ✅ 标注 MiddlewareHandler 泛型,类型正确注入
export function authMiddlewareTyped(): MiddlewareHandler<AuthEnv> {
return async (c, next) => {
c.set('userId', 'user-123')
await next()
}
}响应类型不确定
c.json() 的返回类型会被注入路由信息中供客户端推导。但不同分支返回不同结构的 JSON 时,客户端推导出的会是联合类型。尽量让每个分支的响应结构保持一致。
9. 最大化类型安全的实践模式
Schema 作为唯一类型源
// schemas/user.ts
import { z } from 'zod'
export const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive().optional(),
})
// 类型别名也从 schema 推导,不手写 interface
export type CreateUserInput = z.infer<typeof createUserSchema>校验、处理函数类型、客户端类型全从一份 schema 生成。z.infer 和 c.req.valid() 走同一条推导路径,结果一致。
常用校验器封装成中间件
// middleware/validators.ts
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
export const numericIdParam = z.object({
id: z.string().regex(/^\d+$/).transform(Number),
})
export const paginationQuery = z.object({
page: z.string().optional().transform((v) => (v ? Number(v) : 1)),
limit: z.string().optional().transform((v) => (v ? Number(v) : 20)),
})使用时类型随中间件自动流动:
// routes/users.ts
import { zValidator } from '@hono/zod-validator'
import { numericIdParam, paginationQuery } from '../middleware/validators'
app
.get('/', zValidator('query', paginationQuery), (c) => {
const { page, limit } = c.req.valid('query') // { page: number; limit: number }
return c.json({ users: [], page, limit })
})
.get('/:id', zValidator('param', numericIdParam), (c) => {
const { id } = c.req.valid('param') // { id: number }
return c.json({ user: { id } })
})按领域拆分 Env
// types/env.ts
export type AppEnv = {
Bindings: { MY_KV: KVNamespace; DB: D1Database }
Variables: { userId: string; role: 'admin' | 'user' }
}
// routes/users.ts
const users = new Hono<AppEnv>()
.get('/:id', (c) => {
const userId = c.get('userId') // string
const db = c.env.DB // D1Database
return c.json({ userId, db })
})
export default users每个模块引用同一个 AppEnv。某模块需要额外变量时,通过交叉类型扩展:type AdminEnv = AppEnv & { Variables: { adminToken: string } }。
保持链式调用完整
链式调用是类型推导的基础,每一步都要保留返回值:
// ✅ 正确
const users = new Hono().get(...).post(...)
const route = app.route('/users', users)
export type AppType = typeof route
// ❌ 返回值被丢弃,类型信息丢失
const users = new Hono()
users.get(...)只要有一步断链,对应的路由类型就不会出现在 AppType 中。
用 typecheck 验证推导
// tests/type-check.ts
import type { AppType } from '../server'
import { hc } from 'hono/client'
const client = hc<AppType>('http://localhost:8787')
type UsersResponse = Awaited<ReturnType<typeof client.users.$get>>
// 后端改了字段名,这里会编译报错pnpm typecheck 跑一遍,类型推导断裂会直接报编译错误。比依赖 IDE hover 提示可靠。
延伸阅读
- 03-Hono Client使用方式 —
hc客户端的类型共享用法,与本文的服务端推导互补 - 04.02-动态路由参数 — 路由参数的底层传递机制和
matchResult原理 - @hono/zod-validator 源码 —
zValidator的泛型签名和类型注入实现 - Hono TypeScript 文档 — Hono 官方 TypeScript 使用指南
- Zod 官方文档 — Zod schema 定义、transform 和
z.infer类型推导
总结
服务端的类型推导可以归纳为三层结构:
- 注入层:
zValidator把 Zod schema 的类型信息注入到中间件链的Input泛型中,每个校验目标(json、query、param、header)各占一个键 - 传递层:Hono 通过交叉类型把每一层中间件的
Input合并,处理函数拿到的c对象包含了所有校验结果的类型信息;Env泛型负责上下文变量和运行环境绑定的类型约束 - 取出层:
c.req.valid(target)从合并后的类型中按目标查找,返回精确的推导结果;c.get(key)从Env.Variables中按 key 查找
三层之中,注入层是起点。schema 定义得越精确,后面两层能给出的类型保护就越完整。app.route() 和 basePath() 把这种类型保护扩展到多模块场景,但也带来了链式调用和返回值导出的约束。
理解了服务端的类型传导机制,客户端的类型共享就有了基础。下一篇转到客户端侧,看 hc 怎样把 AppType 转换成前端的调用接口,以及客户端的类型推导和服务端的推导之间怎样衔接。