客户端类型推导

要点

  • 03 介绍了 hc 的基本用法——创建客户端、调用接口、拿到类型安全的响应
  • 这篇往下走一层,看看 hc 怎样把服务端的 AppType 转换成客户端的调用接口
  • URL 路径到属性访问的映射规则背后,是 TypeScript 的模板字面量类型在做字符串操作
  • 请求参数(paramqueryjsonformheader)各有独立的类型来源
  • 响应类型推导依赖服务端 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 /usersclient.api.users.$get()路径段变属性,方法加 $ 前缀
POST /usersclient.api.users.$post()同一路径可挂载多个方法
GET /users/:idclient.api.users[':id'].$get()动态参数用方括号访问
GET /api/v1/postsclient.api.v1.posts.$get()多级路径逐级展开

三条核心规则:

  1. URL 中的 / 变成 .(属性访问)
  2. HTTP 方法名加 $ 前缀变成方法调用
  3. 动态路由参数(: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 类型是 &#123; [key: string]: string &#125;。类型精确度取决于服务端是否做了 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
})

formjson 互斥——同一次请求只能传其中一个。form 用于 multipart/form-data(文件上传等),json 用于 application/jsonhc 自动设置对应的 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'] }),
  })
}

queryFnmutationFn 的返回类型由 res.json() 自动推导,useQuerydata 类型也会跟着推导出来。手动给 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 })
})

hcform 的类型推导相对宽松——FormData 的 key-value 结构在 TypeScript 里不好精确表达。如果服务端用了 zod-form-data 扩展做校验,客户端推导会更精确。

7. 流式响应的客户端处理

服务端用 c.stream()c.sseStream() 返回流式响应时,客户端拿到的 Response.bodyReadableStream

// 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 类型检查可能变慢。缓解方式:

  1. 按模块拆分 AppType,前端只引入需要的子路由类型
  2. 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 编译直接报错。

延伸阅读

总结

hc 客户端的类型推导可以归纳为三层机制:

  1. 映射层AppType 中的路由定义通过模板字面量类型,映射成客户端对象的属性和方法签名
  2. 参数层paramqueryjsonform 各有独立的类型来源,精确度取决于服务端是否使用了 schema 校验
  3. 响应层c.json() 的返回类型沿 AppType 传导到客户端的 res.json(),支持多状态码的联合类型

实际使用时记住两个原则:服务端尽量用 Zod schema 做校验(客户端类型才会精确),前端和 React Query / SWR 配合时不要手动传泛型(让推导链路保持完整)。

WebSocket 和流式响应目前还需要手动处理部分内容,但 HTTP 请求这一侧的类型安全已经覆盖了绝大多数场景。下一篇看 monorepo 环境下,怎样组织前后端的类型共享和包结构。