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 的几个能力:
- 泛型参数可以携带结构化信息——
Hono<Env, Schema, BasePath>三个泛型参数分别记录环境、路由 schema 和基础路径 - 链式方法的返回值可以累积类型——每次
.get()/.post()调用都返回一个新的 Hono 实例,新实例的 Schema 泛型包含了之前所有路由的信息 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 routeAppType 里包含了什么?至少包含这些信息:
/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<Env, {}, '/'>,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 | {}(空) | 否 | 否 |
typeof route | 完整路由树 | 是 | 是 |
一个容易混淆的点:app 和 route 在运行时指向同一个 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 routezValidator('json', createUserSchema) 做了两件事:
- 运行时:解析请求体,用 Zod schema 校验,校验失败返回 400
- 类型层面:把 schema 推导出的类型注入到后续处理函数的
c.req.valid('json')中
AppType 里记录的响应类型 { user: { id: number; name: string; email: string } } 就是处理函数 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 routeEnv 的 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<AppType>(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 route9.2 子路由挂载时丢失类型
// ❌ 直接挂载,没有接住返回值
app.route('/users', users)
export type AppType = typeof app // 不包含子路由
// ✅ 链式挂载
const route = app.route('/users', users)
export type AppType = typeof routeapp.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 users9.4 处理函数没有 return
// ❌ 没有 return
const route = app.get('/users', (c) => {
c.json({ users: [] })
})
// 响应类型变成 void,前端拿不到响应体的类型推导10. 调试类型推导问题
类型推导失败时,TypeScript 不会给你一个明确的错误信息。它只是默默地让 hc 客户端的属性变成 never 或者缺少方法。
排查方法:
- IDE 悬停:把鼠标悬停在
route变量上,看 Schema 是{}还是包含路由信息 - 分步赋值:把链式调用拆开,逐步检查每个中间步骤的类型,定位类型在哪一步丢失
- 检查子路由导出:确认子路由模块的默认导出是链式调用的返回值,不是 Schema 为空的 Hono 实例
- 检查返回值:确认每个处理函数都有
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)这个优化只在路由数量多、中间件层级深的时候才有意义。路由少的时候不需要提前优化。
延伸阅读
- 01-RPC 能力概述 — Hono RPC 的整体定位和设计思路
- 03-Hono Client 使用方式 — 前端怎样用 AppType 创建类型安全的客户端
- 04.02-动态路由参数 — 路由参数的推导机制和
:param的类型传导 - Hono RPC 官方文档 — AppType 和
hc的官方说明 - TypeScript typeof 操作符 —
typeof在类型层面的用法
总结
AppType 导出机制可以归纳为三个环节:
- 链式累积——每次
.get()/.post()/.route()调用都返回一个 Schema 泛型更丰富的新 Hono 实例,链式写法确保所有路由信息被累积到同一个变量中 - typeof 提取——用
typeof route把链式调用终值的类型结构原样提取为类型别名,这个过程不产生运行时代码 - 类型传导——中间件(zValidator、自定义
c.set())的类型信息沿着链路传导,最终体现在 AppType 的响应类型推导中
三个环节缺一不可。链式断开、返回值没接住、子路由没有链式定义,都会导致 AppType 里的路由信息不完整。碰到前端 hc 没有类型提示的情况,先沿着这三个环节逐一排查。
下一篇讲服务端类型推导——Hono 怎样从路由定义、中间件链路、Zod schema 一路把类型传导到处理函数的 c 参数上,以及自定义中间件如何正确声明类型以避免推导断裂。