客户端类型推导
要点
- 03 介绍了
hc的基本用法——创建客户端、调用接口、拿到类型安全的响应 - 这篇往下走一层,看看
hc怎样把服务端的AppType转换成客户端的调用接口 - URL 路径到属性访问的映射规则背后,是 TypeScript 的模板字面量类型在做字符串操作
- 请求参数(
param、query、json、form、header)各有独立的类型来源 - 响应类型推导依赖服务端
c.json()/c.text()的返回类型一路透传 - 与 React Query / SWR 配合时,泛型推导链路需要保持完整
- 文件上传、流式响应、WebSocket 各有特殊的类型处理方式
1. 从 AppType 到客户端接口
03 里展示过,服务端导出 AppType 之后,前端只需要一行代码就能创建类型安全的客户端:
// client.ts
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('http://localhost:8787')hc 函数拿到 AppType 之后,利用 TypeScript 的模板字面量类型,对路由信息做「镜像映射」:URL 路径拆成对象属性,HTTP 方法映射成 $get()、$post() 等方法,请求参数和响应类型绑定到方法签名上。整个过程在编译期完成,不产生运行时代码。
2. URL 路径到属性访问的映射规则
03 给过简化映射表,这里把规则展开说清楚。
| 服务端路由 | 客户端调用 | 说明 |
|---|---|---|
GET /users | client.api.users.$get() | 路径段变属性,方法加 $ 前缀 |
POST /users | client.api.users.$post() | 同一路径可挂载多个方法 |
GET /users/:id | client.api.users[':id'].$get() | 动态参数用方括号访问 |
GET /api/v1/posts | client.api.v1.posts.$get() | 多级路径逐级展开 |
三条核心规则:
- URL 中的
/变成.(属性访问) - HTTP 方法名加
$前缀变成方法调用 - 动态路由参数(
:id)用方括号属性访问
如果服务端用 app.route('/api', ...) 挂载了路由,客户端访问时也要带上 api 这一层:
// server.ts
const api = new Hono().get('/users', async (c) => c.json({ users: [] }))
const app = new Hono().route('/api', api)
export type AppType = typeof app// client.ts — /api/users → client.api.users.$get()
const res = await client.api.users.$get()app.route() 的前缀路径会变成客户端对象上的属性层级。路径少写一段,TypeScript 直接报类型错误。
3. 请求参数的类型来源
hc 客户端方法的参数对象包含多个字段,每个字段的类型来源不同。
param —— 来自路由路径
// server.ts
app.get('/users/:userId/posts/:postId', async (c) => {
return c.json(c.req.param())
})// client.ts — key 必须和路由参数名完全匹配
const res = await client.users[':userId'].posts[':postId'].$get({
param: { userId: '1', postId: '42' },
})类型来自路由注册时的路径字符串,TypeScript 通过模板字面量类型提取出参数名。
query —— 来自 Zod schema
服务端用了 @hono/zod-validator 时,query 类型由 schema 自动推导:
// server.ts
const querySchema = z.object({
page: z.string().optional(),
sort: z.enum(['asc', 'desc']).optional(),
})
app.get('/users', zValidator('query', querySchema), async (c) => {
return c.json({ users: [], ...c.req.valid('query') })
})// client.ts
const res = await client.users.$get({
query: { page: '1', sort: 'asc' },
// ❌ sort: 'random' → 只接受 'asc' | 'desc'
// ❌ extra: true → schema 里没有这个字段
})没用 Zod validator 时,query 类型是 { [key: string]: string }。类型精确度取决于服务端是否做了 schema 校验。
json / form —— 来自请求体 schema
// server.ts — Zod schema 同时约束了服务端校验和客户端类型
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
app.post('/users', zValidator('json', createUserSchema), async (c) => {
return c.json({ user: { id: 1, ...c.req.valid('json') } }, 201)
})// client.ts — json 字段自动推导,hc 自动设置 Content-Type 和 JSON.stringify
const res = await client.users.$post({
json: { name: 'Alice', email: '[email protected]' },
// ❌ name: 123 → 必须是 string
})form 和 json 互斥——同一次请求只能传其中一个。form 用于 multipart/form-data(文件上传等),json 用于 application/json,hc 自动设置对应的 Content-Type。
header —— 自定义请求头
// client.ts — header 接受 Record<string, string>,不做精确推导
const res = await client.users.$get({
header: { 'X-Request-ID': 'abc-123', 'Authorization': 'Bearer token' },
})header 类型相对宽松,这是目前已知的限制。
4. 响应类型推导链路
hc 的响应类型推导依赖一条完整链路:服务端 c.json() 的返回类型 → 路由定义的类型 → AppType → 客户端 res.json() 的返回类型。
// server.ts — c.json() 的类型会自动记录到路由定义中
app.get('/users/:id', async (c) => {
return c.json({ user: { id: 1, name: 'Alice', email: '[email protected]' } })
})// client.ts — data 的类型自动推导为 { user: { id: number; name: string; ... } }
const res = await client.users[':id'].$get({ param: { id: '1' } })
const data = await res.json()多状态码的响应
服务端根据不同状态码返回不同类型时,客户端拿到的是联合类型:
// server.ts
app.get('/users/:id', async (c) => {
const user = await findUser(c.req.param('id'))
if (!user) return c.json({ error: 'Not Found' }, 404)
return c.json({ user })
})// client.ts — data 类型:{ user: User } | { error: string }
const res = await client.users[':id'].$get({ param: { id: '1' } })
const data = await res.json()
if (res.ok) {
// 运行时判断后使用
console.log(data)
}TypeScript 对响应状态码的类型收窄目前不够精确——res.status 的类型通常是 number。更稳妥的做法是运行时判断 res.ok,或让服务端统一用一个 status 字段区分成功和失败。
5. 与 React Query / SWR 配合
hc 返回标准 Response 对象,和 React Query、SWR 配合没有障碍。关键原则:不要手动传泛型,让推导链路保持完整。
React Query
// hooks/use-users.ts
export function useUsers(page: number) {
return useQuery({
queryKey: ['users', page],
queryFn: async () => {
const res = await client.api.users.$get({ query: { page: String(page) } })
return res.json() // 返回类型自动推导
},
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: { name: string; email: string }) => {
const res = await client.api.users.$post({ json: input })
if (!res.ok) throw new Error('创建失败')
return res.json()
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})
}queryFn 和 mutationFn 的返回类型由 res.json() 自动推导,useQuery 的 data 类型也会跟着推导出来。手动给 useQuery 传泛型反而可能和服务端实际返回的类型不一致。
如果服务端导出了 Zod schema 对应的 TypeScript 类型,mutationFn 的参数可以直接复用:
// types.ts
export type CreateUserInput = z.infer<typeof createUserSchema>SWR
export function useUsers(page: number) {
return useSWR(['users', page], async () => {
const res = await client.api.users.$get({ query: { page: String(page) } })
return res.json() // 同样自动推导
})
}6. 文件上传与 FormData 处理
文件上传通过 form 参数传递 FormData 对象:
// client.ts — hc 自动设置 Content-Type: multipart/form-data
const formData = new FormData()
formData.append('title', 'My Post')
formData.append('file', fileInput.files[0], 'image.png')
const res = await client.posts.$post({ form: formData })// server.ts
app.post('/posts', async (c) => {
const body = await c.req.parseBody()
const file = body['file'] as File
return c.json({ filename: file.name })
})hc 对 form 的类型推导相对宽松——FormData 的 key-value 结构在 TypeScript 里不好精确表达。如果服务端用了 zod-form-data 扩展做校验,客户端推导会更精确。
7. 流式响应的客户端处理
服务端用 c.stream() 或 c.sseStream() 返回流式响应时,客户端拿到的 Response.body 是 ReadableStream。
// client.ts — 流式读取
const res = await client.api.stream.$get()
if (res.body) {
const reader = res.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
console.log(decoder.decode(value, { stream: true }))
}
}对于 SSE,客户端需要自己解析 data: ...\n\n 格式。hc 不会自动解析 SSE 事件,类型推导体现在 res 本身是类型安全的,流内容解析逻辑需要自己写:
// sse-parser.ts — 通用的 SSE 解析器
export async function* parseSSE(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data:')) yield line.slice(5).trim()
}
}
}
// 使用
const res = await client.api.events.$get()
if (res.body) {
for await (const data of parseSSE(res.body)) {
console.log(JSON.parse(data))
}
}8. WebSocket 的类型安全
hc 目前不直接提供 WebSocket 客户端封装。客户端使用原生 WebSocket API,类型安全通过共享消息类型定义来实现:
// shared-types.ts — 前后端共享的消息类型
export type WSMessage =
| { type: 'chat'; content: string; userId: string }
| { type: 'ping'; timestamp: number }// client.ts
const ws = new WebSocket('ws://localhost:8787/ws')
ws.onmessage = (event) => {
const msg = JSON.parse(event.data) as WSMessage
if (msg.type === 'chat') console.log(`${msg.userId}: ${msg.content}`)
}消息类型通过共享的 TypeScript 类型定义保证前后端一致。这不是 hc 的自动推导,但在 WebSocket 场景下是目前的务实做法。
9. 类型推导的边界与绕行方案
动态路由的类型丢失
运行时动态拼接的路由路径不会出现在 AppType 里。这种情况只能退回手动标注类型,或改回静态链式写法。
大联合类型的性能问题
路由数量多(几百条以上)时,AppType 会变成大联合类型,TypeScript 类型检查可能变慢。缓解方式:
- 按模块拆分
AppType,前端只引入需要的子路由类型 - 用
app.route()挂载时,单独导出子路由的类型
// users-type.ts — 单独导出子路由类型
const usersRoute = new Hono().get('/', handler1).post('/', handler2)
export type UsersAppType = typeof usersRoute
// client.ts — 只引入用户模块的类型
const usersClient = hc<UsersAppType>('http://localhost:8787')其他限制
- 中间件往
c.set()注入的变量不会传导到客户端 hc的类型推导只覆盖res.json()和res.text(),自定义格式需要手动标注- 类型安全完全依赖 TypeScript 编译期检查,运行时版本不一致时仍会出错
10. 一个完整的类型安全客户端配置
把前面的知识点整合到一个生产项目常见的配置中:
// lib/api-client.ts
import { hc } from 'hono/client'
import type { AppType } from '@/server'
export const client = hc<AppType>(
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8787'
)// hooks/use-api.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { client } from '@/lib/api-client'
export function useUser(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await client.api.users[':id'].$get({ param: { id: userId } })
if (res.status === 404) return null
return res.json()
},
enabled: !!userId,
})
}
export function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...input }: { id: string; name: string; email: string }) => {
const res = await client.api.users[':id'].$put({ param: { id }, json: input })
if (!res.ok) throw new Error('更新失败')
return res.json()
},
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['user', id] })
},
})
}// app/users/[id]/page.tsx
export default function UserPage({ params }: { params: { id: string } }) {
const { data, isLoading } = useUser(params.id)
const updateUser = useUpdateUser()
if (isLoading) return <div>加载中...</div>
if (!data) return <div>用户不存在</div>
return (
<div>
<h1>{data.user.name}</h1>
<p>{data.user.email}</p>
<button onClick={() => updateUser.mutate({
id: params.id, name: 'New Name', email: data.user.email,
})}>
更新用户名
</button>
</div>
)
}从服务端的 Zod schema 到前端的 useQuery 返回类型,整条链路都是类型安全的。服务端改了字段名,前端 TypeScript 编译直接报错。
延伸阅读
- 03-Hono Client使用方式 —
hc的基本用法和 URL 映射规则 - 04-类型安全的共享 —
AppType的导出和跨包共享策略 - Hono RPC 官方文档 — 官方对
hc类型推导的说明 - TypeScript Template Literal Types — 类型映射底层机制
总结
hc 客户端的类型推导可以归纳为三层机制:
- 映射层:
AppType中的路由定义通过模板字面量类型,映射成客户端对象的属性和方法签名 - 参数层:
param、query、json、form各有独立的类型来源,精确度取决于服务端是否使用了 schema 校验 - 响应层:
c.json()的返回类型沿AppType传导到客户端的res.json(),支持多状态码的联合类型
实际使用时记住两个原则:服务端尽量用 Zod schema 做校验(客户端类型才会精确),前端和 React Query / SWR 配合时不要手动传泛型(让推导链路保持完整)。
WebSocket 和流式响应目前还需要手动处理部分内容,但 HTTP 请求这一侧的类型安全已经覆盖了绝大多数场景。下一篇看 monorepo 环境下,怎样组织前后端的类型共享和包结构。