AppType 导出机制

要点

  • Hono RPC 的基础是 TypeScript 的类型推导,不需要手写 interface 也不需要代码生成
  • AppType 是一个类型别名,用来描述整个应用的完整路由树——路径、方法、参数、响应结构都在里面
  • 路由必须用链式写法定义,TypeScript 才能把每一步的路由信息累积到最终类型中
  • typeof app 只拿到一个空的 Hono 实例类型;typeof route 才能拿到链式调用积累下来的路由信息
  • 中间件(比如 zValidator)插入链路后,校验类型会沿着链式调用传导到 AppType
  • Env 泛型决定了 c.set() / c.get() 携带的上下文变量类型,也会体现在 AppType 里
  • 链式断开、返回值没有接住、子路由忘记链式挂载,是三种最常见的类型丢失原因

1. TypeScript 类型系统与 Hono RPC

在讲 AppType 之前,需要先交代 Hono RPC 成立的前提。

传统 REST API 的前后端协作靠人肉同步:后端改字段,前端靠文档或口头通知更新类型。Swagger、OpenAPI 这类方案能缓解,但引入了额外流程——后端改了接口要更新文档,前端要重新生成代码,中间任何一步断了就会脱节。

Hono 选择了一条不同的路径:利用 TypeScript 编译器本身来做类型契约。后端的路由定义代码同时充当了接口规格说明,前端通过 import type 直接引用后端的类型信息。不需要中间格式,不需要代码生成步骤。

这条路径能走通,依赖的是 TypeScript 的几个能力:

  1. 泛型参数可以携带结构化信息——Hono<Env, Schema, BasePath> 三个泛型参数分别记录环境、路由 schema 和基础路径
  2. 链式方法的返回值可以累积类型——每次 .get() / .post() 调用都返回一个新的 Hono 实例,新实例的 Schema 泛型包含了之前所有路由的信息
  3. typeof 可以从值反推类型——TypeScript 自动从链式调用的返回值中提取完整的路由树

AppType 就是这套机制的产物。

2. 什么是 AppType

// server.ts
import { Hono } from 'hono'
 
const app = new Hono()
 
const route = app
  .get('/health', (c) => {
    return c.json({ status: 'ok' })
  })
  .get('/users', (c) => {
    const users = [{ id: 1, name: 'Alice' }]
    return c.json({ users })
  })
 
export type AppType = typeof route

AppType 里包含了什么?至少包含这些信息:

  • /health 这条路由,GET 方法,响应体是 { status: string }
  • /users 这条路由,GET 方法,响应体是 { users: { id: number; name: string }[] }

这些类型信息是 TypeScript 从 c.json() 的返回值自动推导出来的,不需要手写 interface

export type 只导出类型,不包含运行时代码。前端引入 AppType 之后不会把后端代码打包进去——它只在 TypeScript 编译期存在,编译完成后就消失了。

3. 类型推导的工作过程

Hono 的类型推导核心机制可以拆成三步来看。

3.1 Hono 的泛型参数

Hono 类有三个泛型参数:

// hono.d.ts(简化)
declare class Hono<
  E extends Env = Env,           // 环境变量(后面会讲)
  S extends Schema = {},         // 路由 schema
  BasePath extends string = '/'  // 基础路径
> {
  // ...
}

其中 S(Schema)是关键。每次调用 .get() / .post() 等方法,Hono 都会返回一个新的实例,新实例的 S 泛型参数里追加了当前路由的信息。

3.2 链式调用累积类型

// server.ts
const step0 = new Hono()
// step0 的类型: Hono<Env, {}, '/'>  Schema 为空
 
const step1 = step0.get('/users', (c) => c.json({ users: [] }))
// step1 的类型: Hono<Env, { '/users': { $get: { ... } } }, '/'>
 
const step2 = step1.post('/users', (c) => c.json({ user: { id: 1 } }, 201))
// step2 的类型: Hono<Env, { '/users': { $get: ...; $post: ... } }, '/'>

链式写法 app.get(...).post(...) 和分开写 step0 = ...; step1 = ...; step2 = ... 在类型推导上完全等价。但链式写法更简洁,最终只需要对一个变量做 typeof

3.3 typeof 提取

最后一步用 typeof 把值的类型提取为类型别名:

// server.ts
export type AppType = typeof step2
// AppType 等同于 step2 的完整类型,包含所有路由的路径、方法、参数、响应结构

typeof 在这里的作用就是「录像回放」——它不关心运行时发生了什么,只把 TypeScript 在编译期已经推导出来的类型结构原样取出来。

4. 链式调用:类型推导的前提

TypeScript 的类型推导是「基于赋值」的。一个变量的类型来自它被赋的值。链式调用的每一步都返回一个类型更丰富的新对象,最终变量持有的就是包含所有路由信息的类型。

如果分开写:

// server.ts
const app = new Hono()
// app: Hono<Env, {}, '/'>
 
app.get('/users', handler1)
// 返回值(带有路由信息的新 Hono 实例)被丢弃了
// app 的类型仍然是 Hono<Env, {}, '/'>
 
