端到端类型安全(End-to-End Type Safety)的核心诉求很简单:服务端定义的每个接口的入参和返回值类型,能够在前端调用时被精确感知,任何一侧的改动都会在编译阶段反馈到另一侧。TRPC和Hono RPC是当前实现这一目标的两个主流方案,前者是专为类型安全设计的全功能RPC框架,后者则在轻量Web框架的基础上通过泛型技巧实现了类似的客户端调用体验。这篇文章会先拆解类型传递的原理,再分别给出两种框架下的封装实践。

一、端到端类型安全的底层原理:泛型推断链路是如何工作的
无论是TRPC还是Hono RPC,类型安全的核心都依赖TypeScript的条件类型和泛型推断。服务端的路由定义本质上是一个嵌套的对象类型,框架通过递归的条件类型把这个对象结构“翻译”成客户端可调用的函数签名。理解这一点非常重要,因为后续所有的封装技巧,包括保持类型不丢失、抽离公共逻辑,都建立在“类型链路不能断”的前提上。
以Hono为例,当你写下app.post('/user', handler)时,泛型参数中已经记录了路径字符串、输入的Validator类型和输出的ReturnType。Hono导出的ClientRequest类型会在客户端通过hc<AppType>实例化时,把服务端的Schema类型完整地带过来。TRPC的机制类似,createTRPCClient返回的Proxy对象在属性访问时触发类型查找,每个procedure的input和output都来自服务端的resolver函数签名推断。
这里有一个常见的坑值得提前说明:任何在中间层对fetch或axios做包装时,如果直接用any或者丢失泛型的写法,类型链路会立刻断裂。比如很多人会写一个统一的请求拦截器:
async function request(url: string, init?: RequestInit): Promise<any> {
return fetch(url, init);
}返回Promise<any>之后,无论服务端类型定义得多精确,客户端拿到的都是any,等于白做。正确做法是让泛型贯穿整个包装函数,本章后面的封装示例都会遵循这一原则。
二、TRPC客户端封装:createTRPCNext与自定义链接
TRPC的封装重点在于Link机制。默认的httpBatchLink已经能满足大部分场景,但实际项目里往往需要附加鉴权头、错误上报、超时控制。先看服务端定义:
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return { id: input.id, name: '张三', role: 'admin' as const };
}),
createPost: t.procedure
.input(z.object({ title: z.string().min(1), content: z.string() }))
.mutation(async ({ input }) => {
return { postId: 'p_001', ...input };
}),
});
export type AppRouter = typeof appRouter;关键点在最后一行,export type AppRouter = typeof appRouter是类型跨端共享的桥梁,前端通过类型导入(import type)拿到它,不会把服务端代码打包进客户端。接下来是客户端封装,包括自定义fetch与错误处理:
import { createTRPCReact, httpBatchLink } from '@trpc/react-query';
import type { AppRouter } from '../server/router';
export const trpc = createTRPCReact<AppRouter>();
export function createClient(tokenGetter: () => string | null) {
return trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
const token = tokenGetter();
return token ? { Authorization: `Bearer ${token}` } : {};
},
fetch(url, options) {
// 统一超时控制,泛型保持不丢失
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
return fetch(url, { ...options, signal: controller.signal })
.finally(() => clearTimeout(timer));
},
}),
],
});
}注意headers写成函数形式,这样每次请求都能取到最新的token,而不是初始化时固化。在组件中的调用就非常直观了,输入输出全部有精确类型:
function UserCard({ id }: { id: string }) {
const query = trpc.getUser.useQuery({ id });
if (query.isPending) return <p>加载中</p>;
// data的类型自动推断为 { id: string; name: string; role: 'admin' }
return <span>{query.data.name}</span>;
}服务端把role写成'admin' as const后,客户端拿到的就是字面量类型而不是宽泛的string,可以做精确的分支判断。这种细节上的类型收益,是端到端方案相对手写接口声明最大的优势。
三、Hono RPC封装:hc客户端与公共请求层
Hono的RPC方案更轻量,不需要额外的query层,直接通过hc函数生成类型化客户端。服务端需要注意,每个路由的输出必须显式标注,否则推断出的类型会是void或过窄:
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const userRoute = new Hono()
.get('/:id',
zValidator('param', z.object({ id: z.string() })),
async (c): Promise<{ id: string; name: string }> => {
const { id } = c.req.valid('param');
return c.json({ id, name: '李四' });
})
.post('/',
zValidator('json', z.object({ title: z.string() })),
async (c): Promise<Response> => {
const data = c.req.valid('json');
return c.json({ ok: true, title: data.title }, 201);
});
export type UserRoute = typeof userRoute;客户端封装时,最大的坑是中间包装层吞掉类型。正确的做法是利用ClientRequest的泛型签名,把自定义的fetch逻辑透传:
import { hc } from 'hono/client';
import type { UserRoute } from '../server/routes/user';
export function createHonoClient(getToken: () => string | null) {
return hc<UserRoute>('/api', {
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
const token = getToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
return fetch(input, { ...init, headers });
},
});
}
// 使用:client.user[':id'].$get({ param: { id: 'u1' } })
// 返回类型是 Promise<Response>,需要手动调用 json() 解析Hono的$get返回的是Response,需要再调res.json(),此时解析结果的类型依赖服务端c.json()的推断。为了减少重复代码,可以封装一个统一的响应处理函数,用泛型保持类型:
async function unwrap<T>(res: Response): Promise<T> {
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new ApiError(res.status, (err as { message?: string }).message);
}
return res.json() as Promise<T>;
}
// 业务层调用
const res = await client.user[':id'].$get({ param: { id: 'u1' } });
const user = await unwrap<{ id: string; name: string }>(res);四、两种方案对比与选型建议
TRPC的优势在于生态完整,与React Query深度绑定后,缓存、重试、失效管理开箱即用,输入校验用Zod一份Schema同时约束运行时和编译期,开发体验非常顺滑。代价是强绑定TypeScript全栈,如果后端需要同时暴露给非TS客户端(比如移动端原生应用),RPC类型的价值会打折扣。
Hono RPC的优势是轻和灵活,Hono本身跑在各类运行时上(Node、Bun、Cloudflare Workers、Deno),RPC只是薄薄一层类型糖,服务端仍然是标准的REST接口,非TS客户端也能直接消费。缺点是query缓存、加载状态管理需要自己搭,响应解析也要多写一步unwrap。
选型上可以这样判断:纯TS全栈、交互密集的中后台系统,TRPC配合React Query收益最高;边缘计算部署、接口需要同时服务多端、或者项目本身已经在用Hono,那就直接用Hono RPC,没必要再引入一整套TRPC。两者也可以共存,核心业务走TRPC,公开接口用Hono。
五、封装时的三个通用注意事项
第一,类型导入必须用import type,否则服务端代码会被打包进客户端bundle,体积和安全性都有问题。第二,monorepo场景下确保前后端引用的是同一份TypeScript版本和tsconfig的paths配置,版本不一致时泛型推断可能出现诡异的兼容性错误。第三,任何对请求层的二次封装都要保持泛型透传,凡是出现Promise<any>的地方就是类型链路断裂的位置,定期用tsc --noEmit配合noImplicitAny检查可以有效拦截这类退化。
TypeScriptTRPCHono RPC修改时间:2026-09-13 08:10:36