Node.js 生态的 tRPC:端到端类型安全 API 的原理、实践与架构选型
在传统的前后端分离项目中,后端接口与前端类型通常是两套独立维护的系统。
后端可能定义如下 REST API:
GET /api/users/:id
POST /api/users
前端则自行声明返回类型:
interface User {
id: string;
name: string;
email: string;
}
随着项目演进,经常会出现以下问题:
- 后端修改了字段,前端类型没有同步;
- 接口文档与真实实现不一致;
- 前端必须手动维护请求参数和响应类型;
- OpenAPI 客户端代码需要重新生成;
- 多个应用重复封装相同的 API Client;
- 开发阶段只有运行到对应页面时才发现字段错误。
tRPC 尝试解决的核心问题就是:
在客户端和服务端都使用 TypeScript 的前提下,能否直接从服务端实现中推导出完整的客户端 API 类型,而不再维护一份独立的接口描述文件?
tRPC 给出的方案是:服务端导出整个 API Router 的类型,客户端通过 TypeScript 类型推导获得每个接口的输入、输出和错误类型。
tRPC 官方将其定位为一种无需单独 Schema 和代码生成流程的端到端类型安全 API 方案。它主要面向全栈 TypeScript 项目,并且可以运行在独立 Node.js 服务、Express、Fastify、Next.js、AWS Lambda 和符合 Fetch API 的 Edge Runtime 中。
一、tRPC 到底是什么
tRPC 并不是一个完整的 Web Server,也不是类似 Express、Fastify、Hono 的 HTTP 框架。
它主要提供以下能力:
服务端 Procedure 定义
↓
生成 AppRouter TypeScript 类型
↓
客户端导入 AppRouter 类型
↓
获得类型安全的 API 调用客户端
一个典型调用可能是:
const user = await client.user.byId.query({
id: "user_1001",
});
从调用方式上看,它像是在调用一个本地 TypeScript 对象:
client.user.byId.query()
但底层仍然会通过 HTTP、SSE 或 WebSocket 发送网络请求。
因此,tRPC 可以理解为:
建立在 HTTP 等传输协议之上的、面向 TypeScript 全栈项目的类型安全 RPC 层。
客户端不会直接导入服务端运行代码,只导入服务端导出的类型:
import type { AppRouter } from "../server/router";
import type 会在 TypeScript 编译后被移除,因此不会把数据库连接、环境变量或服务端业务代码打包进浏览器。tRPC 官方文档也明确建议使用类型导入来共享 AppRouter。
二、tRPC 解决的核心问题
1. 消除重复的接口类型定义
传统 REST 项目可能同时存在:
后端 DTO
OpenAPI Schema
前端 TypeScript Interface
移动端模型
测试 Mock 类型
这些定义理论上描述的是同一套数据,但实际需要分别维护。
tRPC 中,服务端实现本身就是主要类型来源:
const userRouter = router({
byId: publicProcedure
.input(
z.object({
id: z.string(),
}),
)
.query(async ({ input }) => {
return {
id: input.id,
name: "Alice",
createdAt: new Date(),
};
}),
});
客户端能够自动推导:
const result = await client.user.byId.query({
id: "user_1001",
});
result.id;
result.name;
result.createdAt;
当后端删除、增加或修改字段时,客户端通常会在 TypeScript 编译阶段收到错误,而不是等到线上运行后才发现。
2. 不需要单独执行代码生成
gRPC 通常需要:
.proto 文件
↓
protoc 编译
↓
生成 TypeScript、Go、Java 等客户端
OpenAPI 项目通常需要:
OpenAPI 文档
↓
SDK Generator
↓
生成 API Client 和类型
tRPC 则直接利用 TypeScript 类型推导:
Server Router
↓ typeof
AppRouter Type
↓ 泛型参数
tRPC Client
代码修改后,TypeScript 语言服务器便可以更新客户端类型,不需要额外执行生成脚本。官方文档将“无需代码生成”和端到端静态类型安全列为 tRPC 的主要特征。
3. 提供完整的输入和输出提示
假设服务端定义:
const postRouter = router({
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(100),
content: z.string().min(1),
published: z.boolean().default(false),
}),
)
.mutation(async ({ input, ctx }) => {
return {
id: crypto.randomUUID(),
authorId: ctx.user.id,
...input,
};
}),
});
客户端调用时会自动提示:
await client.post.create.mutate({
title: "Understanding tRPC",
content: "Article content",
published: true,
});
下面这些调用会直接产生 TypeScript 错误:
await client.post.create.mutate({
title: 100,
});
await client.post.create.mutate({
name: "Wrong field",
});
这类类型错误不需要启动后端,也不需要真正发出 HTTP 请求。
三、tRPC 的核心架构
一套完整的 tRPC 应用通常包含以下组件:
┌─────────────────────────────────────┐
│ React / Next.js / Vue / Node Client │
└──────────────────┬──────────────────┘
│
tRPC Client
│
httpLink / httpBatchLink
SSE Link / WebSocket Link
│
┌──────────────────▼──────────────────┐
│ HTTP Server Adapter │
│ Express / Fastify / Next.js / Fetch │
└──────────────────┬──────────────────┘
│
Router
│
Procedures
│
Middleware / Context / Validator
│
Service / Repository / Database
tRPC 本身不负责数据库访问、HTTP Server 生命周期或业务领域建模。
它主要负责:
- API 路由组织;
- 输入输出类型推导;
- 参数校验集成;
- 上下文传递;
- 中间件执行;
- 客户端代理;
- 请求序列化;
- 请求批处理;
- 错误格式化;
- 查询、变更和订阅调用。
四、Router 与 Procedure
1. Router
Router 用于组织一组相关接口。
例如:
export const appRouter = router({
user: userRouter,
post: postRouter,
auth: authRouter,
});
最终形成的 API 树类似:
user.list
user.byId
user.update
post.list
post.create
post.delete
auth.login
auth.logout
Router 可以嵌套,因此适合按照业务领域拆分:
routers/
├── user.router.ts
├── post.router.ts
├── auth.router.ts
├── order.router.ts
└── index.ts
客户端最终可以按照相同结构调用:
client.user.list.query();
client.post.create.mutate();
client.order.cancel.mutate();
2. Procedure
Procedure 是 tRPC 中真正可调用的服务端函数。
tRPC 主要提供三种 Procedure:
query:读取数据;mutation:修改数据;subscription:持续推送实时事件。
官方文档将 Procedure 定义为构建后端 API 的基本函数,并通过 Router 组合多个 Procedure。
Query
Query 通常用于读取数据:
const userList = publicProcedure.query(async ({ ctx }) => {
return ctx.db.user.findMany();
});
客户端调用:
const users = await client.user.list.query();
Mutation
Mutation 通常用于产生副作用:
const userCreate = protectedProcedure
.input(
z.object({
name: z.string().min(1),
email: z.string().email(),
}),
)
.mutation(async ({ input, ctx }) => {
return ctx.db.user.create({
data: input,
});
});
客户端调用:
const user = await client.user.create.mutate({
name: "Alice",
email: "alice@example.com",
});
Subscription
Subscription 用于服务端持续推送消息:
const onMessage = protectedProcedure.subscription(async function* ({ ctx }) {
for await (const message of ctx.messageStream) {
yield message;
}
});
tRPC 11.x 支持通过 SSE 或 WebSocket 承载订阅。官方的 httpSubscriptionLink 使用 Server-Sent Events,而 wsLink 使用 WebSocket。
五、初始化 tRPC
通常需要建立一个统一的 tRPC 初始化文件。
// src/trpc/core.ts
import { initTRPC, TRPCError } from "@trpc/server";
export interface UserSession {
id: string;
role: "user" | "admin";
}
export interface Context {
user: UserSession | null;
requestId: string;
}
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
这里的 t 对象是整个 tRPC 后端的核心工厂。
通过它可以创建:
- Router;
- Procedure;
- Middleware;
- Caller;
- Error Formatter。
项目中通常不应该在每个 Router 文件中重复调用 initTRPC.create(),而应统一导出一套基础构造器。
六、Context:请求级上下文
Context 是每次请求对应的运行时上下文。
适合放入:
- 当前登录用户;
- 数据库客户端;
- Redis 客户端;
- Logger;
- Trace ID;
- Request ID;
- 请求 Headers;
- 权限信息;
- Feature Flag;
- 当前租户信息。
tRPC 会为每个请求创建 Context,各个 Procedure 和 Middleware 都可以访问它。官方文档也将认证信息列为 Context 的典型用途。
例如:
// src/trpc/context.ts
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import type { Context } from "./core";
export async function createContext(
options: CreateExpressContextOptions,
): Promise<Context> {
const authorization = options.req.headers.authorization;
const user = authorization
? await verifyAccessToken(authorization)
: null;
return {
user,
requestId:
options.req.headers["x-request-id"]?.toString() ??
crypto.randomUUID(),
};
}
在 Procedure 中访问:
const profile = protectedProcedure.query(({ ctx }) => {
return {
userId: ctx.user.id,
requestId: ctx.requestId,
};
});
Context 应保持为请求级数据容器,不应在其中塞入大量业务逻辑。
七、Middleware 与权限模型
tRPC Middleware 的作用类似 Express Middleware,但它围绕 Procedure 执行。
常见用途包括:
- 登录校验;
- 管理员权限校验;
- 租户隔离;
- 审计日志;
- 性能统计;
- 限流;
- Trace 信息注入;
- Feature Flag;
- 异常转换。
登录用户 Middleware
export const protectedProcedure = publicProcedure.use(
async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required",
});
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
});
},
);
后续使用 protectedProcedure 时,ctx.user 会被 TypeScript 推导为非空:
const me = protectedProcedure.query(({ ctx }) => {
return {
id: ctx.user.id,
role: ctx.user.role,
};
});
管理员 Middleware
export const adminProcedure = protectedProcedure.use(
async ({ ctx, next }) => {
if (ctx.user.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Administrator permission required",
});
}
return next();
},
);
tRPC 通过 procedure.use() 添加 Middleware,并要求 Middleware 调用和返回 next() 的结果。
八、运行时输入校验
TypeScript 类型只存在于编译阶段。
下面的类型:
type CreateUserInput = {
name: string;
email: string;
};
并不能阻止恶意客户端发送:
{
"name": 123,
"email": false
}
因此,tRPC 的端到端类型安全不能代替运行时校验。
一般使用 Zod、Valibot、ArkType 或其他兼容校验器。
import { z } from "zod";
const createUserInput = z.object({
name: z.string().trim().min(1).max(50),
email: z.string().email(),
age: z.number().int().min(13).max(120).optional(),
});
用于 Procedure:
const createUser = publicProcedure
.input(createUserInput)
.mutation(async ({ input }) => {
return userService.create(input);
});
此时 input 同时具备:
- 编译期 TypeScript 类型;
- 服务端运行时数据验证。
这是理解 tRPC 时非常重要的边界:
tRPC 不需要独立的接口 IDL,但网络输入依然需要运行时 Schema 校验。
九、完整的 Node.js + Express 示例
1. 安装依赖
npm install express @trpc/server @trpc/client zod
npm install -D typescript tsx @types/express
2. 项目结构
src/
├── trpc/
│ ├── core.ts
│ ├── context.ts
│ └── routers/
│ ├── user.router.ts
│ └── index.ts
├── services/
│ └── user.service.ts
├── client.ts
└── server.ts
3. tRPC 核心配置
// src/trpc/core.ts
import { initTRPC, TRPCError } from "@trpc/server";
export interface Context {
user: {
id: string;
role: "user" | "admin";
} | null;
}
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = publicProcedure.use(
async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
});
},
);
4. 创建 Router
// src/trpc/routers/user.router.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
protectedProcedure,
publicProcedure,
router,
} from "../core";
const users = new Map([
[
"user_1",
{
id: "user_1",
name: "Alice",
email: "alice@example.com",
},
],
]);
export const userRouter = router({
list: publicProcedure.query(() => {
return Array.from(users.values());
}),
byId: publicProcedure
.input(
z.object({
id: z.string().min(1),
}),
)
.query(({ input }) => {
const user = users.get(input.id);
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
updateName: protectedProcedure
.input(
z.object({
name: z.string().trim().min(1).max(50),
}),
)
.mutation(({ input, ctx }) => {
const user = users.get(ctx.user.id);
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
});
}
const updatedUser = {
...user,
name: input.name,
};
users.set(user.id, updatedUser);
return updatedUser;
}),
});
5. 根 Router
// src/trpc/routers/index.ts
import { router } from "../core";
import { userRouter } from "./user.router";
export const appRouter = router({
user: userRouter,
});
export type AppRouter = typeof appRouter;
AppRouter 是整个端到端类型系统的核心。
客户端只需要导入这个类型,而不需要复制每个接口的请求和响应类型。
6. 创建 Express 服务
// src/server.ts
import express from "express";
import * as trpcExpress from "@trpc/server/adapters/express";
import { appRouter } from "./trpc/routers";
const app = express();
app.use(express.json());
app.use(
"/trpc",
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext({ req }) {
const userId = req.headers["x-user-id"]?.toString();
return {
user: userId
? {
id: userId,
role: "user" as const,
}
: null,
};
},
onError({ error, path }) {
console.error("tRPC request failed", {
path,
code: error.code,
message: error.message,
});
},
}),
);
app.listen(3000, () => {
console.log("Server listening on http://localhost:3000");
});
Express Adapter 会把 tRPC Router 挂载为 Express Middleware。Query 通常可以通过 GET 发送,Mutation 通常通过 POST 发送。
十、Vanilla tRPC Client
tRPC 客户端不依赖 React。
它可以运行在:
- 浏览器;
- Node.js 服务;
- CLI 工具;
- Electron;
- React Native;
- 测试脚本;
- Worker Runtime。
// src/client.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "./trpc/routers";
const client = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "http://localhost:3000/trpc",
headers() {
return {
"x-user-id": "user_1",
};
},
}),
],
});
const users = await client.user.list.query();
const user = await client.user.byId.query({
id: "user_1",
});
const updatedUser = await client.user.updateName.mutate({
name: "Alice Updated",
});
客户端会自动获得:
- Router 路径提示;
- Procedure 名称提示;
- 输入参数类型;
- 返回结果类型;
- Query 与 Mutation 调用方式;
- 错误数据类型。
官方将 Vanilla Client 描述为一种像调用本地函数一样调用远程 Procedure 的客户端。
十一、Client Links
tRPC Client Link 可以理解为客户端请求管道。
它的职责与 Apollo Link、HTTP Middleware 有些类似。
典型结构:
Operation
↓
loggerLink
↓
retryLink
↓
splitLink
├── Query/Mutation → httpBatchLink
└── Subscription → httpSubscriptionLink
1. httpLink
每个 Procedure 调用发送一个独立 HTTP 请求:
import { createTRPCClient, httpLink } from "@trpc/client";
const client = createTRPCClient<AppRouter>({
links: [
httpLink({
url: "/trpc",
}),
],
});
httpLink 支持通过 HTTP GET 和 POST 请求调用 Procedure。
2. httpBatchLink
将同一时间窗口中的多个调用合并到一个 HTTP 请求:
const [users, posts, profile] = await Promise.all([
client.user.list.query(),
client.post.list.query(),
client.user.profile.query(),
]);
使用批处理后,底层可能合并为:
一个 HTTP 请求
├── user.list
├── post.list
└── user.profile
httpBatchLink 的官方定义就是将一组独立 tRPC Operation 合并到一个 HTTP 请求中。
批处理适合:
- 页面初始化时并发加载多个模块;
- 管理后台 Dashboard;
- 网络往返成本较高的环境;
- 大量细粒度 Query。
但也要注意:
- 一个慢 Query 可能影响整个批次完成时间;
- 请求体可能变大;
- 网关和服务器需要设置合理的 URL、Body 和参数长度限制;
- 日志、Trace 和限流需要能够识别批次内的不同 Procedure。
十二、React 与 TanStack Query 集成
tRPC 并不强制使用 React。
但在 React 生态中,它通常与 TanStack Query 配合,获得:
- Query Cache;
- 自动重新请求;
- Loading 和 Error 状态;
- Mutation;
- 乐观更新;
- 缓存失效;
- Prefetch;
- Infinite Query;
- Suspense;
- SSR Hydration。
tRPC 11.x 官方推荐新的 @trpc/tanstack-react-query 集成,而不是将经典 React Query Client 作为新项目的首选。新的集成更贴近 TanStack Query 原生的 queryOptions、mutationOptions 和 Query Key 模型。
安装:
npm install \
@trpc/server \
@trpc/client \
@trpc/tanstack-react-query \
@tanstack/react-query
创建 Context:
// src/trpc/react.ts
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "../server/router";
export const {
TRPCProvider,
useTRPC,
useTRPCClient,
} = createTRPCContext<AppRouter>();
组件中查询:
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "./trpc/react";
export function UserProfile() {
const trpc = useTRPC();
const userQuery = useQuery(
trpc.user.byId.queryOptions({
id: "user_1",
}),
);
if (userQuery.isPending) {
return <div>Loading...</div>;
}
if (userQuery.isError) {
return <div>{userQuery.error.message}</div>;
}
return <div>{userQuery.data.name}</div>;
}
Mutation:
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "./trpc/react";
export function UpdateUserName() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const updateName = useMutation(
trpc.user.updateName.mutationOptions({
onSuccess() {
return queryClient.invalidateQueries(
trpc.user.byId.queryFilter(),
);
},
}),
);
return (
<button
onClick={() =>
updateName.mutate({
name: "New Name",
})
}
>
Update
</button>
);
}
十三、Next.js 中的 tRPC
tRPC 与 Next.js 是常见组合,但 tRPC 并不属于 Next.js,也不依赖 Next.js。
当前官方文档同时支持:
- Next.js App Router;
- Next.js Pages Router。
对于新项目,官方推荐 App Router 方案,并使用:
- React Server Components;
- Fetch Adapter;
@trpc/tanstack-react-query;- 服务端 Prefetch;
- 客户端 Hydration;
- Suspense。
Pages Router 则继续使用 @trpc/next 和传统的 React Query 集成。
典型 App Router 架构如下:
Next.js App Router
├── Server Components
│ └── 服务端直接 Prefetch tRPC Query
├── Client Components
│ └── TanStack React Query
├── /api/trpc/[trpc]
│ └── Fetch Adapter
└── server/api/
├── routers/
├── context.ts
└── root.ts
在 Next.js 中使用 tRPC 的主要价值不是减少 HTTP 本身,而是让:
Server Component
Client Component
API Route
Server Procedure
共享同一套类型系统。
十四、错误处理
tRPC 使用 TRPCError 表达标准错误:
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
常见错误码包括:
BAD_REQUEST
UNAUTHORIZED
FORBIDDEN
NOT_FOUND
TIMEOUT
CONFLICT
PRECONDITION_FAILED
PAYLOAD_TOO_LARGE
INTERNAL_SERVER_ERROR
服务端:
const postById = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const post = await postService.findById(input.id);
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
return post;
});
客户端:
try {
await client.post.byId.query({
id: "missing",
});
} catch (error) {
console.error(error);
}
tRPC 会把 Procedure 中的错误转换为客户端能够处理的标准错误对象,也支持通过 Error Formatter 扩展错误数据。
生产项目不应把数据库异常、内部堆栈和敏感字段直接返回给客户端。
推荐统一转换:
try {
return await service.execute(input);
} catch (error) {
if (error instanceof DuplicateEmailError) {
throw new TRPCError({
code: "CONFLICT",
message: "Email already exists",
});
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unexpected server error",
cause: error,
});
}
十五、Data Transformer 与 SuperJSON
普通 JSON 无法完整表达:
Date;Map;Set;BigInt;undefined;- 特殊数值类型。
例如,服务端返回:
return {
createdAt: new Date(),
};
经过普通 JSON 后,客户端得到的是字符串:
{
createdAt: "2026-07-14T07:00:00.000Z"
}
tRPC 支持配置 Data Transformer。
常见选择是 SuperJSON:
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
const t = initTRPC.create({
transformer: superjson,
});
客户端也必须配置同一个 Transformer:
import { httpBatchLink } from "@trpc/client";
import superjson from "superjson";
httpBatchLink({
url: "/trpc",
transformer: superjson,
});
配置后,Date 等类型可以在客户端恢复为对应 JavaScript 对象。Transformer 需要在客户端和服务端保持一致。
不过,公共 API 应谨慎使用过度 JavaScript 化的数据类型。
例如:
Date
Map
Set
BigInt
对 TypeScript 客户端很方便,但对其他语言和传统 HTTP 工具并不友好。
十六、实时订阅:SSE 与 WebSocket
tRPC Subscription 适合:
- 通知推送;
- 任务执行进度;
- AI 流式输出;
- 在线状态;
- 简单聊天室;
- 后台日志;
- 数据更新通知。
SSE 方案
客户端:
import {
createTRPCClient,
httpBatchLink,
httpSubscriptionLink,
splitLink,
} from "@trpc/client";
const client = createTRPCClient<AppRouter>({
links: [
splitLink({
condition(operation) {
return operation.type === "subscription";
},
true: httpSubscriptionLink({
url: "/trpc",
}),
false: httpBatchLink({
url: "/trpc",
}),
}),
],
});
SSE 更适合主要由服务端向客户端单向推送的场景,部署和代理配置通常也比 WebSocket 简单。
WebSocket 方案
import {
createTRPCClient,
createWSClient,
wsLink,
} from "@trpc/client";
const wsClient = createWSClient({
url: "ws://localhost:3000",
});
const client = createTRPCClient<AppRouter>({
links: [
wsLink({
client: wsClient,
}),
],
});
WebSocket 更适合需要长期连接和双向消息通道的场景。
但对于高频游戏状态同步、音视频、超低延迟通信等需求,tRPC Subscription 并不一定是最合适的协议层。
十七、tRPC 支持哪些运行环境
tRPC 通过 Adapter 接入不同宿主。
官方文档覆盖的主要适配方式包括:
- Standalone Node.js HTTP Server;
- Express;
- Fastify;
- Next.js;
- AWS Lambda 与 API Gateway;
- Fetch API;
- Edge Runtime。
tRPC 自身不是 HTTP Server,Adapter 负责把 Router 与宿主框架连接起来。
Node.js Server
可以使用:
Standalone Adapter
Express Adapter
Fastify Adapter
如果没有现有 HTTP 框架,Standalone Adapter 是较简单的起点。
如果项目已经使用 Express 或 Fastify,则直接挂载对应 Adapter。
需要注意,当前 tRPC 11.x Fastify Adapter 官方要求 Fastify 5 或更高版本。
Edge Runtime
Fetch Adapter 使用标准的:
Request
Response
Headers
URL
fetch
因此可以运行在支持相应 Web API 的环境中,例如:
- Cloudflare Workers;
- Deno Deploy;
- Vercel Edge Runtime;
- Next.js Edge Runtime;
- 部分 Astro、Remix 和 SolidStart 环境。
十八、tRPC 与 Bun
tRPC 是 TypeScript 库,本身并不限定只能运行在 Node.js。
在 Bun 中可以通过以下方式使用:
- Bun 的 Node.js 兼容层;
- Fetch Adapter;
- Express/Fastify 兼容方案;
- Bun 原生 HTTP Server 与 Fetch Handler。
安装:
bun add @trpc/server @trpc/client zod
对于 Bun 新项目,优先考虑 Fetch Adapter,可以减少对 Node.js 专有接口的依赖。
架构可以是:
Bun Server
↓
Request / Response
↓
tRPC fetchRequestHandler
↓
AppRouter
客户端仍然正常使用:
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "http://localhost:3000/trpc",
}),
],
});
十九、tRPC 与 Hono
Hono 自身已经提供一套 RPC 类型推导能力。
Hono RPC 的核心方式是:
export type AppType = typeof app;
客户端通过:
const client = hc<AppType>("http://localhost:3000");
推导路由参数、请求输入和响应类型。
因此,在 Hono 项目中,需要先判断是否真的还需要 tRPC。
Hono RPC 更适合
- 希望保留标准 HTTP 路由;
- 接口结构接近 REST;
- 需要明确的 HTTP Method 和状态码;
- 需要直接返回原生
Response; - 希望轻量使用 Hono 内置 Client;
- 未来可能生成 OpenAPI;
- 外部系统需要直接调用接口。
例如:
app.get("/users/:id", handler);
app.post("/users", handler);
客户端:
client.users[":id"].$get({
param: {
id: "user_1",
},
});
tRPC 更适合
- API 主要服务于自有 TypeScript 客户端;
- 希望使用 Procedure 而不是 REST Resource;
- 需要 Router、Middleware 和 Context 模型;
- 深度使用 TanStack Query;
- 需要 Request Batching;
- 希望统一 Query、Mutation 和 Subscription;
- 不强调公开 REST 接口语义。
例如:
client.user.byId.query({
id: "user_1",
});
Hono 与 tRPC 能否结合
理论上可以。
Hono 使用标准 Request 和 Response,而 tRPC Fetch Adapter 也使用标准 Fetch API,因此可以把 tRPC Handler 挂载在 Hono 路由下。这属于基于两者 Web API 兼容性进行的组合推断。
但实际项目中,应避免为了“技术栈完整”而同时引入两套 RPC 抽象。
更合理的组合是:
Hono
├── /api/public/* 标准 REST / OpenAPI
├── /webhooks/* 第三方 Webhook
├── /health 健康检查
└── /trpc/* 内部 TypeScript Client API
也就是说:
- 对外接口使用 Hono REST;
- 自有 Web 应用使用 tRPC;
- Webhook 使用标准 HTTP;
- 实时房间使用 WebSocket 或 Colyseus。
二十、tRPC 与 REST 对比
| 维度 | tRPC | REST |
|---|---|---|
| 接口模型 | Procedure / Function | Resource |
| 类型共享 | TypeScript 类型推导 | OpenAPI 或手工定义 |
| 代码生成 | 通常不需要 | SDK 场景通常需要 |
| 浏览器调试 | 相对不直观 | 非常直观 |
| curl 调用 | 可以,但参数格式不如 REST 自然 | 非常方便 |
| 跨语言 | 较弱 | 很强 |
| 公共 API | 一般 | 非常适合 |
| TypeScript 全栈 | 非常适合 | 需要额外类型工具 |
| HTTP 语义 | 被 RPC 层抽象 | 明确使用 Method、Path、Status |
| 请求批处理 | 内置 Link | 通常自行实现 |
| 实时订阅 | 内置抽象 | 通常使用 SSE/WebSocket |
| CDN 缓存 | 不自然 | GET 资源缓存自然 |
tRPC 并不是“比 REST 更先进”。
它是针对特定约束进行了优化:
客户端和服务端都是 TypeScript
由同一个团队维护
可以共享类型依赖
接口不需要优先服务第三方
如果不满足这些条件,tRPC 的优势会明显下降。
二十一、tRPC 与 GraphQL 对比
| 维度 | tRPC | GraphQL |
|---|---|---|
| Schema | 从 TypeScript Router 推导 | GraphQL Schema |
| 客户端语言 | 主要是 TypeScript | 几乎所有语言 |
| 查询能力 | 服务端预定义 Procedure | 客户端选择字段 |
| 代码生成 | 通常不需要 | TypeScript 项目常用 Codegen |
| 缓存 | TanStack Query 等 | Apollo、Relay 等 |
| Federation | 不属于核心优势 | 生态较成熟 |
| 公共 API | 一般 | 较适合 |
| 学习成本 | TypeScript 团队较低 | 需要学习 GraphQL 体系 |
| N+1 风险 | 与普通 API 类似 | 需要 DataLoader 等治理 |
GraphQL 的核心价值是客户端可以声明所需字段:
query {
user(id: "1") {
id
name
posts {
title
}
}
}
tRPC 则由服务端定义调用边界:
client.user.detail.query({
id: "1",
});
tRPC 更简单,但没有 GraphQL 那样的通用查询语言。
二十二、tRPC 与 gRPC 对比
| 维度 | tRPC | gRPC |
|---|---|---|
| 类型来源 | TypeScript 类型推导 | Protocol Buffers |
| 代码生成 | 通常不需要 | 通常需要 |
| 传输 | HTTP、SSE、WebSocket | HTTP/2 |
| 序列化 | 通常 JSON | Protobuf 二进制 |
| 跨语言 | 较弱 | 很强 |
| 浏览器 | 直接面向 Web Client | 通常需要 gRPC-Web 等 |
| 内部微服务 | 中小型 TS 服务可用 | 多语言大型服务更合适 |
| 性能目标 | 开发体验优先 | 性能和契约优先 |
| API 演进 | TypeScript 依赖协调 | Protobuf 字段规则 |
| 流式通信 | Subscription | 四类 Streaming |
tRPC 适合:
React + Node.js
Next.js 全栈
TypeScript Monorepo
内部管理系统
中小型 SaaS
gRPC 适合:
Go + Java + C++ + Python 多语言服务
高吞吐内部 RPC
模型推理服务
大型微服务架构
严格跨团队契约
二十三、tRPC 的主要优势
1. 极佳的 TypeScript 开发体验
从服务端修改返回值,到前端获得类型错误,反馈链路非常短。
2. 不需要维护独立 IDL
没有 .proto、GraphQL Schema 或强制 OpenAPI 文件。
3. API Client 自动形成
Router 的类型天然构成客户端调用树。
4. 非常适合 Monorepo
在 pnpm Workspace、Turborepo 或 Nx 中,可以直接共享 API Type Package。
5. 与 React Query 集成紧密
适合以数据请求和缓存为中心的前端应用。
6. 容易渐进式接入
可以把 tRPC 挂载在现有 Express、Fastify 或 REST 服务中,不要求重写整个后端。官方文档也将其描述为可加入现有项目的轻量方案。
二十四、tRPC 的局限
1. 客户端和服务端耦合较强
tRPC 的优势来自共享 AppRouter 类型,但这也意味着客户端与服务端接口版本需要协调。
如果服务器先部署了破坏性修改,而客户端仍然运行旧版本,TypeScript 无法在运行时保护旧客户端。
因此仍然需要:
- 兼容性设计;
- 灰度发布;
- API 版本策略;
- 可选字段;
- 客户端最低版本控制。
2. 跨语言能力较弱
Python、Java、Go 或 C# 客户端无法直接获得与 TypeScript 一样的体验。
虽然可以手工调用 HTTP Endpoint,也可以尝试生成 OpenAPI,但这不是 tRPC 最自然的使用方式。
3. 不适合公共开放 API
第三方开发者通常更希望获得:
- 标准 REST;
- OpenAPI;
- curl 示例;
- Postman Collection;
- 多语言 SDK;
- 稳定的 URL 和 HTTP Method;
- 标准 OAuth 与 Webhook。
4. 网络运行时仍然可能出错
端到端类型安全不能消除:
- 超时;
- 断网;
- 服务器升级;
- 数据库失败;
- 旧客户端;
- 非法外部请求;
- 权限变化;
- 部分部署;
- 序列化失败。
5. 类型推导可能增加大型项目编译压力
当 Router 非常庞大、类型层级过深或大量 Procedure 相互组合时,TypeScript Language Server 可能出现推导变慢。
需要通过领域 Router、Package 边界和显式类型控制复杂度。
二十五、OpenAPI 支持
tRPC 当前提供 @trpc/openapi,可以从 tRPC Router 生成 OpenAPI 3.1 规范,用于:
- 生成其他语言的客户端;
- 使用 Postman 或 Insomnia 调试;
- 暴露标准化 API 文档;
- 接入需要 OpenAPI 的工具。
但截至当前官方文档,@trpc/openapi 仍标记为 Alpha,API 可能变化,而且 Subscription 暂未包含在生成规范中。
因此,对于重要的公共 API,不建议只依赖 tRPC OpenAPI Alpha 功能。
更稳妥的方案通常是:
内部 Web API:tRPC
外部 Public API:REST + OpenAPI
异步通知:Webhook
内部事件:Kafka / NATS
实时连接:SSE / WebSocket
二十六、推荐的 Monorepo 架构
apps/
├── web/
│ ├── src/
│ └── package.json
├── api/
│ ├── src/
│ │ ├── routers/
│ │ ├── context.ts
│ │ └── server.ts
│ └── package.json
└── worker/
└── src/
packages/
├── api-contract/
│ ├── src/
│ │ └── index.ts
│ └── package.json
├── database/
├── auth/
├── domain/
└── shared/
api-contract 只导出类型:
export type { AppRouter } from "@app/api/router";
前端:
import type { AppRouter } from "@app/api-contract";
应避免前端从 API Package 导入会执行的运行时代码。
推荐在 package.json 中使用明确的 exports:
{
"name": "@app/api-contract",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
二十七、业务代码不要直接堆在 Procedure 中
不推荐:
const createOrder = protectedProcedure
.input(createOrderInput)
.mutation(async ({ input, ctx }) => {
// 300 行库存、支付、优惠、订单业务逻辑
});
推荐:
const createOrder = protectedProcedure
.input(createOrderInput)
.mutation(({ input, ctx }) => {
return orderService.create({
userId: ctx.user.id,
input,
});
});
业务层:
export class OrderService {
async create(command: CreateOrderCommand) {
// 校验商品
// 锁定库存
// 计算价格
// 创建订单
// 发布事件
}
}
Procedure 应主要负责:
协议输入
身份和权限
调用业务服务
错误映射
协议输出
这样可以避免业务逻辑绑定在 tRPC 上,也方便未来增加:
- REST API;
- CLI;
- Queue Consumer;
- 定时任务;
- 管理后台任务;
- 单元测试。
二十八、不要在 Procedure 内部通过 createCaller 调另一个 Procedure
错误思路:
const orderCreate = protectedProcedure.mutation(async ({ ctx }) => {
const caller = appRouter.createCaller(ctx);
const user = await caller.user.profile();
return createOrder(user);
});
正确方式是提取公共业务函数:
async function getUserProfile(userId: string) {
return userRepository.findProfile(userId);
}
const userProfile = protectedProcedure.query(({ ctx }) => {
return getUserProfile(ctx.user.id);
});
const orderCreate = protectedProcedure.mutation(async ({ ctx }) => {
const user = await getUserProfile(ctx.user.id);
return orderService.create({
userId: user.id,
});
});
tRPC 官方明确不建议在一个 Procedure 内使用 createCaller 调用另一个 Procedure,因为这会重复执行 Context、Middleware 和输入校验等过程。
二十九、生产环境最佳实践
1. 所有网络输入都做运行时校验
不要把 TypeScript 类型当成安全边界。
2. Context 中统一注入认证信息
不要让每个 Procedure 重复解析 Token。
3. 用基础 Procedure 表达权限
例如:
publicProcedure
protectedProcedure
adminProcedure
tenantProcedure
internalProcedure
4. Procedure 保持轻量
把业务逻辑放入 Service 和 Domain 层。
5. 使用统一错误映射
不要直接把 ORM 或数据库错误返回客户端。
6. 使用 import type
避免客户端意外打包服务端模块。
7. 对 Batch 请求设置限制
限制:
- 最大批次操作数;
- 最大请求体;
- 最大参数长度;
- 单个 Procedure 超时;
- 并发数量。
8. 建立 Procedure 级可观测性
日志至少包含:
requestId
procedurePath
procedureType
duration
userId
tenantId
errorCode
9. 不要只依赖编译期兼容性
Web、桌面客户端和移动端可能不会与服务端同时升级。
10. 统一 tRPC Package 版本
@trpc/server、@trpc/client、React 集成包和 OpenAPI 扩展应尽量保持兼容版本。
三十、哪些项目最适合使用 tRPC
tRPC 特别适合以下项目:
1. TypeScript Monorepo
React / Next.js
Node.js / Bun
共享 Package
同一团队维护
2. 中小型 SaaS
例如:
- 项目管理;
- 数据分析后台;
- AI 工具;
- 内容管理平台;
- 内部运营系统;
- 开发者工具;
- 个人订阅服务。
3. 管理后台
管理后台通常:
- API 不对外开放;
- 页面数量多;
- CRUD 接口多;
- 前后端变化频繁;
- TypeScript 类型提示价值高。
4. Next.js 全栈应用
服务端、客户端和类型都在同一个代码库中。
5. 快速迭代的独立开发项目
tRPC 可以减少:
- API 文档同步;
- DTO 重复声明;
- Client SDK 封装;
- 前后端字段联调。
三十一、哪些项目不适合优先选择 tRPC
以下项目通常不应把 tRPC 作为唯一 API 方案:
1. 公共开放平台
需要为第三方提供长期稳定的跨语言 API。
2. 多语言微服务体系
服务端包含:
Java
Go
Rust
Python
C++
TypeScript
这类系统更适合 gRPC、REST 或消息协议。
3. 移动客户端长期版本共存
移动 App 无法像网页一样立即强制升级,需要更严格的向后兼容 API。
4. 高性能内部 RPC
如果重点是:
- 二进制序列化;
- 超低延迟;
- 多语言;
- HTTP/2 Streaming;
- 严格 IDL;
gRPC 通常更加合适。
5. 强 HTTP 语义和 CDN 缓存
例如:
公开内容 API
图片与资源元数据
搜索引擎抓取接口
长期缓存的 GET Resource
REST 更自然。
三十二、结合 Hono、Bun、React 的推荐方案
对于一个以 React、Bun、Hono 和 Cloudflare 为主要技术栈的项目,可以考虑两套方案。
方案一:只使用 Hono RPC
React
↓
Hono Client
↓
Hono Routes
↓
Service
↓
Database
适合:
- API 需要保持 REST 结构;
- 需要 OpenAPI;
- 希望部署到 Cloudflare Workers;
- 不需要复杂 Subscription;
- 希望技术栈尽量轻。
方案二:Hono + tRPC 分层
React Web
↓ tRPC
/trpc/*
↓
Hono / Fetch Runtime
↓
Domain Service
第三方 Client
↓ REST
/api/v1/*
↓
Hono
↓
Domain Service
适合:
- 自有前端追求最佳类型体验;
- 同时需要公共 REST API;
- 公共和内部接口复用同一业务层。
推荐目录:
src/
├── domain/
├── services/
├── repositories/
├── trpc/
│ ├── context.ts
│ ├── core.ts
│ └── routers/
├── rest/
│ └── routes/
└── index.ts
核心原则是:
tRPC 和 REST 只负责协议适配,真正的业务逻辑必须沉淀在共享的 Service 与 Domain 层。
三十三、最终选型建议
可以按照以下规则判断。
优先使用 tRPC
当项目满足:
前端是 TypeScript
后端也是 TypeScript
客户端和服务端由同一团队维护
代码可以共享类型
API 主要服务自有客户端
快速迭代比跨语言兼容更重要
优先使用 REST
当项目满足:
API 面向第三方
需要稳定 URL 和 HTTP 语义
需要 OpenAPI
需要 curl 和 Postman 直接调用
客户端语言不确定
需要 CDN 缓存
优先使用 gRPC
当项目满足:
内部微服务数量较多
服务语言不同
调用频率高
性能要求高
需要强 IDL 和代码生成
需要流式通信
优先使用 Hono RPC
当项目满足:
已经采用 Hono
希望保留 REST 风格
只需要轻量端到端类型推导
需要 Fetch API 和 Edge Runtime
不希望额外引入完整 RPC 层
总结
tRPC 的核心价值并不是取代 REST、GraphQL 或 gRPC,而是优化一个非常明确的场景:
客户端和服务端都使用 TypeScript,并由同一个产品团队维护的全栈应用。
在这个场景下,tRPC 可以显著减少:
- 重复类型;
- 接口文档漂移;
- 手工 Client 封装;
- 代码生成流程;
- 前后端字段联调成本。
它最适合:
TypeScript Monorepo
React / Next.js
Node.js / Bun
内部系统
中小型 SaaS
独立开发项目
但它不适合作为所有系统的统一协议。
一个成熟的系统可能同时采用:
tRPC:自有 TypeScript Web 客户端
REST:第三方开放 API 与 Webhook
gRPC:内部多语言微服务
Kafka / NATS:异步事件
SSE / WebSocket:实时推送
因此,选择 tRPC 的关键不在于它是否“比 REST 更好”,而在于项目是否满足它最有价值的前提:
端到端 TypeScript
类型可以共享
客户端与服务端协同发布
API 主要供自有应用使用
满足这些条件时,tRPC 是当前 TypeScript 全栈生态中开发体验非常出色的一种 API 方案。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。