做前后端分离项目久了,你会发现最消耗团队耐心的往往不是业务逻辑本身,而是接口两侧的"对齐"问题。字段名改一下,文档没同步,前端报错一片,联调群里互相@;后端说"我改了你看一下",前端回"你哪个接口改了也不说一声"。次数多了,我开始认真思考一个问题:前后端之间的类型契约,能不能不再靠人肉维护。后来我发起了 t3code 这个项目,用 T3 技术栈(TypeScript、Tailwind CSS、tRPC)从零搭了一套全栈应用基线,核心目标就是让"前端调接口像调本地函数一样有类型提示"这件事从设想变成可以每天用的现实。这篇文章会把项目的完整思路、落地步骤和实战中踩过的坑都摊开讲,适合正在了解端到端类型安全的全栈开发者,也适合团队里想引入 tRPC 但还在犹豫的同学做参考。
1. t3code的来龙去脉:这不是又一个脚手架模板
1.1 项目要解决的问题:API契约漂移
先说说我为什么非要折腾这么一套东西。过去几年我经手过好几个典型的前后端分离项目,流程都差不多:后端写接口,在 Swagger 里维护文档,前端照着文档手写 interface,联调时再对着 Postman 抓字段。这个流程最大的问题不是谁偷懒,而是任何一环靠人肉同步,迟早都会出错。
举一个我印象很深的例子。某个管理后台的订单接口,后端把status字段从枚举改成了字符串描述,自认为"反正改了调用方也能处理"。但前端至少有三个页面用了这个字段做条件渲染,其中一个是status === 'PAID',改完之后页面直接崩了。排查了三小时,最后发现就是接口文档没更新,前端根本不知道这个约定变了。这种问题严格来说不怪某个同事,而是流程里缺少一个强制性的类型约束。
t3code 就是在这个背景下做的。我不打算再做一个通用脚手架模板,市面上create-next-app之类的工具已经够多了。我想验证的是:能不能把后端的类型定义作为唯一事实来源,让前端在编译期就发现"你这里类型对不上",而不是等到联调甚至上线后才发现。
1.2 T3 这三个字母到底指什么
t3code 里的 T3,对应的是 TypeScript、Tailwind CSS、tRPC 三样东西,另外把 Next.js 当作宿主框架来跑整个应用。这个组合不是随便拼的:
- TypeScript是地基,没有它后面都不成立;
- Tailwind CSS负责样式,原子化类名让前端写 UI 快很多;
- tRPC是核心,它把前后端的类型安全贯穿到整个调用链上。
如果你第一次听到 tRPC,可以把它理解成一个"函数调用级别的 API 方案":后端定义好一个 router,前端直接调用里面的 procedure,参数和返回值的类型完全自动推断。对我这种长期写 REST 接口的人来说,第一次跑通这个流程时的感受就是——以前是隔着围墙扔纸条,现在是直接坐下来开会。
1.3 整体目录结构与模块边界
t3code 的目录结构我按"能力分层"来组织,而不是按页面来组织。核心区分是src/server和src/trpc两条线,前者是后端逻辑,后者是前端调用入口,二者通过共享类型连接。
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts │ │ └── (main)/tasks/page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── trpc.ts │ │ │ └── root.ts │ │ ├── db.ts │ │ └── modules/ │ │ ├── task/ │ │ │ ├── task.router.ts │ │ │ └── task.service.ts │ │ └── user/ │ │ └── user.router.ts │ ├── trpc/ │ │ ├── client.ts │ │ └── provider.tsx │ └── styles/globals.css └── package.json这个结构有几个好处:模块边界清晰,task相关的 router 和 service 放一起,后续做单元测试也容易定位;前后端共享的类型都从src/server/api这一层流出去,不会出现"同一个类型在多个地方各写一份"的尴尬。
1.4 项目范围:聚焦四个典型场景
为了避免项目失控,我把 t3code 的范围压缩得很小:登录鉴权与会话、任务 CRUD、按状态做分组统计、以及一套服务端渲染场景的调用示例。这几个场景覆盖了全栈开发里最常见的痛点,包括权限控制、数据变更后的缓存刷新、服务端组件的数据获取方式等等。
所以这篇文章不是"从零教你写一个完整业务系统"的教程,而是把 t3code 做为一套"带类型保障的全栈开发基线"来分享。你看完可以直接把核心思路搬到自己的项目里,不一定非要照搬代码。
2. 为什么放弃手写接口对接:端到端类型安全的思考过程
2.1 传统前后端对接方式的日常差异
很多团队对"类型不同步"的容忍度非常高,原因是"反正能跑"。但"能跑"和"跑得对"是两回事。我用一张表对比一下传统 REST 对接和 tRPC 对接的日常差异:
| 环节 | 传统 REST + 手写类型 | tRPC 方式 |
|---|---|---|
| 新增一个字段 | 后端改 DTO、改文档;前端改 interface、改 mock | 后端改 zod schema 和数据库,前端类型自动跟随 |
| 类型错误发现时机 | 联调期、运行期、甚至线上 | 编译期、编辑器即时提示 |
| 接口调用方式 | 手工拼接 URL、处理 query 参数 | 直接调用函数式 API,参数自动提示 |
| 维护文档 | 需要额外维护,容易过期 | 类型定义本身就是活文档 |
我见过太多团队把大量精力花在"让文档不过期"这件事上,但文档的本质是事后记录,类型推断才是事前约束。两者解决的问题层次完全不同。
2.2 tRPC 的工作原理:Router、Procedure、Context
tRPC 的核心概念不多,理解这三个就够用:
- procedure:一个可以被前端调用的后端函数,分为
query(查)和mutation(写); - router:一组 procedure 的集合,类似于 REST 里的"资源控制器";
- context:每次请求进来时构建的上下文对象,通常放鉴权信息、数据库实例等。
整个流程说起来很简单:后端用initTRPC创建实例,定义router和procedure;前端用createTRPCReact创建客户端,然后通过 hooks 调用。关键在于 tRPC 的 TypeScript 类型推断能力,它会把后端 router 的类型直接传给前端客户端,前端拿到的不再是"某个接口地址",而是一个完整的、带参数和返回值类型的函数表。
生活化类比:以前 REST 接口像两个国家之间通过海关报关单交流,两边各自维护一套申报格式,格式不一致就得来回改单;tRPC 更像是同一个公司内部直接打电话,两边用的是同一本通讯录、同一个语言系统,不存在"单子对不上"的问题。
2.3 对比 GraphQL 和 OpenAPI:为什么选 tRPC
不少同学会问:GraphQL 不也能解决类型同步问题吗?OpenAPI 配合代码生成也可以啊?我的答案是:都能,但要比较的是综合成本。
| 对比项 | REST + OpenAPI | GraphQL | tRPC |
|---|---|---|---|
| 类型同步方式 | 依赖代码生成工具,需要额外构建步骤 | 依赖 Schema + codegen,需要维护 resolver 与 schema 的一致 | 天然同步,零生成步骤 |
| 学习成本 | 低 | 高(GraphQL Schema 语言、resolver、缓存策略、N+1 处理) | 中低,会写 TypeScript 基本就能上手 |
| 多客户端支持 | 好 | 好 | 差,只适合 TypeScript/JavaScript 生态 |
| 调试体验 | 通用工具多 | 需要专门工具 | 浏览器 Network 面板直接看请求,调试轻量 |
GraphQL 的方案本身很强,但放在 t3code 这种"全栈 TypeScript、前后端同仓"的项目里,引入它就等于多了一套 Schema 语言和解析层,而 tRPC 直接把这些省掉了。OpenAPI 适合对外发布 API 的场景,但内部全栈项目里,你往往需要的是"快速建一个带类型保证的后端接口",而不是"维护一套公开契约文档"。
2.4 这套取舍什么时候不成立
务必清醒一点:tRPC 不是万能的。如果你是以下情况,别用 tRPC,真的别用:
- 需要给 iOS、Android、小程序等非 TypeScript 客户端提供 API;
- 需要将 API 开放给第三方开发者,形成公开契约;
- 前后端团队严格分离、仓库也分离,后端不能向前端暴露内部函数结构;
- 需要长期稳定的 API 版本管理,REST + OpenAPI 反而更合适。
tRPC 的定位是"同构全栈项目里的高效通道",它是给你省事的,不是给你制造契约负担的。理解这一点,后面选型才不会跑偏。
3. 从空目录到跑通全流程:t3code 项目的核心落地步骤
3.1 初始化与依赖安装
我建议在空目录里从 Next.js 起步,免得被模板里预置的东西干扰。命令如下:
pnpm create next-app@latest t3code --typescript --tailwind --app --src-dir cd t3code pnpm add @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod superjson pnpm add -D prisma @types/node tsx说明几点:superjson用来序列化复杂类型(比如 Date、Map),tRPC 默认的 transformer 就是它,不装的话Date类型传输会丢;zod是 tRPC 官方推荐的 input 校验工具,后面会一起提。数据库我这里先用 SQLite 起步,方便本地跑,正式部署再换 PostgreSQL 也不影响结构。
3.2 数据层先行:先写好 Prisma Schema
t3code 里我先定义User和Task两张表,关系很简单:一个用户有多条任务。这个先行的好处是,后端的 router 代码能直接引用 Prisma 生成的类型,不会出现"接口写完了才发现字段名对不上"的情况。
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique passwordHash String name String? tasks Task[] } model Task { id String @id @default(cuid()) title String done Boolean @default(false) priority Int @default(1) userId String user User @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) }然后执行:
pnpm prisma migrate dev --name init这一步会生成数据库文件和 Prisma Client。注意.env里必须配好DATABASE_URL="file:./dev.db",这是新手最容易漏的。
3.3 搭出 tRPC 服务端:trpc.ts 与 router
接下来是 t3code 的核心:src/server/api/trpc.ts。这里面定义了三样东西——tRPC 实例、上下文类型、以及后续鉴权会用到的protectedProcedure。
import { initTRPC, TRPCError } from '@trpc/server'; import { ZodError } from 'zod'; import { prisma } from '../db'; import superjson from 'superjson'; export const createTRPCContext = async (opts: { req: Request }) => { // 这里可以从请求头解析 session,先留空 return { req: opts.req, db: prisma, userId: null as string | null, }; }; const t = initTRPC.context<Awaited<ReturnType<typeof createTRPCContext>>>().create({ transformer: superjson, errorFormatter({ shape, error }) { // 把 zod 的校验错误统一整理成友好信息 return { ...shape, data: { ...shape.data, validationError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const router = t.router; export const publicProcedure = t.procedure; // 后续鉴权中间件加进来后,protectedProcedure 就代表"必须登录" export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { ...ctx, userId: ctx.userId } }); });再写src/server/api/root.ts把业务模块聚合起来。tRPC 支持多个 router 合并,这个设计在业务变大之后非常管用:
import { router } from './trpc'; import { taskRouter } from '../modules/task/task.router'; import { userRouter } from '../modules/user/user.router'; export const appRouter = router({ task: taskRouter, user: userRouter, }); export type AppRouter = typeof appRouter;Task模块的 router 是这样:
import { z } from 'zod'; import { router, protectedProcedure } from '../../api/trpc'; const taskRouter = router({ list: protectedProcedure .input(z.object({ done: z.boolean().optional() })) .query(async ({ ctx }) => { const where = { userId: ctx.userId, ...(done === undefined ? {} : { done }) }; return ctx.db.task.findMany({ where, orderBy: { createdAt: 'desc' } }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), priority: z.number().int().min(1).max(5).default(1) })) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { ...input, userId: ctx.userId } }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const task = await ctx.db.task.findFirst({ where: { id: input.id, userId: ctx.userId } }); if (!task) throw new TRPCError({ code: 'NOT_FOUND' }); return ctx.db.task.update({ where: { id: input.id }, data: { done: !task.done } }); }), });这里的要点是input 的 zod 校验同时承担了类型推断职责。比如create传进来的参数,前端拿到手之后就知道必须要有title,priority会有默认值1,乱传根本编译不过去。
3.4 把 tRPC 挂到 Next.js 上
Next.js App Router 下,只需要一个 route handler 转发请求:
// src/app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import { appRouter } from '@/server/api/root'; import { createTRPCContext } from '@/server/api/trpc'; const handler = (req: Request) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext: () => createTRPCContext({ req }), }); export { handler as GET, handler as POST };看上去只是薄薄一层,但它是整个前端调用链的入口。所有请求都走/api/trpc,具体调用哪个 procedure 由请求体里的路径决定。
3.5 前端接入:client、Provider 与 hooks
先建两个文件。src/trpc/client.ts创建 React hooks 客户端,src/trpc/provider.tsx注入 QueryClient 和 tRPC Provider:
import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@/server/api/root'; export const api = createTRPCReact<AppRouter>();'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { httpBatchLink } from '@trpc/client'; import { api } from './client'; import superjson from 'superjson'; const queryClient = new QueryClient(); export function TRPCProvider({ children }: { children: React.ReactNode }) { const trpcClient = api.createClient({ links: [ httpBatchLink({ url: '/api/trpc', transformer: superjson, }), ], }); return ( <api.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </api.Provider> ); }页面里的调用非常直接:
'use client'; import { api } from '@/trpc/client'; export default function TaskList() { const { data: tasks, isLoading } = api.task.list.useQuery({ done: false }); const toggleTask = api.task.toggle.useMutation({ onSuccess: () => api.useUtils().task.list.invalidate(), }); if (isLoading) return <p>加载中...</p>; return ( <ul> {tasks?.map((task) => ( <li key={task.id}> <input type="checkbox" checked={task.done} onChange={() => toggleTask.mutate({ id: task.id })} /> {task.title} </li> ))} </ul> ); }注意看,api.useUtils().task.list.invalidate()会在 mutation 成功后自动刷新列表,不需要手动 refetch。这就是 tRPC + react-query 组合最常见的正确姿势,后面在坑里我还会强调一次。
3.6 用 Tailwind 把页面撑起来
Tailwind 在这套体系里承担的是"快速出样式"的任务,不需要单独说太多,但有一个经验值得提:把 tRPC 的类型推断和 Tailwind 的类名结合,页面代码量能压到很低。比如任务卡片我只需要一个映射:
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-4"> <span className={task.done ? 'line-through text-gray-400' : ''}>{task.title}</span> <span className="text-sm text-gray-500">优先级 {task.priority}</span> </div>这样一来,整个 t3code 的核心链路就跑通了:数据库 -> Prisma -> tRPC router -> 前端 hooks -> Tailwind UI,每个环节都有类型兜底。
4. 类型安全之外的暗礁:服务端渲染、缓存与错误处理的那些细节
4.1 服务端组件里怎么调 tRPC
如果你只在客户端组件里用useQuery,t3code 还没真正触到全栈的难点。Next.js 的 App Router 里,很多页面是服务端组件(RSC),在 RSC 里不能直接用 hooks,那怎么拿数据?答案是用createCallerFactory在后端直接创建调用器:
import { createCallerFactory } from '@trpc/server'; import { appRouter } from '@/server/api/root'; import { createTRPCContext } from '@/server/api/trpc'; const createCaller = createCallerFactory(appRouter); export async function getServerSideTasks() { const ctx = await createTRPCContext({ req: new Request('http://internal') }); const caller = createCaller(ctx); return caller.task.list({ done: false }); }这里有个非常关键的细节:服务端调用时 context 必须手动构建,不能从"当前浏览器请求"里拿。因为在 RSC 阶段你拿到的不是某个具体用户的请求头,session 解析逻辑得单独处理好,否则会出现"服务端渲染时拿不到登录态"这种诡异问题。我建议的做法是:把 userId 解析函数抽成独立模块,客户端和服务端 context 都复用它。
4.2 数据缓存:staleTime 与 invalidate 的正确姿势
react-query 的缓存机制是双刃剑。刚开始用的时候,很多人会抱怨"改了数据页面不刷新",其实问题不在缓存,而在你没有正确使用失效机制。
- 查询用
staleTime控制数据多久之后算过期,适合高频只读列表; - 变更用
onSuccess里调invalidate,让相关查询重新拉取。
拿任务列表举例,如果你给list.useQuery设置了staleTime: 0,那就等于放弃了缓存;正确做法是保留默认缓存,等 mutation 成功后再把对应 key 失效掉。mutation 的目标不是"每次都拉新数据",而是"数据变了之后才拉新数据"。这个心智模型一定要转过来。
4.3 错误处理模型:不要只 catch 一个笼统的 error
tRPC 的错误是结构化的,TRPCError会暴露code,zod 校验失败还会带validationError字段。我在 t3code 里统一做了一个前端错误呈现组件,把错误分成三类:
| 错误场景 | TRPCError code | 前端处理建议 |
|---|---|---|
| 未登录 | UNAUTHORIZED | 跳转登录页 |
| 参数错误 | BAD_REQUEST | 表单局部标红 |
| 数据不存在 | NOT_FOUND | 提示"内容已删除" |
在 errorFormatter 里把 zod 错误扁平化输出之后,前端能直接拿到字段级别的错误信息,不需要在页面里再写一堆"看 status code 猜原因"的逻辑。
4.4 鉴权中间件的实现细节
前面留了个protectedProcedure的位子,这里补上完整的鉴权流程。实际项目中,登录成功后把 session 存在 cookie 里,每次请求时从 cookie 解析出 userId,注入 context:
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { // ctx 里已经有从 cookie 解析出来的 userId if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { ...ctx, userId: ctx.userId } }); });业务 router 里,凡是需要登录的接口一律用protectedProcedure而不是publicProcedure。这样"谁可以调这个接口"的约束就直接写在路由定义层,前端通过类型也能感知到接口是否需要鉴权,不用靠猜。
4.5 性能边界:关注 N+1 与分页方式
类型安全解决的是正确性问题,但性能问题照样会来。t3code 里我有几条硬性规则:
- 尽量避免在 query 里串行调用多次数据库查询,用 Prisma 的
include或select一次性取数; - 列表接口预留分页参数,即使初期数据量小,也不要用裸的
findMany; - 游标分页优先于 offset 分页,因为 offset 在深翻页时性能会明显下滑。
实现一个简单的 cursor 分页不复杂:
list: protectedProcedure .input(z.object({ cursor: z.string().optional(), take: z.number().default(10) })) .query(async ({ ctx, input }) => { const tasks = await ctx.db.task.findMany({ where: { userId: ctx.userId }, take: input.take + 1, ...(input.cursor ? { cursor: { id: input.cursor } } : {}), orderBy: { id: 'asc' }, }); const hasMore = tasks.length > input.take; const items = hasMore ? tasks.slice(0, -1) : tasks; return { items, nextCursor: hasMore ? items[items.length - 1]?.id : null }; });这些看起来跟 tRPC 没关系,但在 t3code 这种"全栈以类型为轴心"的项目里,性能方案的选型同样要写在代码里,作为团队的默认约定。
5. 实测中的四个坑:从现象到根因的完整排查
5.1 坑一:Edge Runtime 下 Prisma 调用直接报错
现象:把 t3code 部署到测试环境后,任务列表接口一直返回 500,服务端日志显示 Prisma 报错,关键词是Connection is not established之类。
排查过程:我先在本地复现,本地完全正常,说明不是代码逻辑问题。随后看部署日志,发现 route handler 被运行在 Edge Runtime 里。Edge 环境不支持传统的数据库 TCP 长连接,Prisma Client 在这里根本没法正常工作。
根因:Next.js 部分平台默认开启 Edge Runtime,而我的 route handler 没有显式指定 runtime。
修复:在src/app/api/trpc/[trpc]/route.ts顶部加一行:
export const runtime = 'nodejs';加完之后重新部署,接口恢复正常。这个坑属于"平台默认值和数据库生态不匹配"的典型问题,排查思路就一条:从本地到部署环境逐层对比差异,先确认自己的代码没问题,再去怀疑环境配置。
5.2 坑二:mutation 成功后列表不刷新
现象:创建任务接口调用成功,数据库里能看到新记录,但页面上列表没有任何变化。
排查过程:一开始我以为是要重新挂载组件,刷新页面后发现数据其实已经进去了,说明问题出在客户端缓存。再看代码,发现我的 mutation 里只写了接口调用,没有做任何缓存失效操作。
根因:react-query 默认会缓存已请求的数据,mutation 不会自动触发相关查询刷新。这不是 bug,是设计机制。
修复:
const utils = api.useUtils(); const createTask = api.task.create.useMutation({ onSuccess: () => { utils.task.list.invalidate(); // 让列表重新请求 }, });这个坑几乎每个人都会踩一次。记住一句话:任何修改数据的 mutation 成功之后,都要显式 invalidate 相关查询,养成这个条件反射,能省很多时间。
5.3 坑三:zod 可选字段在更新接口里的"幽灵报错"
现象:写任务更新接口时,前端只传一个id和一个title,其他字段全部不传,结果后端返回校验错误。
排查过程:把请求体打出来看,priority字段是undefined。zod 的默认行为是"字段不存在或不满足类型就报错",我 schema 里写的是z.number(),但前端在编辑场景下不一定会传这个字段,于是触发校验失败。
根因:可选字段的定义不严谨。z.number()不接受undefined,必须明确写成.optional(),同时在 Prisma update 里处理好"没传就不更新"的逻辑。
修复:
input: z.object({ id: z.string(), title: z.string().min(1).optional(), priority: z.number().int().min(1).max(5).optional(), })然后在 service 层把传入的字段做一层过滤,不要让undefined直接进 Prisma update 语句。这个坑提醒我:zod schema 要站在调用方的视角来设计,而不是站在数据库字段的视角。
5.4 坑四:照着 tRPC v10 老教程写,API 形态对不上
现象:网上很多 tRPC 教程还是 v10 的写法,比如initTRPC.create()之后直接t.createRouter(...)、t.procedure.query(...)。照着写,控制台报createRouter不存在。
排查过程:查 package.json 发现装的是 tRPC v11。v11 把初始化 API 改成了initTRPC.context<Context>().create(),router 的创建方式也从createRouter变成了t.router。
根因:版本升级带来的 breaking change。这个不算 bug,但确实是资料滞后造成的常见问题。
修复:统一以官方文档为准,确认版本号之后再决定怎么写。我建议比较稳的做法是:项目初始化时把 tRPC 相关依赖固定版本,避免哪天pnpm add装到新版本后旧代码大面积报错。
6. 适用边界与横向对比:这套方案在不该用的地方不要硬用
6.1 哪些场景可以放心用
从 t3code 的实践经验来看,下面几类场景用 tRPC 收益最大:
- 内部管理系统:运营后台、管理面板、工具型站点,用户量不大但迭代极快,类型安全能直接减少联调成本;
- 个人全栈项目:一个开发者同时写前后端时,tRPC 能让你彻底忘掉"接口文档"这件事,专注业务;
- 创业公司早期 MVP:需要快速验证业务逻辑,又不想在接口文档和代码生成上投入太多维护精力。
如果你正在面对以上场景,我的建议是先挑一个小模块试用,比如把一个列表页从 REST 改成 tRPC,跑通一次全流程再决定是否铺开。不要一上来就全量重构,风险太大。
6.2 哪些场景建议绕开
反过来,下面这些场景用 tRPC 会踩得很痛:
- 需要服务第三方开发者的开放平台,API 是产品的一部分;
- 客户端是原生 App 或小程序,无法共享 TypeScript 类型;
- 后端技术栈不是 Node.js 生态,比如 Java、Go 后端;
- 团队内部前后端仓库分离且有明确的发布节奏,后端不希望暴露内部函数结构。
在这些场景里,REST + OpenAPI 或者 GraphQL 才是更稳妥的选择。技术选型没有"最正确",只有"最匹配现实约束"。
6.3 我自己的决策标准清单
最后分享一套我在做技术选型时实际会过一遍的问题,你可以直接抄走:
- 前后端是不是都用 TypeScript?不是,直接排除 tRPC;
- API 是否需要被公司外部调用?需要,走 REST + OpenAPI 更保险;
- 前端是否需要读接口文档?团队小、沟通直接,tRPC 合适;团队大、人员流动快,一份稳定的 OpenAPI 文档可能更香;
- 是否对极低延迟有强需求?tRPC 本身不是性能瓶颈,但如果你做的是开放的高并发 API,通用 REST 网关的生态会更成熟。
这套清单不一定适合所有人,但至少能帮你避免"上完 tRPC 之后再推倒重来"的尴尬。
6.4 后续扩展方向:t3code 还能往哪里走
目前 t3code 已经验证了"端到端类型安全"这个核心假设。如果继续做下去,我会优先从这几个方向扩展:
- 用 Turborepo 做 monorepo 拆分,把
server和web分开但共享类型包,方便团队多人协作; - 引入 Playwright 做端到端测试,因为类型安全保证了调用链正确,但 UI 交互仍然需要 E2E 兜底;
- 增加日志与可观测性,在 tRPC middleware 里统一打点,记录每个 procedure 的耗时和错误,方便排查线上问题。
我最初做 t3code 的时候,最直观的收获是"少跑了很多次联调";但真正让我觉得值回票价的,是后期改字段时的从容——前端编译直接告诉我哪里受了影响,而不是等同事在群里喊一嗓子。类型安全不是银弹,它解决的是"前后端对齐"这个具体问题,但这个问题恰恰是很多项目里最日常、最隐蔽的时间黑洞。如果你也想把这套思路落到自己的项目里,不妨先找一个最小的场景跑通试试,感受一下"改完后端、前端立刻变红"这种即时反馈带来的安心感。