类型推导

要点

  • Hono + Zod 的目标是写一份 schema,让校验规则、请求数据类型、响应类型在整条链路中自动传导,不需要手写 interface 也不需要 as 断言
  • z.infer 推导出转换之后的输出类型,z.input 推导出转换之前的输入类型,schema 没有 transform 时两者相同
  • Hono 的泛型系统把 zValidator 的 schema 类型传导到 c.req.valid() 的返回值,这条链路是类型安全的核心
  • c.set / c.get 通过 Env 泛型声明上下文变量类型,不声明就只能拿到 unknown
  • Hono 能从路由路径字符串推导出 param 的 key,但字符串拼接会丢失字面量类型
  • RPC 模式(hc 客户端)把服务端路由类型直接传导到前端调用方,改 schema 客户端编译就会报错
  • 类型推导有编译成本,schema 过大、中间件层数过多、路由合并过深都会拖慢构建

1. 写一次 schema,到处拿类型

前面几篇介绍 zValidator 时,已经多次看到这种场景。把完整链路写出来:

// src/routes/users.ts
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
 
const createUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().min(0).optional(),
})
 
const app = new Hono()
 
app.post('/users', zValidator('json', createUserSchema), (c) => {
  const data = c.req.valid('json')
  //    ^? { name: string; email: string; age?: number | undefined }
 
  return c.json({ id: 1, ...data }, 201)
})

不需要手写 interface CreateUserInput,也不需要 as 断言。TypeScript 从 schema 出发,穿过 zValidator 的泛型签名,一路推导到 data 的类型。

这种效果依赖的是 Hono 对泛型参数的设计。如果 schema 改了字段,TypeScript 会在所有消费 c.req.valid() 的地方标红——改一处,全局同步。这是「schema 即类型」带来的直接好处:校验规则和类型定义只有一份来源,不存在两边不一致的可能。

2. z.infer 与 z.input 的区别

Zod 提供两个类型提取工具,区别出现在 schema 包含 transformcoercepreprocess 时:

// src/schemas/user.ts
const userSchema = z.object({
  name: z.string(),
  age: z.coerce.number(),
  createdAt: z.string().transform((s) => new Date(s)),
})
 
type UserOutput = z.infer<typeof userSchema>
// { name: string; age: number; createdAt: Date }
 
type UserInput = z.input<typeof userSchema>
// { name: string; age: string | number; createdAt: string }
  • z.infer:转换之后的类型。createdAt 经过 transform 变成了 Dateage 经过 coerce 变成了 number
  • z.input:转换之前的类型。createdAt 还是原始输入的 stringagestring | number(coerce 接受两种输入)

没有 transform 的 schema,两者完全相同。需要区分它们的场景:

// src/services/user.ts
 
// 构造测试数据时用 z.input —— 模拟客户端发来的原始数据
function buildMockInput(): z.input<typeof userSchema> {
  return { name: 'Alice', age: '25', createdAt: '2026-01-01' }
}
 
// 处理函数里拿到的是 z.infer —— 转换已经完成
function formatUser(user: z.infer<typeof userSchema>) {
  // user.createdAt 是 Date,可以直接调用 getFullYear()
  return { ...user, year: user.createdAt.getFullYear() }
}

c.req.valid() 返回的类型对应 z.infer,因为 zValidator 在校验过程中已经完成了所有转换。如果你的函数接收的是未经校验的原始输入(比如单元测试的 mock 数据),用 z.input 更准确。

3. Hono 的类型传导机制

Hono 的类型推导建立在 TypeScript 的泛型参数传递之上。理解这条传导链,是排查类型推导失败的前提。

传导分三层:

  1. zValidator 泛型签名 — zValidator 接受 Zod schema 作为参数,返回的中间件类型中携带了 schema 的类型信息
  2. c.req.valid() 类型提取valid() 方法的返回类型由泛型参数决定。zValidator 把 schema 类型写入 c 的泛型链路后,valid() 的返回类型就不再是 unknown
  3. 处理函数接收类型 — 处理函数的 c 参数类型由 Hono 实例泛型和中间件链共同决定。zValidator 在类型层面修改了 c 的类型

