服务端类型推导

要点

  • zValidator 把 Zod schema 注入到处理函数的 c 对象类型里,c.req.valid('json') 的返回类型直接从 schema 推导
  • HonoRequest<Input>Input 泛型记录了每个校验目标对应的类型,valid() 从中按 key 查找
  • 多个校验器串联时,Hono 用交叉类型合并每一层的 Inputc 对象上同时能看到所有校验结果
  • 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

这里没有写一行 interfacedata 的类型完全来自 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 直接报错。

两个推论:

  1. valid() 只能取到校验过的目标。没加 zValidator('query', schema) 就调用 c.req.valid('query') 会编译失败。
  2. 返回类型精确对应 schema 推导结果。Zod 的 z.object(&#123; name: z.string() &#125;) 推导出 &#123; name: string &#125;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 })
  }
)

三个校验器各自注入了 jsonqueryheader 三个目标的类型。Hono 使用交叉类型合并每一层中间件的 Input,最终处理函数拿到的 c 对象类型包含所有校验结果,互不干扰。

4. Env 泛型与上下文变量类型

c.set() / c.get() 在处理函数之间共享数据,不标注类型 c.get() 返回 anyEnv 泛型为这组操作加类型约束:

// 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 })
  }
)

经过校验后,idstring | 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,后续的 jsonheader 校验不会执行。把校验成本低、失败概率高的放前面,可以减少不必要的请求体解析。

类型合并的边界。多个校验器的 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 route

AppType 包含 /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.inferc.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 & &#123; Variables: &#123; adminToken: string &#125; &#125;

保持链式调用完整

链式调用是类型推导的基础,每一步都要保留返回值:

// ✅ 正确
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 提示可靠。

延伸阅读

总结

服务端的类型推导可以归纳为三层结构:

  1. 注入层zValidator 把 Zod schema 的类型信息注入到中间件链的 Input 泛型中,每个校验目标(jsonqueryparamheader)各占一个键
  2. 传递层:Hono 通过交叉类型把每一层中间件的 Input 合并,处理函数拿到的 c 对象包含了所有校验结果的类型信息;Env 泛型负责上下文变量和运行环境绑定的类型约束
  3. 取出层c.req.valid(target) 从合并后的类型中按目标查找,返回精确的推导结果;c.get(key)Env.Variables 中按 key 查找

三层之中,注入层是起点。schema 定义得越精确,后面两层能给出的类型保护就越完整。app.route()basePath() 把这种类型保护扩展到多模块场景,但也带来了链式调用和返回值导出的约束。

理解了服务端的类型传导机制,客户端的类型共享就有了基础。下一篇转到客户端侧,看 hc 怎样把 AppType 转换成前端的调用接口,以及客户端的类型推导和服务端的推导之间怎样衔接。