app.post('/users', handler2)
// 同样,返回值被丢弃

app.get() 的返回值没有被赋给任何变量,路由信息就丢失了。app 的类型始终是初始的 Hono&lt;Env, &#123;&#125;, '/'&gt;,Schema 为空。

链式写法的本质就是确保每一步的返回值都被使用:

// server.ts
const route = app
  .get('/users', handler1)
  .post('/users', handler2)
 
export type AppType = typeof route

链式写法不是风格偏好,而是类型推导能工作的结构性要求。

5. typeof app vs typeof route

理解了链式调用之后,两者的区别就明确了:

// server.ts
const app = new Hono()
 
app.get('/users', (c) => c.json({ users: [] }))
app.post('/users', (c) => c.json({ user: {} }))
// typeof app → Hono<Env, {}, '/'>  Schema 是空的
 
const route = app
  .get('/users', (c) => c.json({ users: [] }))
  .post('/users', (c) => c.json({ user: {} }))
// typeof route → Hono<Env, { '/users': { $get: ...; $post: ... } }, '/'>
表达式类型中的 Schema包含路由信息适合导出给前端
typeof app&#123;&#125;(空)
typeof route完整路由树

一个容易混淆的点:approute 在运行时指向同一个 Hono 实例,路由都能正常访问。区别只在类型层面。

6. 中间件类型如何流入 AppType

中间件不只是运行时逻辑。当中间件涉及数据校验时,它的类型也会沿着链路传导。

6.1 zValidator 的类型传导

// server.ts
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
 
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

zValidator('json', createUserSchema) 做了两件事:

  1. 运行时:解析请求体,用 Zod schema 校验,校验失败返回 400
  2. 类型层面:把 schema 推导出的类型注入到后续处理函数的 c.req.valid('json')

AppType 里记录的响应类型 &#123; user: &#123; id: number; name: string; email: string &#125; &#125; 就是处理函数 c.json() 返回值的类型。这个类型之所以能正确推导,是因为 zValidator 在前一步把请求体的类型信息传递进来了。

6.2 多个中间件串联

// server.ts
const route = app
  .post('/posts', authMiddleware, zValidator('json', postSchema), (c) => {
    const data = c.req.valid('json')     // 来自 zValidator
    const userId = c.get('userId')        // 来自 authMiddleware
    return c.json({ post: { id: 1, ...data, authorId: userId } }, 201)
  })

每个中间件都可能影响后续处理函数的类型。zValidator 注入请求体类型,自定义中间件通过 c.set() 注入上下文变量类型。这些信息都会体现在最终 AppType 中。

7. Env 泛型与上下文变量

Hono 的第一个泛型参数 E extends Env。Env 定义了上下文变量的类型,也会影响 AppType。

7.1 基本用法

// server.ts
type Env = {
  Bindings: { DB: D1Database }
  Variables: { userId: string; role: 'admin' | 'user' }
}
 
const app = new Hono<Env>()
 
app.use('*', async (c, next) => {
  c.set('userId', 'user-123')
  c.set('role', 'admin')
  await next()
})
 
const route = app.get('/me', (c) => {
  const userId = c.get('userId') // string,有类型提示
  const role = c.get('role')     // 'admin' | 'user'
  return c.json({ userId, role })
})
 
export type AppType = typeof route

Env 的 Variables 定义了 c.set() / c.get() 可以存取的变量及其类型。这些类型通过 Hono 的泛型参数一路传导到 AppType。

7.2 子路由继承 Env

// routes/users.ts
type Env = { Variables: { userId: string } }
 
const users = new Hono<Env>()
  .get('/me', (c) => {
    const userId = c.get('userId') // string
    return c.json({ userId })
  })
 
export default users
 
// server.ts
const app = new Hono<Env>()
const route = app.route('/users', users) // 子路由必须链式挂载
export type AppType = typeof route

子路由和主应用的 Env 类型需要兼容。如果子路由用了主应用没定义的 Variables,TypeScript 会报错。

8. 完整示例

// server.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
 
type Env = {
  Bindings: { DB: D1Database }
  Variables: { userId: string }
}
 
const createUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
})
 
const paginationSchema = z.object({
  page: z.string().optional().default('1'),
  limit: z.string().optional().default('20'),
})
 
const app = new Hono<Env>()
 
app.use('*', async (c, next) => {
  c.set('userId', 'user-123')
  await next()
})
 
const route = app
  .get('/health', (c) => c.json({ status: 'ok' as const }))
  .get('/users', zValidator('query', paginationSchema), (c) => {
    const { page, limit } = c.req.valid('query')
    return c.json({
      users: [{ id: 1, name: 'Alice', email: '[email protected]' }],
      page: Number(page),
      limit: Number(limit),
    })
  })
  .post('/users', zValidator('json', createUserSchema), (c) => {
    const data = c.req.valid('json')
    const userId = c.get('userId')
    return c.json({ user: { id: 2, ...data, createdBy: userId } }, 201)
  })
  .get('/users/:id', (c) => {
    const id = c.req.param('id')
    return c.json({ user: { id, name: 'Alice', email: '[email protected]' } })
  })
 