完整传导路径:

schema → zValidator 泛型 → 中间件类型叠加 → c 参数类型 → c.req.valid() 返回类型

可以用一段代码验证这条链路:

// src/routes/verify.ts
const todoSchema = z.object({
  title: z.string().min(1),
  done: z.boolean().default(false),
})
 
app.post('/todos', zValidator('json', todoSchema), (c) => {
  // 第一步:c 的类型已经包含了 todoSchema 的信息
  // 第二步:c.req 继承了 c 的泛型参数
  // 第三步:c.req.valid('json') 返回 { title: string; done: boolean }
  const data = c.req.valid('json')
  return c.json(data, 201)
})

遇到类型推导失败时,沿着这条路径逐层排查:schema 对不对 → zValidator 是否正确挂载 → c 的类型是否被其他中间件覆盖。

4. c.req.valid() 的推导实践

一条路由挂多个 zValidator 时,每个 target 的类型独立推导:

// src/routes/posts.ts
app.put(
  '/posts/:postId',
  zValidator('param', z.object({ postId: z.string().regex(/^\d+$/) })),
  zValidator('query', z.object({ includeComments: z.coerce.boolean().default(false) })),
  zValidator('json', z.object({ title: z.string().min(1), content: z.string() })),
  (c) => {
    const param = c.req.valid('param')  // { postId: string }
    const query = c.req.valid('query')  // { includeComments: boolean }
    const body = c.req.valid('json')    // { title: string; content: string }
    return c.json({ param, query, body })
  }
)

三个 c.req.valid() 调用的返回类型各自独立,互不干扰。每个 target 的 schema 类型被分别记录在 c 的泛型参数中。

需要注意一种情况:如果对同一个 target 调用了两次 zValidator,后一次会覆盖前一次的类型信息。比如先挂 zValidator('json', schemaA) 再挂 zValidator('json', schemaB)c.req.valid('json') 的类型只反映 schemaB。需要合并校验时,在 schema 层面做 schemaA.merge(schemaB) 比挂两个中间件更合理——运行时也只校验一次,类型信息也不会丢失。

5. 中间件链中的类型传递

中间件往上下文写入数据时,类型通过 Env.Variables 保持。这是 Hono 类型系统中容易被忽视的一环。

// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
 
type Env = {
  Variables: {
    userId: string
    role: 'admin' | 'user'
  }
}
 
export const authMiddleware = createMiddleware<Env>(async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  if (!token) return c.json({ error: 'unauthorized' }, 401)
 
  // c.set 的 key 和 value 类型受 Env.Variables 约束
  c.set('userId', 'user-123')
  c.set('role', 'admin')
  await next()
})

createMiddleware&lt;Env&gt; 约束了 c.set 能写入哪些 key,每个 key 对应什么 value 类型。如果写了 Env.Variables 中不存在的 key,TypeScript 会直接报错。

读取侧同样受类型约束:

// src/routes/admin.ts
const app = new Hono<Env>()
 
app.get('/admin/dashboard', authMiddleware, (c) => {
  const userId = c.get('userId') // string
  const role = c.get('role')     // 'admin' | 'user'
  if (role !== 'admin') return c.json({ error: 'forbidden' }, 403)
  return c.json({ userId, role })
})

路由侧的 new Hono&lt;Env&gt;() 必须声明相同的 Env 类型,否则 c.get() 只能返回 unknown。这是一个常见的新手问题——中间件里 c.set 写得好好的,路由里 c.get 却拿不到正确类型,原因就是两侧的 Env 没有对齐。

更完整的写法是用 hono/factorycreateFactory 统一创建中间件和路由,确保 Env 类型在整条链路中一致:

// src/factory.ts
import { createFactory } from 'hono/factory'
 
type AppEnv = {
  Bindings: { DATABASE_URL: string }
  Variables: { userId: string; requestId: string }
}
 
const factory = createFactory<AppEnv>()
 
