Chapter 04

tRPC:端到端类型安全 API

无需手写接口文档,无需代码生成——用 TypeScript 类型系统连接前后端

REST API 的痛点

传统 REST API 开发中,前后端之间存在一道类型鸿沟:

// 后端定义(TypeScript)
interface User {
  id: string;
  name: string;
  email: string;
}

app.get('/users/:id', async (req, res) => {
  const user: User = await db.findUser(req.params.id);
  res.json(user);
});

// 前端调用(经典困境)
const res = await fetch('/api/users/123');
const user = await res.json(); // 类型:any 😱

// 开发者需要手动维护类型声明、OpenAPI schema、
// 文档同步……后端改了字段名,前端完全不知道

tRPC 的核心思想:如果前后端都用 TypeScript,那么只需导出后端的路由类型定义,前端就能自动获得完整的类型推断——无需 Swagger、无需 GraphQL schema、无需代码生成。这就是"端到端类型安全"。

核心概念

安装与配置

bun add @trpc/server zod
bun add -d @trpc/client  # 前端用

服务端:创建 tRPC 实例

// src/trpc/init.ts — tRPC 初始化
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

// 1. 定义 Context 类型
type Context = {
  db: Database;           // Prisma 客户端等
  user: User | null;      // 当前登录用户
  requestId: string;
};

// 2. 初始化 tRPC(每个应用只做一次)
const t = initTRPC.context<Context>().create({
  // 错误格式化(生产环境隐藏内部错误)
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError
          ? error.cause.flatten()
          : null,
      },
    };
  },
});

// 3. 导出构建块
export const router = t.router;
export const publicProcedure = t.procedure;

// 4. 需要登录的 Procedure(通过中间件)
const isAuthenticated = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: { ...ctx, user: ctx.user }, // 类型收窄:user 不再为 null
  });
});
export const protectedProcedure = t.procedure.use(isAuthenticated);

定义路由和 Procedure

// src/trpc/routers/users.ts
import { router, publicProcedure, protectedProcedure } from '../init';
import { z } from 'zod';
import { TRPCError } from '@trpc/server';

export const usersRouter = router({

  // Query — 获取用户列表
  list: publicProcedure
    .input(z.object({
      page: z.number().default(1),
      limit: z.number().max(100).default(20),
    }))
    .query(async ({ ctx, input }) => {
      const { page, limit } = input;
      return ctx.db.user.findMany({
        skip: (page - 1) * limit,
        take: limit,
        orderBy: { createdAt: 'desc' },
      });
    }),

  // Query — 根据 ID 获取用户
  byId: publicProcedure
    .input(z.string().uuid())
    .query(async ({ ctx, input: id }) => {
      const user = await ctx.db.user.findUnique({ where: { id } });
      if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' });
      return user;
    }),

  // Mutation — 创建用户
  create: protectedProcedure
    .input(z.object({
      name: z.string().min(2),
      email: z.string().email(),
    }))
    .mutation(async ({ ctx, input }) => {
      const existing = await ctx.db.user.findUnique({
        where: { email: input.email }
      });
      if (existing) {
        throw new TRPCError({ code: 'CONFLICT', message: '邮箱已被注册' });
      }
      return ctx.db.user.create({ data: input });
    }),

  // Mutation — 删除用户(需要管理员权限)
  delete: protectedProcedure
    .input(z.string().uuid())
    .mutation(async ({ ctx, input: id }) => {
      // 检查是否有权限
      if (ctx.user.role !== 'admin' && ctx.user.id !== id) {
        throw new TRPCError({ code: 'FORBIDDEN' });
      }
      return ctx.db.user.delete({ where: { id } });
    }),
});

合并路由(AppRouter)

// src/trpc/router.ts — 根路由
import { router } from './init';
import { usersRouter } from './routers/users';
import { postsRouter } from './routers/posts';
import { authRouter } from './routers/auth';

export const appRouter = router({
  users: usersRouter,
  posts: postsRouter,
  auth: authRouter,
});

// 导出类型(前端使用这个!)
export type AppRouter = typeof appRouter;

与 Hono 集成(服务端)

// src/index.ts — Hono + tRPC 服务端
import { Hono } from 'hono';
import { trpcServer } from '@hono/trpc-server';
import { appRouter } from './trpc/router';
import { createContext } from './trpc/context';

const app = new Hono();

// 挂载 tRPC 到 /trpc 路径
app.use(
  '/trpc/*',
  trpcServer({
    router: appRouter,
    createContext,
  })
);

// 其他 REST 路由可以共存
app.get('/health', (c) => c.json({ ok: true }));

export default app;
// src/trpc/context.ts — 创建请求上下文
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
import { prisma } from '../lib/prisma';
import { verifyToken } from '../lib/auth';

export async function createContext({ req }: FetchCreateContextFnOptions) {
  const token = req.headers.get('Authorization')?.replace('Bearer ', '');
  const user = token ? await verifyToken(token) : null;
  return { db: prisma, user, requestId: crypto.randomUUID() };
}

前端客户端类型推断

这是 tRPC 最神奇的部分——前端完全不需要写类型定义:

// src/lib/trpc.ts(前端代码)
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../server/src/trpc/router';
// ↑ 只导入类型,不包含任何服务端运行时代码

export const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/trpc',
      headers() {
        return {
          Authorization: `Bearer ${localStorage.getItem('token')}`,
        };
      },
    }),
  ],
});

// 使用(完整类型推断)
const users = await trpc.users.list.query({ page: 1, limit: 10 });
// users 的类型自动推断为 User[]

const user = await trpc.users.byId.query('some-uuid');
// user 的类型自动推断为 User

const newUser = await trpc.users.create.mutate({
  name: '张三',
  email: 'zhang@example.com',
});
// 如果传入 name: 123,TypeScript 立即报错!

与 React Query 集成(React 前端)

bun add @trpc/react-query @tanstack/react-query
// src/lib/trpc.ts(React 版)
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../../server/src/trpc/router';

export const trpc = createTRPCReact<AppRouter>();

// 在 React 组件中使用
function UserList() {
  const { data: users, isLoading } = trpc.users.list.useQuery({ page: 1 });
  const createUser = trpc.users.create.useMutation({
    onSuccess: () => { /* 刷新列表 */ }
  });

  if (isLoading) return <div>加载中...</div>;

  return (
    <ul>
      {users?.map(user => (
        <li key={user.id}>{user.name} — {user.email}</li>
      ))}
    </ul>
  );
}

何时用 tRPC,何时用 REST

场景推荐理由
全栈 TypeScript(Next.js/Remix)tRPC极致类型安全,开发体验最佳
需要对外开放 APIREST + OpenAPI行业标准,支持非 TS 客户端
团队同时有 iOS/Android 客户端REST移动端无法直接用 tRPC
微服务间通信gRPC / REST跨语言支持
内部工具/管理后台tRPC快速迭代,类型保障

tRPC 的限制:tRPC 目前不支持文件上传(需要单独的 REST 端点)、缺乏原生 WebSocket 支持(需要 subscription 配合 ws 适配器)、前后端必须共享 TypeScript 代码(monorepo 或 npm 包)。