资讯动态

全栈 TypeScript 类型安全实践:用 t3code 干掉重复样板代码

发布时间:2026/10/14 14:50:22 来源:尧图企业网站定制
1. 从一次改字段的崩溃说起为什么会有 t3code事情的开端有点狼狈。去年我接手一个老项目需求简单得离谱给用户表加一个“昵称”字段。我本以为半小时能收工结果光是把这一处改动铺到前后端就花了快一整个下午。后端要先改数据库模型再改 service 层的查询接着是 controller 的参数映射然后是 router 的请求体校验最后还要手动补一份 TypeScript 类型文件供前端调用时使用。等到前端真能从接口拿到新字段我几乎忘了自己最初要干什么。那天晚上我坐在电脑前认真问了自己一个问题这些重复的样板代码真的都是必要的吗t3code 就是在这个问号里诞生的。它不是一个开源组织也不是什么明星项目是我自己搭出来的一个全栈 TypeScript 实验台。名字里的 t3指的是 T3 家族那一套以类型为骨架的现代工具组合——TypeScript、Tailwind、tRPC这一类技术共同的特点是“把类型当一等公民”。code 则是多年业务开发留下的执念代码应该写给下一个维护者看而不只是写给机器跑。这个项目最终长成了一个带登录、带持久化、带实时协作的清单应用同时也能被拆成一个可自托管的小服务。如果你已经对分散的契约文件、手写 CRUD 路由和无休止的类型同步感到疲劳想看看“全栈类型安全”到底能把开发体验提升到什么程度那这篇复盘值得你花十分钟。1.1 那个让我反复回味的核心痛点三份契约三个孤儿很多团队其实不是败在业务复杂度上而是败在“信息要复制多份”这件事上。传统的前后端分离开发里一个用户列表功能至少存在三份互不相干的描述数据库表结构一份、后端接口文档一份、前端类型定义一份。这三份内容描述了同一个事物却各自单独维护。一旦某一边忘了更新编译不会报错运行也不一定立刻报错只会在某个深夜前端把undefined传给后端后端再抛出一个莫名其妙的 500。t3code 想验证的核心命题是如果数据库、API 层和前端能共享同一份类型推导这三份契约是不是可以合并成一份事实证明确实可以。当数据库模型变化时Prisma 会重新生成类型当 tRPC 路由变化时类型会顺着调用链直接传导到前端组件。改字段这件事从“牵一发而动全身”变成了“改一处其余由编译器告诉你哪里需要调整”。这种体验是被动補出来的不是靠团队纪律约束出来的。纪律会松懈让编译器盯住类型边界则不会。1.2 什么人适合参考这套实践先说实话t3code 不是银弹。它适合的场景有几个共同特征团队规模不大、TypeScript 使用率接近 100%、产品迭代节奏快、后端逻辑没有特别复杂的分布式编排需求。如果你所在的团队上百人系统里既有 Java 微服务又有 Node.js BFF还有一堆历史接口在“带病运行”那 t3code 这种小集成度的形态反而不合适。但如果你是一个独立开发者、一个小型产品团队或者一个正在做技术选型调研的工程师这套实践的参考价值很高尤其适合那些“一个人要负责前后端整条链路”的场景。少写一半样板代码意味着你有更多时间思考业务本身。2. 选技术栈就像挑合伙人每个选择都要回答“为什么”t3code 的技术栈清单看上去可能会让人产生“这不就是抄 T3 全家桶”的误解。实际上我挑选每一项技术时都砍掉了不少“流行选项”只留下真正服务于全栈类型安全这一目标的组合。2.1 TypeScript不是加分项是前提很多人把 TypeScript 当作“给 JS 加类型标注”这个理解太浅了。TypeScript 真正的价值是在编译期建立一张约束网络。t3code 一开始就定下规矩所有跨进程通信的数据都必须能通过类型系统被两端感知。这条规矩的执行基础就是 TypeScript 具备结构类型系统和泛型推导能力。比如我可以定义一个Item类型然后在数据库层、API 层和 UI 层复用任何一边的形状对不上编译阶段就会炸出来。这比运行时靠console.log一点点找问题效率高出一个数量级。2.2 tRPC 与 REST主张一种更直接的调用方式在没有 tRPC 之前一个典型的全栈项目大概是这样的前端用 axios 发起请求后端用 Express 写路由中间通过 OpenAPI 或者 Postman 文档维持默契。这么干的最大问题是前端永远无法静态感知“这个接口到底接收什么参数、返回什么结构”。一旦后端悄悄改了字段名前端静默失灵排查时常要靠猜。tRPC 给出的解法非常极端不把接口当作“URL 资源”而是当作“可远程调用的函数”。后端导出一个 router 对象前端导入这个对象的类型定义然后就像调用本地 async 函数一样发起请求。参数校验、返回类型推断、错误处理全部由框架串联起来。这样做并不是说 REST 有罪。只是在“单人维护全栈”的场景里REST 带来的边界清晰感敌不过它那套需要额外维护契约的成本。t3code 追求的是少写代码、多留精力给业务所以选 tRPC 是顺理成章的。2.3 Prisma让数据库不再像一个“不定型激素”数据库从表结构设计上讲本来应该是最先确定形状的一层但传统 ORM 通常只能帮你把数据映射成对象很少能把表结构反过来参与到前端类型推导里。Prisma 的特别之处在于它把 schema 当成“唯一事实来源”每次跑prisma generate都会根据 schema 生成一套完整的 TypeScript 类型。于是数据库表和 tRPC 路由之间形成了一条从 Prisma → tRPC → 前端组件 的类型传导链。表结构一改类型跟着改类型改了前端引用处直接爆红。这种“被动推动修改”的特性是 t3code 能少踩大量隐性坑的根本原因。2.4 Tailwind实用主义的外观方案别误会我这里选 Tailwind 不是因为它“酷”而是因为它能最大程度降低“样式代码”对注意力的侵占。Tailwind 把样式写成基于原子类名的组合不需要在各式 CSS 文件里翻来翻去。配合 TypeScript类名在模板里写错不会被立刻发现但配合 Prettier 和 lint 规则至少能把样式的维护成本压到很低。如果单论 UI 代码的组织方式CSS Modules 也不差。但 Tailwind 让我能在一屏内同时看到结构、逻辑和样式对小型项目来说这份“所见即所得”的效率红利很实在。3. 从空目录到一个能跑的全栈骨架动手搭建才是这篇文章的重点。我不会用“一键脚手架”糊弄过去而是把最关键的几步展开讲清楚每个文件存在的理由。以下步骤基于我在 t3code 里的真实操作整理成可直接复制的流程。3.1 项目结构一上来就为将来拆分留好位置很多人在第一步就把项目结构搞得过度复杂t3code 则采用了一个很克制的分层t3code ├── apps │ ├── web │ │ ├── app │ │ │ ├── (auth)/login/page.tsx │ │ │ ├── dashboard/page.tsx │ │ │ └── api/trpc/[trpc]/route.ts │ │ ├── components │ │ └── lib │ └── server │ ├── trpc │ │ ├── router.ts │ │ └── trpc.ts │ └── lib ├── packages │ └── db │ ├── prisma │ │ └── schema.prisma │ └── src │ └── index.ts └── package.json唯一的“隐藏设计”是把数据库封装成一个独立包packages/db而不是直接塞在apps/server里。这么安排是因为在后期你极大可能需要不止一个消费数据库的服务——比如再加一个后台定时任务、一个 WebSocket 推送服务。届时直接导入packages/db即可不需要复制粘贴 Schema。3.2 数据库Prisma schema 是系统的北极星先说明我实际用的是 PostgreSQL本地开发用 Docker 起一个实例docker run --name t3code-postgres \ -e POSTGRES_PASSWORDt3code \ -e POSTGRES_DBt3code \ -p 5432:5432 \ -d postgres:16-alpine然后初始化 Prisma定义两张非常简单的表用户和清单条目。都先求干净不要过度设计。model User { id String id default(cuid()) email String unique name String? items Item[] createdAt DateTime default(now()) } model Item { id String id default(cuid()) title String done Boolean default(false) ownerId String owner User relation(fields: [ownerId], references: [id]) createdAt DateTime default(now()) }跑npx prisma migrate dev --name init和npx prisma generate之后类型就自动生成了。这里有个细节值得注意ownerId不要依赖 Prisma 自动生成的关系字段类型显式写清楚引用关系能让你在后面的 tRPC 路由里少遇到一堆类型报错。3.3 tRPC把后端逻辑当作一组可调用函数来设计先创建一个最小的 tRPC 实例这是所有路由的根// packages/api/src/trpc.ts import { initTRPC, TRPCError } from trpc/server; import type { Context } from ./context; export const t initTRPC.contextContext().create(); export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use((opts) { if (!opts.ctx.session?.userId) { throw new TRPCError({ code: UNAUTHORIZED }); } return opts.next(); }); export const router t.router;这里最关键的是protectedProcedure。它不是普通的中间件包装而是在类型层面就区分了“无需登录”和“必须登录”的两类调用。前端如果错误调用了需要登录的接口编译器能直接提示不用等后端拒绝。接下来定义第一个真实路由查询清单列表、新增清单项、切换完成状态。// packages/api/src/router.ts import { z } from zod; import { protectedProcedure, router } from ./trpc; export const appRouter router({ itemList: protectedProcedure.query(({ ctx }) { return ctx.db.item.findMany({ where: { ownerId: ctx.session.userId }, orderBy: { createdAt: desc }, }); }), itemCreate: protectedProcedure .input(z.object({ title: z.string().min(1).max(80) })) .mutation(({ ctx, input }) { return ctx.db.item.create({ data: { title: input.title, ownerId: ctx.session.userId }, }); }), itemToggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const item await ctx.db.item.findUniqueOrThrow({ where: { id: input.id }, }); if (item.ownerId ! ctx.session.userId) { throw new Error(无权操作); } return ctx.db.item.update({ where: { id: input.id }, data: { done: !item.done }, }); }), }); export type AppRouter typeof appRouter;读这段代码时你会发现这里没有“URL 设计”这回事——没有GET /api/items没有POST /api/items/:id/toggle。每个操作就是 router 上的一个字段函数。输入用 Zod 声明输出来自 Prisma 类型推断两端永远对齐。3.4 前端接入真正像“本地函数”一样调用在 Next.js 的 App Router 里先建一个 tRPC 的 HTTP 入口// apps/web/app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from trpc/server/adapters/fetch; import { appRouter } from /server/router; import { createContext } from /server/context; export const dynamic force-dynamic; const handler (req: Request) fetchRequestHandler({ endpoint: /api/trpc, req, router: appRouter, createContext, }); export { handler as GET, handler as POST };然后在 React 侧React Query 配合 tRPC Client 生成一套 hookconst client createTRPCClientAppRouter({ links: [ httpBatchLink({ url: /api/trpc, headers: { authorization: getSessionToken() }, }), ], }); function ItemList() { const { data } api.itemList.useQuery(); const createItem api.itemCreate.useMutation({ onSuccess: () api.useUtils().itemList.invalidate(), }); return (...); }整个流程走通之后我最大的感受是调用后端逻辑的心理负担变小了。以前我会想“这个接口在哪个环境、要不要拼 URL、要不要做参数序列化”现在我只想“这个数据要从哪个查询里取”。大脑内存占用直接下降debug 时不再需要来回切换上下文。4. 一个真实功能的完整落地从需求到代码只需要一条直线光有骨架还不够把功能真做出来才是检验方案的最好方式。t3code 里面的“清单应用”看着简单但它覆盖了登录鉴权、数据读写、乐观更新、错误回滚这几个关键链路。我把其中最有代表性的“新增并刷新列表”功能拆开看对比一下同样的逻辑在传统 REST 写法下的工作量。4.1 需求拆解和数据库操作需求只有一句话用户登录后能提交一条新事项提交完成列表自动更新。在 t3code 里这个需求只需要两个后端函数和一个前端 hookitemCreate、itemList以及useMutation的onSuccess回调。数据库操作直接内联在路由函数里没有独立的 service 层。这不代表不重视分层而是这种规模的操作根本不需要为了分层而分层。很多团队喜欢把所有逻辑都塞进 service 层仿佛那是唯一的“规范”。但在我个人的实践里过度分层和完全不分层一样危险。t3code 的做法是简单操作直接写在路由函数内复杂业务聚合成独立函数但依旧保留在同一个模块边界内。4.2 与 REST 写法的工作量对比让我们把同一功能用传统写法过一遍。假设后端仍然用 Express前端仍然用 axios// 传统 REST后端路由 router.post(/items, async (req, res) { const { title } req.body; if (!title || typeof title ! string) { res.status(400).json({ message: invalid title }); return; } const item await db.item.create({ data: { title, ownerId: req.session.userId }, }); res.json(item); });而后端还要配套一个请求体类型定义、一个响应类型定义前端要有一个api/items.ts文件封装 axios有一个Item类型接口有一处手工修改的请求参数结构。这些内容分散在 5 个文件里改一次加一次。t3code 的版本则全部浓缩在 router 函数的几十行代码内。用表格直观对比维度传统 REST 手写类型t3code主要收益类型定义来源手写、易漂移Prisma tRPC 推导编译期发现变更参数校验各路由自行实现Zod 声明式单点收敛前端调用封装每个模块一个 api 文件全项目共用同一套 hook零重复封装接口文档依赖 Swagger 或记忆读 router 类型即可文档永远不滞后改字段影响范围后端 3 处、前端 2 处schema 1 处其余编译器提示定位避免漏改对比下来t3code 的写法对“小团队 全栈同构”的场景几乎是碾压级的省事。你损失的是“前后端物理隔离”带来的那一丝安全感收获的是全链路的类型闭环。4.3 协作和乐观更新响应性在类型安全之外的额外红利清单应用里还有一个高频操作勾选完成状态。在传统 REST 里勾选后会先等请求完成再拿返回结果刷新列表。这个过程在弱网环境下会感觉很“卡”。t3code 配合 React Query 的 optimistic update可以做到先改 UI再同步服务端失败则回滚。实现这个并不复杂核心代码如下const toggle api.itemToggle.useMutation({ onMutate: async ({ id }) { await utils.itemList.cancel(); const prev utils.itemList.getData(); utils.itemList.setData(undefined, (old) old?.map((item) item.id id ? { ...item, done: !item.done } : item ) ); return { prev }; }, onError: (_err, _vars, ctx) { utils.itemList.setData(undefined, ctx?.prev); }, });关键点是utils.itemList.setData能直接操作缓存数据而缓存的形状和类型完全来自后端 router 的类型。这在 REST 里需要前端自己维护一个全局 store、若干个常量 type、若干套 update 逻辑。在这里类型和缓存逻辑是同一个库提供的天然同构。结果是写出来的代码更少逻辑更内聚。5. 踩坑实录六个让我深夜怀疑人生的边界问题再顺滑的方案也躲不开真实环境里的坑。t3code 从零搭到上线运行我在里面栽了不少跟头挑六个最有代表性的写出来希望你能绕开。5.1 tRPC 上下文里拿不到 session第一次跑通登录后我发现protectedProcedure全部在抛UNAUTHORIZED。查了半天最后发现是createContext在 SSR 和客户端请求中拿 cookie 的时机不一样。App Router 下如果你在服务端组件里直接调用 tRPCheaders()可以拿到但如果是在普通的 middleware 流程里需要手动传递 header。落地解法把 session 解析放到createContext里统一从req.headers.get(cookie)读取不要依赖框架的隐式注入。5.2 Prisma 的findUniqueOrThrow在 tRPC 里抛出非预期错误当我使用findUniqueOrThrow时如果找不到记录Prisma 会抛一个 P2025 错误这个错误默认不会被 tRPC 转义成用户友好的 JSON而是变成一个笼统的INTERNAL_SERVER_ERROR前端组件会直接进入isError状态。用户压根不知道自己到底错在哪里。后来我在 tRPC 最外层加了一个 error formatter把所有 Prisma 错误码映射为对应业务消息再统一处理。5.3 嵌套路由的泛型推导偶尔会“绕晕”tRPC 的 router 支持嵌套例如user.list和user.detail。在 router 结构很深时TypeScript 的类型推断会让编辑器卡顿几秒这不算 bug但会影响开发体验。我一度把嵌套层级从六层砍到了三层换来的是瞬时响应和可读性提升。嵌套适度才是真道理。5.4 React Query 缓存失效不及时乐观更新虽爽但如果某个请求失败缓存没有及时回滚列表会停留在“看似成功但实际没执行”的状态。我在一开始没写onError回滚导致同事演示功能时出了个尴尬 bug。后来养成的习惯是任何 mutation都要把onError里的 cache 回滚当作必填项而不是可选项。5.5 数据库连接被默认配置耗尽本地开发时连接池只有默认 4 个连接每次热重载都会新建一个 PrismaClient连接数很快就满了。解决方式有两个在全局对象上复用 PrismaClient 实例生产环境单独设置连接池大小。这个坑几乎人人会踩提前写进项目 README 能省一堆排查时间。5.6 类型“误差”其实来自 lazy 加载我在 Next.js 里为了减少首包体积给 tRPC client 加了 dynamic import。结果首次渲染时tRPC 的 query 状态在 hydration 完成前会出现undefined。这不是类型系统的问题而是 React 生命周期和动态加载打架。解决方案是在根布局里保留一个 static import 的临界包或者用 Suspense 包住列表组件二者选其一不要同时上动态加载。这六个坑没有一个能靠“多写几行代码”绕过去核心都是要理解框架时机的细节。我把这些经验整理成表格问题根因解法session 拿不到SSR 与 middleware 的 header 差异统一在 context 解析Prisma 错误变 500错误未被格式化加全局 error formatter深层嵌套卡类型泛型推导路径过长控制嵌套深度缓存不回滚缺少 onError回滚逻辑必写连接耗尽热重载 默认连接池小全局复用 调池大小Hydration 状态为 undefined动态导入 SSR 时序静态导入或 Suspense 收口6. 部署到一台 2C2G 服务器后的真实手感很多人做技术调研只看“本地跑得爽”但对我来说一个全栈项目值不值得用还得看它在穷酸服务器上的表现。t3code 最终被我部署在了一台 2 核 2G 的 Linux 小机器上数据库用的同一台机器上的 PostgreSQL Docker 容器。整体压测和持续跑了两个月之后有几个真实的数据和感受值得分享。6.1 构建产物与启动情况用docker build构建全栈应用前端静态资源由 Next 输出服务端进程用 tRPC 的 Node adapter 跑。最终镜像大约 450MB里面有 Node 运行时、Next 产物、Prisma engine。启动后内存占用稳定在 500MB 左右对于 2G 内存的 VPS 来说还能再塞一两个小服务。首屏接口响应时间在 120ms 左右局域网内测试。作为对比同一台机器上跑传统 REST 接口大概也是 80-150ms。tRPC 的批量和压缩机制并没有引入可感知的性能损失反而因为减少了网络往返列表刷新时的体感更快。6.2 资源占用和并发表现我写了个简单的脚本模拟 20 个并发用户同时刷新列表接口持续 5 分钟。CPU 峰值到了 80%没有出现内存溢出也没有一次连接池超限。这当然不算高并发但已经能覆盖一个小型工具类产品的日常负载。如果你做的是 To B 后台系统、个人工具站或团队内部系统这个量级完全够用。6.3 哪些场景不适合这套部署形态说句公道话如果你是做 C 端高并发产品或者系统需要跨地域多活t3code 这种单机 Docker 部署形态就明显不够看了。到时候你需要把数据库拆出来单独托管、把 Next 应用放到边缘节点再加上更完整的可观测体系。但走到那一步时开发期的类型安全收益仍然会保留下来不会因为部署架构升级而失效。7. 如果让我重新走一遍几条实在建议项目收尾阶段复盘整个开发过程我想给准备尝试类似路径的朋友留几句真心话。第一不要盲目追求全链路类型安全。t3code 里我最满意的是数据库到前端这一条主链路的类型闭环但并不是所有边界都要纳入类型系统。比如日志输出、外部回调、第三方 webhook这类“不受你控制”的入口保持运行时校验就好强行做类型推导只会让你陷入无尽的any抗争。第二小功能直接写不需要为了分层而分层。刚开始搭 t3code 时我下意识地给每个模块都建了 service、repository、controller 三个文件夹结果一个清单应用一半的代码都是空壳。后来全删掉只保留路由函数和少量工具函数代码量少了三分之一维护却轻松得多。第三把“改完一处编译器帮你找下一处”作为衡量标准。如果你改了数据库模型前端引用处没有同步爆红那一定是某个地方缺少类型传递。这类问题不要靠眼睛找要靠跑一次tsc --noEmit来确认。第四把踩坑清单固化到项目文档里。前面列的六个问题我全部写进了 README 的 Troubleshooting 章节。后来有两周没碰代码回来一看还能快速上手靠的就是这份清单。第五也是最重要的一条这类实验项目的核心价值不在于“用了什么新技术”而在于“你有没有逼自己回答为什么”。t3code 里每一项技术选型都不是为了赶时髦而是在回答同一个问题怎么让一个人高效地维护一条完整产品链路。答案不是唯一的但只要你能清晰地讲出“为什么选它”那这个实验就已经值得了。最后再分享一个小技巧如果你照着 t3code 的思路去搭项目请务必在第一天就把 ESLint、Prettier 和 TypeScript strict mode 全部打开。这些前期看似无关紧要的设置会在项目后期帮你挡掉大量低级错误。类型安全的幸福感一半来自框架设计另一半来自从一开始就不放任自己写脏代码。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