端到端类型安全 tRPC

后端改了接口,前端立刻报错——不需要代码生成

🎯 学习目标

学完本章,你将能够:

  • 理解 tRPC 的类型推导原理和端到端类型安全的价值
  • 定义 tRPC Router 并实现 Procedure
  • 使用中间件实现认证和权限控制
  • 在前端 React 组件中类型安全地调用后端 API
  • 实现请求批处理、错误处理和 Subscriptions

你有没有想过

为什么后端改了一个字段名,前端要到上线后才发现接口报错?为什么前后端的类型定义总是不同步?

传统 REST 开发中,后端定义接口后,前端要手动维护类型。API 一变,类型就对不上了。

tRPC 解决了这个痛点:后端的路由类型自动传到前端,零代码生成,改了接口立刻有红色波浪线提醒你。

三种方案对比

tRPC 的定位和优势

特性RESTGraphQLtRPC
类型安全手动维护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 方法适用场景幂等性
queryGET获取列表、详情是
mutationPOST创建、更新、删除否
subscriptionWebSocket实时通知、聊天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,
      },
    };
  },
});
不要泄露内部细节给客户端。数据库连接字符串、内部路径、敏感数据都应该在 formatError 里过滤掉。

请求批处理 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 },
      });
    }),
});
不要在前端重新建校验规则。把 Zod Schema 提取到共享模块(如 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);  // 自动批处理
  });
DataLoader 是请求级别的。不要在 context 外面创建 DataLoader,否则会跨请求缓存导致数据错乱。

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。两者可以和平共处。

排错:类型不匹配

症状:前端调用 tRPC 方法时 TypeScript 报类型错误,但后端代码明明没问题。
原因:前端的 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 容器化——让应用在任何环境都能一致运行。

1 / X