常见类型错误处理

要点

  • Hono RPC 中的类型错误大部分可以归入五类:链式断开、中间件类型断裂、Env 泛型不一致、app.route 返回值丢失、复杂 Schema 推导失败
  • 链式写法是类型推导的结构性要求,不是风格偏好——分开写路由会导致 AppType 中 Schema 为空
  • 自定义中间件缺少 Env 泛型声明,会阻断下游的类型传导
  • 编译期类型安全不能替代运行时校验——部署版本不一致时 TypeScript 保护不了你
  • 类型变更后按「后端先更新共享类型 → 前端修复编译错误 → 联调验证」的顺序处理,降低互相阻塞的概率

1. 「Type 'X' is not assignable to type 'Y'」

这是 TypeScript 里出现频率最高的错误格式。在 Hono RPC 的上下文中,它通常出现在两个位置。

1.1 后端侧:c.json 返回值

当你在 c.json 上显式指定了泛型参数,但实际返回值与泛型参数不一致时:

// server.ts
interface UserResponse {
  user: { id: number; name: string; email: string }
}
 
return c.json<UserResponse>({
  user: { id: 1, name: 'Alice', email: '[email protected]', role: 'admin' },
  // ❌ role 不在 UserResponse 里
})

多数情况下 c.json() 的返回值类型是直接推导的,不会报这个错。显式指定泛型时才会触发。修复方式是让接口定义和实际返回值对齐。

1.2 前端侧:调用参数

// client.ts
const res = await client.users.$post({
  json: {
    name: 123, // ❌ Type 'number' is not assignable to type 'string'
    email: '[email protected]',
  },
})

根源在前端传了后端 schema 不接受的类型。错误信息里 Type 'X' is not assignable to type 'Y' 已经告诉你期望的类型是什么。

2. 链式写法断开导致类型丢失

这是 Hono RPC 里出现频率最高的类型问题。

2.1 条件分支中注册路由

// server.ts
const app = new Hono()
 
if (ENABLE_ADMIN) {
  app.get('/admin/users', adminHandler)
  // ❌ app.get() 的返回值被丢弃,路由信息丢失
}
 
const route = app.get('/public', publicHandler)
export type AppType = typeof route
// AppType 不包含 /admin/users

修复方式是把条件逻辑放到处理函数内部,或者用 reduce 在循环中累积路由:

// server.ts
const resources = ['users', 'posts', 'comments']
const route = resources.reduce(
  (acc, name) => acc.get(`/${name}`, createHandler(name)),
  new Hono()
)
export type AppType = typeof route

2.2 子路由自身没有链式定义

// routes/users.ts
const users = new Hono()
users.get('/', handler1)
users.post('/', handler2)
export default users // ❌ Schema 为空

子路由也必须用链式写法,这在 02 篇已经详细说明。

3. 中间件类型断裂

3.1 自定义中间件缺少 Env 泛型

// middleware/auth.ts
// ❌ 没有声明 Env,c.set 的类型不会传导给下游
export const authMiddleware = async (c: Context, next: Next) => {
  c.set('userId', 'user-123')
  await next()
}
// server.ts
const route = app
  .use('*', authMiddleware)
  .get('/me', (c) => {
    const userId = c.get('userId')
    // ❌ userId 的类型可能是 unknown
    return c.json({ userId })
  })

给中间件加上正确的 Env 泛型可以修复:

// middleware/auth.ts
type AppEnv = { Variables: { userId: string } }
 
export const authMiddleware = async (c: Context<AppEnv>, next: Next) => {
  c.set('userId', 'user-123') // ✅ 类型会传导给下游
  await next()
}

3.2 中间件 Env 与 app 的 Env 不一致

// middleware/auth.ts — 声明了 role
type AuthEnv = { Variables: { userId: string; role: string } }
export const authMiddleware = async (c: Context<AuthEnv>, next: Next) => { /* ... */ }
// server.ts — ❌ app 的 Env 只声明了 userId
type AppEnv = { Variables: { userId: string } }
const app = new Hono<AppEnv>()
const route = app
  .use('*', authMiddleware)
  // ❌ AuthEnv 和 AppEnv 不兼容

修复方式是让中间件和 app 共用同一个 Env 类型定义,避免多处声明导致的不一致。

4. Env 泛型不匹配

4.1 Hono 实例没有传 Env 泛型

// server.ts
const app = new Hono() // ❌ 默认 Env,不允许设置任何 Variables
 
app.use('*', async (c, next) => {
  c.set('userId', 'user-123')
  // ❌ Type 'string' is not assignable to type 'never'
  await next()
})

需要给 Hono 实例提供 Env 泛型:

