端到端类型安全 tRPC
后端改了接口,前端立刻报错——不需要代码生成
🎯 学习目标
学完本章,你将能够:
- 理解 tRPC 的类型推导原理和端到端类型安全的价值
- 定义 tRPC Router 并实现 Procedure
- 使用中间件实现认证和权限控制
- 在前端 React 组件中类型安全地调用后端 API
- 实现请求批处理、错误处理和 Subscriptions
你有没有想过
为什么后端改了一个字段名,前端要到上线后才发现接口报错?为什么前后端的类型定义总是不同步?
传统 REST 开发中,后端定义接口后,前端要手动维护类型。API 一变,类型就对不上了。
tRPC 解决了这个痛点:后端的路由类型自动传到前端,零代码生成,改了接口立刻有红色波浪线提醒你。
三种方案对比
tRPC 的定位和优势
| 特性 | REST | GraphQL | tRPC |
|---|---|---|---|
| 类型安全 | 手动维护 | Schema 生成 | 自动推导 |
| 代码生成 | 需要 | 需要 | 不需要 |
| 学习成本 | 低 | 中等 | 低(纯 TS) |
| 请求批处理 | 不支持 | 支持 | 自动批处理 |
| 语言绑定 | 语言无关 | 语言无关 | TypeScript 专属 |
这个比喻只是为了帮你建立直观感受:tRPC 像是前后端之间的一根类型电话线,后端改了什么前端立刻知道。实际上它是利用 TypeScript 的泛型和条件类型实现的编译时检查。
类型推导原理
一根类型电话线连接前后端
// 后端:定义 Router
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const appRouter = t.router({
user: t.router({
list: t.procedure.query(() => getUsers()),
getById: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => getUserById(input.id)),
}),
});
// 关键:导出类型(不是值)
export type AppRouter = typeof appRouter;
前端自动推导
导入类型,所有调用都有检查
// 前端:导入后端类型
import type { AppRouter } from '../server/routers';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
const client = createTRPCProxyClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000/trpc' })],
});
// 所有调用都有完整的类型检查和自动补全!
const users = await client.user.list.query();
const user = await client.user.getById.query({ id: '1' });
tRPC 的核心优势是"零代码生成"。后端改了接口,前端会立即得到类型错误提示。
Part 1: 后端路由
用 Router 和 Procedure 组织你的 API。
初始化 tRPC
绑定 Context,导出构建器
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// 需要认证的 procedure
export const protectedProcedure = t.procedure.use(
t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, user: ctx.user } });
})
);
定义 Router
用 Zod 验证输入,用 query/mutation 分类
// server/routers/userRouter.ts
export const userRouter = router({
list: publicProcedure
.input(z.object({
page: z.number().default(1),
limit: z.number().max(100).default(10),
}))
.query(async ({ ctx, input }) => {
const [users, total] = await Promise.all([
ctx.db.user.findMany({
skip: (input.page - 1) * input.limit,
take: input.limit,
}),
ctx.db.user.count(),
]);
return { users, pagination: { page: input.page, total } };
}),
register: publicProcedure
.input(z.object({
name: z.string().min(1, '姓名不能为空'),
email: z.string().email('邮箱格式不正确'),
password: z.string().min(8, '密码至少8位'),
}))
.mutation(async ({ ctx, input }) => {
const hashed = await bcrypt.hash(input.password, 12);
return ctx.db.user.create({ data: { ...input, password: hashed } });
}),
});
Part 2: 中间件与认证
在 procedure 执行前运行校验逻辑,鉴权、日志、性能监控一条龙。
中间件链
洋葱模型:每个 procedure 都经过层层拦截
tRPC 的中间件和 Express/Koa 的中间件概念类似:在到达最终 handler 之前(或之后)执行逻辑。
这个比喻只是为了帮你建立直观感受。实际上 tRPC 中间件是一个纯函数,接收 ctx 和 next,返回结果,完全在 TypeScript 类型系统内运行。
鉴权
检查 Token / Session,未登录直接抛 UNAUTHORIZED
日志
记录每个 procedure 的入参、耗时、调用者
性能监控
统计 P99 耗时,上报 Sentry / OpenTelemetry
错误转换
把数据库/第三方异常统一映射为 TRPCError
// 中间件的本质:一个函数,包装 next()
const myMiddleware = t.middleware(async ({ ctx, next, path, type }) => {
const start = Date.now();
const result = await next(); // 调用下一个中间件或 handler
console.log(`[${type}] ${path} took ${Date.now() - start}ms`);
return result;
});
完整中间件示例
日志 + 认证组合实战
// server/middlewares.ts
import { initTRPC, TRPCError } from '@trpc/server';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
// 1. 日志中间件:记录所有请求
const loggerMiddleware = t.middleware(async ({ ctx, next, path, type }) => {
const start = performance.now();
const result = await next();
const duration = (performance.now() - start).toFixed(2);
console.log(
`[${new Date().toISOString()}] ${type.toUpperCase()} ${path} ` +
`— user=${ctx.user?.id ?? 'anonymous'} — ${duration}ms`
);
return result;
});
// 2. 认证中间件:强制要求登录
const authMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '请先登录后再访问此资源',
});
}
return next({ ctx: { user: ctx.user } });
});
// 3. 组合成 protectedProcedure
export const publicProcedure = t.procedure.use(loggerMiddleware);
export const protectedProcedure = t.procedure
.use(loggerMiddleware)
.use(authMiddleware);
中间件的执行顺序与 .use() 的调用顺序一致:logger → auth → handler。想在 auth 之前做额外校验?再加一个 .use() 即可。
Procedure 类型
三种操作,对应不同语义
| 类型 | HTTP 方法 | 适用场景 | 幂等性 |
|---|---|---|---|
| query | GET | 获取列表、详情 | 是 |
| mutation | POST | 创建、更新、删除 | 否 |
| subscription | WebSocket | 实时通知、聊天 | N/A |
router.resource.action。过深嵌套让代码难以维护。
Part 2: 前端调用
React Query 集成,让数据获取变得优雅。
React Query 集成
Provider 配置一次,全局可用
// client/trpc.ts
import { createTRPCReact, httpBatchLink } from '@trpc/react-query';
import type { AppRouter } from '../server/routers';
export const trpc = createTRPCReact<AppRouter>();
// providers/TRPCProvider.tsx
export function TRPCProvider({ children }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [httpBatchLink({ url: '/api/trpc' })],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
组件中使用
自动补全、参数检查、缓存管理
// components/UserList.tsx
export function UserList() {
const [page, setPage] = useState(1);
const { data, isLoading } = trpc.user.list.useQuery({ page, limit: 10 });
if (isLoading) return <div>加载中...</div>;
return (
<div>
{data?.users.map(user => (
<div key={user.id}>{user.name} - {user.email}</div>
))}
<button disabled={page <= 1}
onClick={() => setPage(p => p - 1)}>上一页</button>
<button onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
);
}
// 创建用户
const create = trpc.user.register.useMutation({
onSuccess() {
trpc.useUtils().user.list.invalidate(); // 刷新列表
},
});
错误处理
TRPCError + formatError:把异常变成客户端可理解的信息
tRPC 内置 TRPCError,每个错误码对应一个 HTTP 状态码。正确分类错误,前端才能做出合适的响应。
import { TRPCError } from '@trpc/server';
// 在 procedure 中抛出
const updateUser = protectedProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
try {
return await ctx.db.user.update({
where: { id: ctx.user.id },
data: { name: input.name },
});
} catch (err) {
if (err.code === 'P2002') {
throw new TRPCError({
code: 'CONFLICT',
message: '该用户名已被使用',
});
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '服务器内部错误',
});
}
});
BAD_REQUEST (400)
输入校验失败、参数格式不对
UNAUTHORIZED (401)
未提供 Token 或 Token 过期
FORBIDDEN (403)
已登录但权限不足
INTERNAL_SERVER_ERROR (500)
服务端bug、数据库异常
formatError 统一格式化
避免生产环境泄露堆栈信息
// server/trpc.ts
const t = initTRPC.context<Context>().create({
formatError({ error, shape }) {
if (
error.code === 'INTERNAL_SERVER_ERROR' &&
process.env.NODE_ENV === 'production'
) {
return {
...shape,
message: '服务器开小差了,请稍后再试',
};
}
return {
...shape,
data: {
...shape.data,
stack: process.env.NODE_ENV === 'development'
? error.stack
: undefined,
},
};
},
});
请求批处理 Batching
同帧多个请求自动合并为一个 HTTP 请求
tRPC 默认使用 httpBatchLink,在同一帧触发的多个 procedure 调用会自动合并为一个 POST 请求。
// 同一个组件内,这些调用会被合并为 1 次 HTTP 请求
function Dashboard() {
const user = trpc.user.get.useQuery();
const orders = trpc.order.list.useQuery();
const settings = trpc.settings.get.useQuery();
// 实际只发 1 个请求:POST /api/trpc
// body: [{ method: 'user.get', ... }, { method: 'order.list', ... }, ...]
}
REST 需要 N 次请求
GET /user + GET /orders + GET /settings = 3 次往返
tRPC 只需 1 次请求
httpBatchLink 批量打包,减少网络开销
Subscriptions 实时通信
WebSocket 原生支持,无需额外框架
tRPC 的 subscription 类型让你可以直接在后端定义实时事件,前端用 useSubscription 监听。
// server/routers/notificationRouter.ts
export const notificationRouter = router({
onNewMessage: protectedProcedure
.subscription(({ ctx }) => {
return observable<Message>((emit) => {
const handler = (msg: Message) => emit.next(msg);
ctx.websocket.on(`user:${ctx.user.id}:message`, handler);
return () => ctx.websocket.off(`user:${ctx.user.id}:message`, handler);
});
}),
});
// 前端:实时监听新消息
function ChatRoom() {
trpc.notification.onNewMessage.useSubscription(undefined, {
onData(message) {
setMessages(prev => [...prev, message]);
},
onError(err) {
console.error('连接断开:', err);
},
});
}
这个比喻只是为了帮你建立直观感受:subscription 就像给后端装了一个"事件对讲机",前端按个频道就能听到。实际上它是通过 WebSocket 长连接维持双向通信,底层可以用 ws 库或 Socket.IO 实现。
输入验证最佳实践
Zod Schema 不止是类型校验
在 tRPC 中,input() 接受的 Zod Schema 会在运行时解析并校验数据,同时自动推导出 TypeScript 类型——一份定义,双重保障。
import { z } from 'zod';
// 用 zod 定义复杂的校验规则
const CreatePostInput = z.object({
title: z.string()
.min(5, '标题至少5个字符')
.max(100, '标题不超过100个字符'),
content: z.string().min(1),
tags: z.array(z.string()).max(5, '最多5个标签'),
published: z.boolean().default(false),
publishedAt: z.date().optional(),
category: z.enum(['tech', 'life', 'tutorial']),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export const postRouter = router({
create: protectedProcedure
.input(CreatePostInput)
.mutation(async ({ ctx, input }) => {
// input 已经通过 Zod 校验,并且类型完美
return ctx.db.post.create({
data: { ...input, authorId: ctx.user.id },
});
}),
});
server/schemas),前后端共用同一份定义,避免校验逻辑不一致。
输出类型验证
服务端返回什么,前端就拿不到更多
除了 input,tRPC 还支持 output() 校验返回值。防止数据库泄露多余字段给前端(如 password 字段)。
const UserOutput = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
avatar: z.string().nullable(),
createdAt: z.date(),
});
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.output(UserOutput) // ← 校验输出
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
});
// Zod 会校验 user 对象,如果包含 password 等未定义字段会报错
return user;
}),
});
output 校验还能帮你发现"数据库返回了意外字段"这种隐蔽 bug。建议对外暴露的接口都加上 output。
测试 tRPC Procedures
使用 mock context 进行单元测试
tRPC 是纯函数,不需要启动服务器就能测试。传入 mock context 即可。
// server/__tests__/userRouter.test.ts
import { appRouter } from '../routers';
describe('userRouter.list', () => {
it('应该返回分页用户列表', async () => {
const ctx = {
db: {
user: {
findMany: jest.fn().mockResolvedValue([
{ id: '1', name: '张三', email: 'a@b.com' },
]),
count: jest.fn().mockResolvedValue(1),
},
},
user: { id: 'admin', role: 'ADMIN' },
};
const caller = appRouter.createCaller(ctx);
const result = await caller.user.list({ page: 1, limit: 10 });
expect(result.users).toHaveLength(1);
expect(result.pagination.total).toBe(1);
expect(ctx.db.user.findMany).toHaveBeenCalledWith({
skip: 0, take: 10,
});
});
});
用 appRouter.createCaller(ctx) 创建 caller,直接调用 procedure,无需 HTTP 请求。比 supertest 快 10 倍以上。
性能优化
dataLoader 模式解决 N+1 查询
tRPC 的 context 在每个请求中创建一次,你可以在其中初始化 DataLoader 来批量查询。
// server/context.ts
import DataLoader from 'dataloader';
export function createContext({ req, res }: FetchCreateContextFnOptions) {
return {
db,
// DataLoader 自动合并同请求内的多次查询
userLoader: new DataLoader<string, User>(async (ids) => {
const users = await db.user.findMany({
where: { id: { in: [...ids] } },
});
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id)!);
}),
};
}
// 在 procedure 中使用
const getUserById = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.userLoader.load(input.id); // 自动批处理
});
Part 3: 实战场景
tRPC 不是银弹。了解它擅长什么、不擅长什么,才能做出正确的技术选型。
何时选择,何时放弃
技术选型不是信仰问题,是权衡问题
| tRPC 最合适 | tRPC 不合适 |
|---|---|
| 全 TypeScript 技术栈 | 团队主要用 Python / Java / Go |
| 中小型内部产品 | 需要对外提供公开 API |
| 前后端在同一仓库 (monorepo) | 前端是移动端(React Native 支持较弱) |
| 快速迭代、类型安全优先 | 需要复杂的 API 版本管理 |
| 团队熟悉 React + Node | 已有成熟 GraphQL 基础设施 |
一个简单判断标准:如果你会犹豫"要不要用 tRPC",大概率说明你当前项目的约束更适合 REST 或 GraphQL。tRPC 让人毫不犹豫选择时,通常是因为你的技术栈是全 TS + 内部产品。
✅ 选择 tRPC
全栈 TS · Monorepo · 内部工具 · 快速迭代 · 前端驱动开发
❌ 选择 REST/GraphQL
多语言团队 · 公开 API · 移动端为主 · 需要持久化缓存
Express 集成
tRPC 可以和 REST 共存
// server/index.ts
import express from 'express';
import * as trpcExpress from '@trpc/server/adapters/express';
const app = express();
app.use(cors());
app.use(express.json());
// tRPC 路由
app.use('/trpc', trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
}));
// REST 路由(共存)
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.listen(3000);
核心业务用 tRPC 获得类型安全,第三方集成(支付回调、Webhook)用 REST。两者可以和平共处。
排错:类型不匹配
原因:前端的
AppRouter 类型导入路径错误,或者前后端不在同一个 TypeScript 项目中。
修复:确保
import type { AppRouter } 指向后端正确的导出。在 monorepo 中使用 TypeScript Project References。
试一试
练习一
任务:创建一个 tRPC Router,包含 todo 子路由,有 list(query)和 add(mutation)两个 procedure。
// 提示:用内存数组存储 todo
// 你的代码:
参考答案
const todos: { id: number; text: string; done: boolean }[] = [];
const todoRouter = router({
list: publicProcedure.query(() => todos),
add: publicProcedure
.input(z.object({ text: z.string().min(1) }))
.mutation(({ input }) => {
const todo = { id: Date.now(), text: input.text, done: false };
todos.push(todo);
return todo;
}),
});
练习二:添加认证中间件
练习二
任务:创建一个 protectedProcedure,要求请求头中包含有效的 Bearer Token。如果 Token 无效或缺失,抛出自定义的 UNAUTHORIZED 错误。
// 你的代码:
// 1. 从 ctx.headers 读取 Authorization
// 2. 验证 Bearer Token(可用假函数 verifyToken)
// 3. 无效时 throw new TRPCError({ code: 'UNAUTHORIZED' })
参考答案
import { initTRPC, TRPCError } from '@trpc/server';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
const isAuthenticated = t.middleware(({ ctx, next }) => {
const authHeader = ctx.req.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '缺少 Authorization 头,请提供 Bearer Token',
});
}
const token = authHeader.replace('Bearer ', '');
const user = verifyToken(token); // 你的验证逻辑
if (!user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Token 无效或已过期',
});
}
return next({ ctx: { user } });
});
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthenticated);
在 React 端,你需要把 Token 加到请求里:
// client/trpc.ts
import { createTRPCReact, httpBatchLink } from '@trpc/react-query';
const trpc = createTRPCReact<AppRouter>();
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
},
}),
],
});
📝 要点总结
本章核心知识点
类型推导
后端定义 Router,前端自动获得完整类型,重构不再怕漏改
Procedure
query/mutation/subscription 三种 Procedure 覆盖所有交互模式
中间件链
认证、日志、权限检查通过中间件组合,保持 Procedure 简洁
React Query 集成
tRPC + TanStack Query = 类型安全 + 缓存 + 自动重试
下一章
tRPC 让前后端类型永远同步。下一章我们学习 Docker 容器化——让应用在任何环境都能一致运行。