// 所有中间件和处理函数都自动继承 AppEnv
const logger = factory.createMiddleware(async (c, next) => {
  c.set('requestId', crypto.randomUUID())
  await next()
})
 
export { factory, logger }
// src/routes/users.ts
import { factory, logger } from '../factory'
 
const app = factory.createApp()
 
app.use('*', logger)
 
app.get('/users/me', (c) => {
  const requestId = c.get('requestId') // string — 类型自动传导
  const dbUrl = c.env.DATABASE_URL     // string
  return c.json({ requestId, dbUrl })
})

6. 类型安全的路由参数

Hono 能从路由路径字符串中推导参数 key,这不需要 zValidator 参与:

// src/routes/articles.ts
app.get('/articles/:articleId/comments/:commentId', (c) => {
  const params = c.req.param()        // { articleId: string; commentId: string }
  const id = c.req.param('articleId') // string
  return c.json({ id })
})
 
// 可选参数也能推导
app.get('/search/:query?', (c) => {
  const q = c.req.param('query') // string | undefined
  return c.json({ query: q })
})

推导依赖路径字符串的字面量类型。如果路径是动态拼接的,字面量类型会丢失:

// ❌ 动态拼接,字面量类型丢失
const prefix = '/api/v1'
app.get(`${prefix}/users/:id`, (c) => {
  const id = c.req.param('id') // 推导可能失败
  return c.json({ id })
})
 
// ✅ basePath 保持字面量类型
const api = new Hono().basePath('/api/v1')
api.get('/users/:id', (c) => {
  const id = c.req.param('id') // string — 推导正常
  return c.json({ id })
})

app.route() 也有类似效果,子路由的路径字面量类型在合并时会被保留。

7. 类型推导的边界与排查

TypeScript 的类型系统有上限。以下几种情况会导致推导退化,以及对应的排查方式:

中间件层数过多。 一条路由挂了 5-6 个 zValidator 加自定义中间件时,泛型嵌套过深,TypeScript 可能放弃推导,直接退化成 unknown。这不是 Hono 的 bug,而是 TypeScript 对泛型实例化深度的保护机制。应对方式是把多个校验合并到更少的中间件里,或者显式声明 Env 类型绕过推导链。

中间件类型声明不匹配。 自定义中间件没有用 createMiddleware&lt;Env&gt; 声明 Variables,后续处理函数就拿不到上下文变量的类型。始终用带泛型的工厂函数创建中间件。

动态路由前缀。 字符串拼接丢失字面量类型,用 basePath()app.route() 代替。

zValidator 和 hono/validator 混用。 @hono/zod-validatorhono/validator 是两套实现,前者的类型签名能传导 schema 类型,后者不会。同一条路由上统一使用 @hono/zod-validator

排查手段:

  1. hover 逐层检查 — 在 VS Code 里悬停在 cc.reqc.req.valid('json') 上,找到类型退化成 unknown 的那一层就是断裂点
  2. 显式类型注释 — 写 const data: ExpectedType = c.req.valid('json'),让编译器告诉你在哪里不匹配
  3. 检查导入来源 — 确认 zValidator 来自 @hono/zod-validator 而不是 hono/validator
  4. 检查 schema 声明方式 — 如果 schema 被 let 声明或经过函数返回,TypeScript 可能把类型推成宽泛的 ZodObject&lt;...&gt; 而非具体的字面量类型。用 const 声明或用 as const
  5. tsc --extendedDiagnostics — 定位编译慢的文件,重点关注路由定义文件和 schema 文件

8. RPC 模式:客户端类型推导

Hono 的 RPC 模式把服务端路由的类型信息直接传导到客户端。从 schema 定义到客户端调用,全程类型安全,不需要生成 OpenAPI spec,也不需要额外的代码生成步骤。

服务端导出类型:

// src/server.ts
const app = new Hono()
 
app.get('/users/:id', zValidator('param', z.object({
  id: z.string().regex(/^\d+$/),
})), (c) => {
  const { id } = c.req.valid('param')
  return c.json({ id, name: 'Alice', email: '[email protected]' })
})
 