// server.ts
type Env = {
  Bindings: { DB: D1Database }
  Variables: { userId: string }
}
const app = new Hono<Env>() // ✅

4.2 Bindings 和 Variables 搞混

Bindings 对应 Cloudflare Workers 的绑定资源(D1、KV、R2),Variables 对应 c.set() / c.get() 存取的上下文变量。把 DB 放到 Variables 里不会导致运行时错误,但 c.env.DB 访问路径会报错。

4.3 子路由和主应用 Env 不一致

子路由声明了主应用未包含的 Variables,类型检查器会拒绝挂载。修复方式是把共享的 Variables 抽成公共 Env 类型,各模块统一引用。

5. app.route() 的类型问题

5.1 返回值没有接住

// server.ts
app.route('/users', users) // ❌ 返回值丢弃
app.route('/posts', posts)
export type AppType = typeof app // Schema 为空

修复方式是链式挂载:

// server.ts
const route = app
  .route('/users', users)
  .route('/posts', posts)
export type AppType = typeof route

5.2 子路由 export default 指向错误的值

// routes/users.ts
const users = new Hono()
  .get('/', handler1)
  .post('/', handler2)
 
const wrapped = users.use('*', loggerMiddleware)
export default users // ❌ 导出的 users 没有包含 loggerMiddleware 的类型

需要导出最终的值。如果中间件也参与链式调用,确保 export default 指向链路终值。

6. 循环类型依赖与复杂 Schema

6.1 循环引用

当路由模块之间互相引用类型时:

// routes/users.ts — 引用 posts 的类型
import type { PostType } from './posts'
 
// routes/posts.ts — 又引用 users 的类型
import type { UserType } from './users'

TypeScript 可以处理部分循环引用,但涉及条件类型或深层泛型时可能导致推导超时。把共享类型抽到独立文件可以打破循环:

// types/shared.ts
export interface UserSummary { id: string; name: string }
export interface PostSummary { id: string; title: string; authorId: string }

6.2 复杂 Schema 推导缓慢

深层嵌套的 Zod schema 会让 TypeScript 推导变慢,甚至导致 hc 客户端参数类型显示为 any。修复策略:

  1. 拆解大 schema——把子 schema 独立定义,组合时引用已定义的类型
  2. z.infer 提前提取类型——避免每次使用时重新推导
  3. z.discriminatedUnion 替代 z.union——TypeScript 可以按判别字段快速缩小范围

7. 调试类型错误

7.1 逐级 hover

沿着链式路由定义,逐级悬停检查类型变化:

// server.ts
const step1 = new Hono().get('/users', handler1)
//    ^? Schema 中是否包含 /users
 
const step2 = step1.post('/users', handler2)
//    ^? Schema 中是否同时包含 $get 和 $post

如果 step1 的 Schema 就是空的,问题出在 Hono 版本或 TypeScript 版本的兼容性。如果 step1 正常但 step2 丢失,说明 handler2 的签名有问题。

7.2 显式注解作为退路

当推导链无法修复时,用显式类型标注保证下游不报错:

// server.ts
type UserResponse = { users: Array<{ id: number; name: string }> }
 
const route = app.get('/users', async (c) => {
  const users = await db.query('SELECT id, name FROM users')
  return c.json<UserResponse>({ users }) // 显式标注覆盖推导
})

代价是后端查询结果变了,TypeScript 不会自动提醒,类型注解需要手动同步。

7.3 分离类型检查

# 只跑类型检查
npx tsc --noEmit
 
# 追踪推导过程(调试用)
npx tsc --noEmit --extendedDiagnostics

--extendedDiagnostics 会输出每个文件的类型检查耗时,帮你定位导致推导变慢的具体文件。

8. 运行时类型错误

TypeScript 的类型安全在编译之后就不存在了。

8.1 后端响应与类型不一致

// server.ts
const route = app.get('/users', async (c) => {
  const users = await db.query('SELECT * FROM users')
  return c.json({ users })
  // ❌ TypeScript 不会检查数据库结果是否和类型定义一致
})

在数据边界处加一层 Zod 校验可以捕获这种不一致:

// server.ts
const userSchema = z.object({ id: z.number(), name: z.string() })
 
const route = app.get('/users', async (c) => {
  const rawUsers = await db.query('SELECT id, name FROM users')
  const users = z.array(userSchema).parse(rawUsers)
  // 如果数据库返回的数据不符合 schema,这里会抛异常
  return c.json({ users })
})

8.2 前后端版本不一致

后端部署了新版本,前端还没更新。前端代码里的类型信息停留在上一次构建时,和后端实际返回的数据可能不一致。如果无法保证同步发布,可以在前端加一层运行时校验:

// client.ts
const res = await client.users.$get()
const raw = await res.json()
const data = UserResponseSchema.parse(raw) // 运行时校验

8.3 网络层错误

hc 返回的是标准 Response 对象。HTTP 层面的错误(500、网络超时等)需要自己处理:

// client.ts
const res = await client.users.$get()
if (!res.ok) throw new Error('Request failed')
const data = await res.json()

9. CI/CD 与类型迁移

9.1 CI 中的类型检查

在流水线中添加独立的类型检查步骤,类型错误应当阻断构建:

# .github/workflows/ci.yml
- run: pnpm install
- run: pnpm --filter backend typecheck
- run: pnpm --filter frontend typecheck
  # 后端改了类型但前端没更新,这里会报错

tsconfig.json 中启用 incremental: true 可以缓存上次检查结果,缩短 CI 运行时间。

9.2 向后兼容的变更

最理想的变更方式是只增加不修改:

// 变更前
type UserResponse = { user: { id: number; name: string } }
// 变更后——新增 email,不删除 name
type UserResponse = { user: { id: number; name: string; email: string } }

前端如果不使用 email,旧代码完全不受影响。

9.3 向后不兼容的变更

必须修改字段名或删除字段时,需要协调前后端发布时间:

  1. 后端发布兼容层——新接口同时返回新旧字段
  2. 前端迁移到新字段——修复编译错误
  3. 后端移除旧字段——确认前端全部迁移后清理

9.4 迁移清单

  1. 后端更新 Zod schema 和路由返回值类型
  2. 后端运行 tsc --noEmit 确认编译通过
  3. 导出 AppType,前端引用最新类型
  4. 前端运行 tsc --noEmit,逐个修复编译错误
  5. 前后端联调,验证变更接口的请求参数和响应结构
  6. 更新 E2E 测试中涉及变更接口的用例

10. 系列回顾

到这里,「Hono RPC 与前后端类型共享」这个系列的全部内容已经讲完。回顾各篇覆盖的内容:

  1. 01 RPC 能力概述:前后端类型断层问题的起源,Hono RPC 的定位与能力边界
  2. 02 AppType 导出机制:链式写法如何累积路由类型、typeof route 提取了什么
  3. 03 Hono Client 使用方式hc 的调用语法、URL 映射规则、与 React Query 配合
  4. 04 服务端类型推导:Zod validator 如何把运行时校验和编译期类型合到一条链路
  5. 05 客户端类型推导hc 内部怎样从 AppType 生成调用签名
  6. 06 单仓库 Monorepo 实践:类型共享在 monorepo 中的目录结构和构建配置
  7. 07 API 类型共享规范:团队协作中的路由命名约定和版本管理
  8. 08 RPC 与 OpenAPI 对比:两种路径的适用场景和组合方式
  9. 09 RPC 在 AI 项目中的应用:流式响应、SSE 与 RPC 类型共享的配合
  10. 10 常见类型错误处理(本篇):五类常见错误的排查、调试技巧、运行时边界、CI 集成、迁移策略

整个系列围绕一条主线:从后端的路由定义出发,类型信息如何沿着链路传导到前端,中间在哪些环节可能断裂,以及断裂之后怎样修复。

延伸阅读

总结

Hono RPC 的类型错误可以归纳为五类:

  1. 链式断开——分开写路由、循环中注册路由、条件分支中注册路由,导致 AppType 不包含完整信息
  2. 中间件类型断裂——自定义中间件缺少 Env 泛型,或 Env 与 app 不一致
  3. Env 泛型不匹配——Hono 实例没有提供 Env 泛型、Bindings 和 Variables 混淆
  4. app.route 返回值丢失——挂载子路由时没有接住返回值
  5. 复杂 Schema 推导失败——深层嵌套、union 选项过多

排查的核心方法归纳为三步:

  1. 逐级 hover——从 new Hono() 开始,沿着链式调用逐步检查 Schema 是否正确累积
  2. 定位断裂点——找到类型从正确变成错误的那一步
  3. 补上声明——给中间件加 Env 泛型、给 app 加 Env 泛型、给 schema 拆解嵌套

编译期的类型安全不能替代运行时的数据校验。在数据边界处加一层 Zod 校验,在 CI 中加一步类型检查,在发布时做好前后端同步——这三层保护组合在一起,才能让 Hono RPC 的类型共享可靠运转。

下一个系列进入「项目结构组织」,讨论当项目从几个路由文件增长到几十个模块时,怎样组织目录结构、拆分路由模块、管理共享类型,让代码库保持可维护的状态。