export type AppType = typeof route
export default app

这份代码导出的 AppType 包含四条路由的完整信息。前端引入后,hc&lt;AppType&gt;(baseURL) 可以按类型安全的方式调用任意一条路由。

9. 类型推导失败的常见原因

碰到前端 hc 调用没有类型提示的情况,问题大概率出在后端的类型导出上。

9.1 没有用链式写法

// ❌ 类型丢失
app.get('/users', handler1)
app.post('/users', handler2)
export type AppType = typeof app // Schema 为空
 
// ✅ 类型完整
const route = app
  .get('/users', handler1)
  .post('/users', handler2)
export type AppType = typeof route

9.2 子路由挂载时丢失类型

// ❌ 直接挂载,没有接住返回值
app.route('/users', users)
export type AppType = typeof app // 不包含子路由
 
// ✅ 链式挂载
const route = app.route('/users', users)
export type AppType = typeof route

app.route() 同样返回带有路由信息的新实例。不接住返回值,子路由的类型信息就丢了。

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

// ❌ 分开写
const users = new Hono()
users.get('/', handler1)
users.post('/', handler2)
export default users
 
// ✅ 链式定义
const users = new Hono()
  .get('/', handler1)
  .post('/', handler2)
export default users

9.4 处理函数没有 return

// ❌ 没有 return
const route = app.get('/users', (c) => {
  c.json({ users: [] })
})
// 响应类型变成 void,前端拿不到响应体的类型推导

10. 调试类型推导问题

类型推导失败时,TypeScript 不会给你一个明确的错误信息。它只是默默地让 hc 客户端的属性变成 never 或者缺少方法。

排查方法:

  1. IDE 悬停:把鼠标悬停在 route 变量上,看 Schema 是 &#123;&#125; 还是包含路由信息
  2. 分步赋值:把链式调用拆开,逐步检查每个中间步骤的类型,定位类型在哪一步丢失
  3. 检查子路由导出:确认子路由模块的默认导出是链式调用的返回值,不是 Schema 为空的 Hono 实例
  4. 检查返回值:确认每个处理函数都有 return c.json(...) 或其他返回 Response 的语句

11. 性能考量

AppType 是一个类型层面的结构。它不影响运行时代码(export type 编译后就消失了),但会影响 TypeScript 编译器的类型检查时间。

11.1 类型复杂度

路由数量少的时候,AppType 的类型推导几乎是瞬时的。当路由数量增长到几十上百条,并且大量使用了 zValidator、嵌套中间件时,类型结构会变得很深,类型检查时间会上升。

实际项目中,几百条路由的 AppType 通常在可接受范围内。但如果遇到明显的类型检查变慢,可以考虑拆分 AppType。

11.2 拆分 AppType

按模块导出子路由的类型,而不是把所有路由合并到一个 AppType:

// routes/users.ts
const users = new Hono()
  .get('/', handler1)
  .post('/', handler2)
export type UsersAppType = typeof users
// client.ts
import type { UsersAppType } from './routes/users'
import type { PostsAppType } from './routes/posts'
import { hc } from 'hono/client'
 
// 前端按需引用,不把所有路由合并到一个巨型类型
const usersClient = hc<UsersAppType>('http://localhost:8787')
const postsClient = hc<PostsAppType>('http://localhost:8787')

这样做的前端调用路径会稍长一些,但类型检查速度更快。适合路由数量很大的场景。

11.3 减少中间件层级

每一层中间件都会在路由类型中追加一层泛型嵌套。在没有必要的情况下,避免把中间件拆得过细:

// ❌ 类型嵌套过深
const route = app
  .use('*', mw1)
  .use('*', mw2)
  .use('*', mw3)
  .get('/users', handler)
 
// ✅ 合并中间件,减少类型层级
const combinedMiddleware = async (c, next) => {
  await mw1(c, async () => {
    await mw2(c, async () => {
      await mw3(c, next)
    })
  })
}
const route = app.use('*', combinedMiddleware).get('/users', handler)

这个优化只在路由数量多、中间件层级深的时候才有意义。路由少的时候不需要提前优化。

延伸阅读

总结

AppType 导出机制可以归纳为三个环节:

  1. 链式累积——每次 .get() / .post() / .route() 调用都返回一个 Schema 泛型更丰富的新 Hono 实例,链式写法确保所有路由信息被累积到同一个变量中
  2. typeof 提取——用 typeof route 把链式调用终值的类型结构原样提取为类型别名,这个过程不产生运行时代码
  3. 类型传导——中间件(zValidator、自定义 c.set())的类型信息沿着链路传导,最终体现在 AppType 的响应类型推导中

三个环节缺一不可。链式断开、返回值没接住、子路由没有链式定义,都会导致 AppType 里的路由信息不完整。碰到前端 hc 没有类型提示的情况,先沿着这三个环节逐一排查。

下一篇讲服务端类型推导——Hono 怎样从路由定义、中间件链路、Zod schema 一路把类型传导到处理函数的 c 参数上,以及自定义中间件如何正确声明类型以避免推导断裂。