app.post('/users', zValidator('json', z.object({
  name: z.string().min(1),
  email: z.string().email(),
})), (c) => {
  const data = c.req.valid('json')
  return c.json({ id: '1', ...data }, 201)
})
 
export type AppType = typeof app

客户端消费类型:

// src/client.ts
import { hc } from 'hono/client'
import type { AppType } from './server'
 
const client = hc<AppType>('http://localhost:8787')
 
// GET:param 类型来自服务端路由推导
const getRes = await client.users[':id'].$get({
  param: { id: '123' }  // id 必须是 string,和服务端 schema 一致
})
if (getRes.ok) {
  const data = await getRes.json()
  // data 的类型是 { id: string; name: string; email: string }
}
 
// POST:json 类型来自服务端 schema
const postRes = await client.users.$post({
  json: { name: 'Bob', email: '[email protected]' }
  // 如果漏了字段或类型不对,编译就会报错
})

服务端改了 schema,客户端编译时就会报错。这种模式适合前后端同一仓库的场景。前后端分开仓库时,类型信息无法直接共享,需要 OpenAPI 这类跨语言方案替代。

app.route() 合并的子路由类型也会被 typeof app 捕获,RPC 客户端能访问到所有子路由的类型信息。

9. 类型推导与构建性能

类型推导有成本。项目小的时候感知不到,路由和 schema 多起来之后,TypeScript 编译时间会明显增长。

以下情况会拖慢编译:

  1. 超大 schema — 几十个字段 + 多层嵌套 + transform,Zod 的类型推导是递归的,每个字段都会产生条件类型计算
  2. 深层中间件链 — 5 个以上中间件,每个都通过泛型修改 c 的类型
  3. 大量路由合并app.route() 嵌套过深,类型合并复杂度随路由数量增长
  4. 频繁使用 z.union / z.discriminatedUnion — 联合类型推导比简单对象更耗时

实用优化手段:

拆分大 schema。 把大 schema 拆成子 schema,用 merge / extend 组合。TypeScript 对小组合的计算效率高于一次性处理大对象:

// src/schemas/user.ts
const baseUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
})
 
const addressSchema = z.object({
  city: z.string(),
  street: z.string(),
})
 
const createUserSchema = baseUserSchema.extend({
  address: addressSchema,
})
 
// 提取具名类型,避免每次重新推导
type CreateUserInput = z.infer<typeof createUserSchema>

提取具名类型。 z.infer&lt;typeof schema&gt; 每次出现都会触发推导。如果同一个 schema 的类型在多处使用,提取成具名类型可以减少重复计算。

控制中间件泛型复杂度。 不需要修改上下文类型的中间件,不要用 createMiddleware&lt;Env&gt;。普通的 hono().use() 不引入额外的泛型参数,类型计算更轻。

监控编译性能。tsc --extendedDiagnostics 定期检测,找出编译耗时最长的文件。路由定义文件和 schema 文件通常是重灾区。如果单个文件的类型检查超过 1 秒,值得排查是否有过度复杂的类型推导。

延伸阅读

总结

Hono + Zod 的类型推导体系可以归纳为一条核心链路:

  1. schema 定义 — 用 Zod 声明数据结构和校验规则,这是类型的唯一来源
  2. zValidator 挂载 — schema 的类型信息通过泛型参数嵌入中间件类型,沿传导链往下游流动
  3. 处理函数消费c.req.valid() 的返回类型由 schema 推导,不需要手写 interface 或 as 断言
  4. 跨中间件传递Env.Variables 声明上下文变量类型,c.set / c.get 受类型约束
  5. RPC 端到端hc&lt;AppType&gt; 把服务端类型传导到客户端,改 schema 客户端编译就报错

理解传导路径之后,类型推导失败时就知道该逐层排查。也需要认识到边界:中间件层数过多、路径动态拼接、schema 过于复杂都会让推导退化。在类型安全和编译性能之间找到平衡点,是项目变大之后需要持续关注的问题。

下一篇讲输入输出类型共享——怎样把服务端的 schema 类型复用到前端的表单组件和状态管理里,让同一份类型定义同时驱动前后端的数据结构。