正在加载,请稍候…

tRPC:全栈 TypeScript 应用的端到端类型安全

使用 tRPC 构建完全类型安全的 API——涵盖路由设置、过程、Zod 输入验证、中间件、React Query 集成以及 Next.js App Route

tRPC:全栈 TypeScript 应用的端到端类型安全

tRPC 的特殊之处

tRPC 消除了代码生成的需要。你的 TypeScript 类型会自动从服务器流向客户端——无需 OpenAPI,无需 GraphQL schema,只需 TypeScript。

tRPC:全栈 TypeScript 应用的端到端类型安全插图

安装

npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod

服务器设置

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
import superjson from 'superjson';
import type { Context } from './context';

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
      },
    };
  },
});

export const router = t.router;
export const publicProcedure = t.procedure;

// 受保护的过程
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { ...ctx, user: ctx.session.user } });
});

上下文

// server/context.ts
import { getServerSession } from 'next-auth';
import { prisma } from '@/lib/prisma';
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';

export async function createContext(opts: CreateNextContextOptions) {
  const session = await getServerSession(opts.req, opts.res, authOptions);
  return { prisma, session };
}

export type Context = Awaited<ReturnType<typeof createContext>>;

tRPC:全栈 TypeScript 应用的端到端类型安全插图

路由和过程

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

export const usersRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string().cuid() }))
    .query(async ({ input, ctx }) => {
      const user = await ctx.prisma.user.findUnique({
        where: { id: input.id },
        select: { id: true, name: true, email: true, createdAt: true },
      });
      if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
      return user;
    }),
  
  list: publicProcedure
    .input(z.object({
      page: z.number().min(1).default(1),
      perPage: z.number().min(1).max(100).default(20),
      search: z.string().optional(),
    }))
    .query(async ({ input, ctx }) => {
      const { page, perPage, search } = input;
      const where = search
        ? { OR: [{ name: { contains: search } }, { email: { contains: search } }] }
        : {};
      
      const [users, total] = await Promise.all([
        ctx.prisma.user.findMany({
          where,
          skip: (page - 1) * perPage,
          take: perPage,
          orderBy: { createdAt: 'desc' },
        }),
        ctx.prisma.user.count({ where }),
      ]);
      
      return { users, total, pages: Math.ceil(total / perPage) };
    }),
  
  create: protectedProcedure
    .input(z.object({
      name: z.string().min(2).max(100),
      email: z.string().email(),
      role: z.enum(['USER', 'ADMIN']).default('USER'),
    }))
    .mutation(async ({ input, ctx }) => {
      const existing = await ctx.prisma.user.findUnique({
        where: { email: input.email },
      });
      if (existing) throw new TRPCError({ code: 'CONFLICT', message: 'Email taken' });
      
      return ctx.prisma.user.create({ data: input });
    }),
  
  delete: protectedProcedure
    .input(z.object({ id: z.string().cuid() }))
    .mutation(async ({ input, ctx }) => {
      if (ctx.user.role !== 'ADMIN') {
        throw new TRPCError({ code: 'FORBIDDEN' });
      }
      return ctx.prisma.user.delete({ where: { id: input.id } });
    }),
});

App Router

// server/routers/_app.ts
import { router } from '../trpc';
import { usersRouter } from './users';
import { postsRouter } from './posts';

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

export type AppRouter = typeof appRouter;

Next.js API 处理器

// app/api/trpc/[trpc]/route.ts (App Router)
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext,
  });

export { handler as GET, handler as POST };

tRPC:全栈 TypeScript 应用的端到端类型安全插图

React 客户端使用

// lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();

// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';

const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
  trpc.createClient({
    transformer: superjson,
    links: [
      httpBatchLink({ url: '/api/trpc' }),
    ],
  })
);
// components/UserList.tsx
export function UserList() {
  const { data, isLoading } = trpc.users.list.useQuery({ page: 1, perPage: 20 });
  const deleteUser = trpc.users.delete.useMutation({
    onSuccess: () => utils.users.list.invalidate(),
  });
  
  if (isLoading) return <Spinner />;
  
  return (
    <ul>
      {data?.users.map(user => (
        <li key={user.id}>
          {user.name}
          <button onClick={() => deleteUser.mutate({ id: user.id })}>
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

订阅(WebSocket)

// 服务器
export const postsRouter = router({
  onNewPost: publicProcedure.subscription(async function* () {
    for await (const post of pubsub.on('new-post')) {
      yield post;
    }
  }),
});

// 客户端
trpc.posts.onNewPost.useSubscription(undefined, {
  onData: (post) => {
    console.log('New post:', post);
  },
});

为什么选择 tRPC 而非 GraphQL

  • tRPC:零 schema,纯 TypeScript,设置更简单
  • GraphQL:语言无关,内省,灵活查询
  • 选择 tRPC:适用于 TypeScript monorepo 或 Next.js 项目
  • 选择 GraphQL:适用于面向多样客户端的公共 API