资讯详情

资讯详情

t3code 全栈实践:TypeScript 端到端类型安全与代码分层指南

1. 从“t3code”这个代号说起它到底指什么第一次看到“t3code”这个词很多人会一头雾水。它不像“Python入门”“Docker实战”那样自带说明更像是一个圈内人才懂的暗号。我最初接触这个词是在一个开发者闲聊群里有人甩出一句“t3code那套东西跑通了没”底下立刻有人接话。这种场景说明一件事t3code不是某个官方标准术语而是特定圈子里对一类技术实践的简称。那它到底指什么结合我后来翻资料、跟人交流、自己动手试的经历t3code大概率指向的是**“TypeScript 三层代码架构”或者“T3 Stack 风格的代码组织方式”。T3 Stack 是近几年在前端社区火起来的一套技术组合核心成员包括 Next.js、TypeScript、tRPC、Prisma、Tailwind CSS 这几样。而“t3code”这个词更像是把“T3 Stack”和“code”捏在一起用来指代用这套技术栈写出来的、有统一规范的项目代码**。为什么这个词会被人反复提起因为前端圈有个老毛病技术选型太自由自由到同一个团队里三个人能写出三种风格。有人用 Redux有人用 Zustand有人直接 useState 满天飞有人把接口请求写在组件里有人抽到 hooks有人搞 service 层。项目小的时候无所谓一旦代码量上去、人员流动起来维护成本就爆炸。t3code 这类概念之所以有市场本质上是大家在寻找一套“不用每次重新发明轮子”的默认答案。我自己的判断是t3code 不是一个必须严格遵守的规范而是一套经过社区验证的、默认合理的代码组织思路。它的价值不在于“你必须这么写”而在于“如果你不知道怎么写先这么写大概率不会错”。这篇文章我就围绕这个核心把 t3code 涉及的关键决策点、实操步骤、容易踩的坑以及我自己的经验完整地拆一遍。适合谁看如果你正在用 TypeScript 做全栈项目或者团队里代码风格开始失控又或者你单纯想看看别人是怎么组织项目的那这篇内容应该能给你一些直接能用的东西。2. t3code 的骨架为什么是这几样技术凑在一起2.1 端到端类型安全才是真正的粘合剂很多人第一次看 T3 Stack 的组成会觉得“这不就是把几个热门库堆在一起吗”。Next.js 负责渲染tRPC 负责通信Prisma 负责数据库Tailwind 负责样式——单看每一个都不新鲜。但 t3code 真正的精髓不在于用了哪些库而在于这些库之间的类型是怎么打通的。传统的前后端分离项目里类型安全是断层的。后端定义了一个接口返回{ id: number, name: string }前端得手动写一个 interface 去对应。后端改了字段名前端不跟着改编译不会报错只有运行时才炸。t3code 要解决的就是这个断层。它的做法是数据库 schema 用 Prisma 定义Prisma 自动生成 TypeScript 类型后端用这些类型写业务逻辑tRPC 把后端的类型直接暴露给前端前端调用时自动获得完整的类型提示。这条链路一旦跑通效果非常直观。你在前端写api.user.getById.useQuery({ id: 1 })的时候编辑器会告诉你返回的对象里有哪些字段、每个字段是什么类型。后端把name改成fullName前端立刻飘红。这种体验用过就回不去。我试过在一个中型项目里推行这套东西最明显的收益是联调时间大幅缩短——以前前后端要对字段对半天现在类型对上了基本就通了。2.2 为什么不是 REST 加 OpenAPI 生成代码有人会问我用 REST 接口再通过 OpenAPI 生成前端类型不也能实现类型安全吗理论上可以但实操下来有几个绕不开的麻烦。第一OpenAPI 需要额外维护一份 spec 文件这份文件和实际代码容易脱节第二代码生成步骤增加了构建复杂度CI 里多一个环节就多一个出错点第三生成的类型往往是“死”的缺少 tRPC 那种从路由定义直接推导类型的灵活性。tRPC 的思路更直接后端定义 procedure 的时候类型就已经确定了前端通过一个泛型客户端去调用类型是推导出来的而不是生成出来的。这意味着你不需要维护中间产物改完后端代码前端立刻感知。当然代价是前后端必须用同一套 TypeScript 代码库不能跨语言。但对于全栈 TypeScript 项目来说这个代价完全可以接受。2.3 Prisma 在 t3code 里扮演的角色被低估了大部分讲 T3 Stack 的文章会把重点放在 tRPC 上但我觉得Prisma 才是整个类型链路的起点。因为所有业务类型最终都来源于数据模型如果数据模型这一层是模糊的上面的类型安全就是空中楼阁。Prisma 的做法是用一套 schema 语言描述数据模型然后自动生成客户端。这个客户端的好处是查询结果自带类型关联查询也能推导。比如你定义 User 和 Post 是一对多关系写prisma.user.findUnique({ where: { id: 1 }, include: { posts: true } })返回类型里自动包含 posts 数组每个 post 的字段也清清楚楚。这种体验比手写 SQL 再手动映射类型要舒服太多。我在实际项目里踩过一个坑早期为了图快直接用prisma.$queryRaw写原生 SQL结果返回类型是unknown类型安全直接断掉。后来老老实实回到 Prisma 的查询 API虽然有些复杂查询写起来啰嗦一点但类型链路完整长期看是划算的。3. 动手搭一套 t3code 风格的项目从零到跑通3.1 初始化项目时最容易忽略的三个配置现在搭 T3 Stack 项目最省事的方式是用create-t3-app这个脚手架。一条命令下去它会问你几个问题要不要 tRPC、要不要 Prisma、要不要 NextAuth、要不要 Tailwind。勾选完自动生成项目结构。但脚手架生成的东西只是起点有三个配置项如果不注意后面会反复回来改。第一个是TypeScript 的 strict 模式。脚手架默认会开但有些人嫌报错多会关掉。我的建议是千万别关。t3code 整套东西的价值建立在类型严格的基础上strict 关了等于自废武功。如果第三方库类型有问题用skipLibCheck绕过而不是降低整体严格度。第二个是路径别名。Next.js 默认支持/指向src目录但 tsconfig 里的paths和实际目录结构要对齐。我见过有人把代码放在根目录的app文件夹而不是src/app结果别名失效到处是../../..。初始化时就把目录结构定好后面省很多事。第三个是环境变量的校验。T3 脚手架自带一个env.js用 zod 校验环境变量。这个文件很多人直接忽略但它其实很有用。数据库连接串、密钥这些如果缺失或格式不对项目启动时就会报清晰的错误而不是运行到一半才崩。我建议把项目里所有环境变量都加进去养成习惯。3.2 定义第一个数据模型和对应的 procedure假设我们要做一个最简单的待办事项应用。第一步是在prisma/schema.prisma里定义模型model Todo { id Int id default(autoincrement()) title String done Boolean default(false) createdAt DateTime default(now()) }然后跑npx prisma migrate dev --name initPrisma 会生成迁移文件并更新客户端类型。这时候在代码里 importPrismaClient就能获得完整的类型提示。接下来定义 tRPC 的 router。在src/server/api/routers/todo.ts里import { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; export const todoRouter createTRPCRouter({ getAll: publicProcedure.query(({ ctx }) { return ctx.prisma.todo.findMany({ orderBy: { createdAt: desc } }); }), create: publicProcedure .input(z.object({ title: z.string().min(1) })) .mutation(({ ctx, input }) { return ctx.prisma.todo.create({ data: { title: input.title } }); }), });这里有几个细节值得说。input 用 zod 校验这是 t3code 的标配。zod 的好处是它既是运行时校验又能推导出 TypeScript 类型一份定义两处用。ctx 里挂着 prisma 实例这是通过 context 注入的方便测试时替换。query 和 mutation 的区分要清晰读操作走 query写操作走 mutation这样前端调用时语义明确。3.3 前端调用时类型是怎么自动流过来的在组件里调用刚才定义的 procedureconst { data: todos, refetch } api.todo.getAll.useQuery(); const createTodo api.todo.create.useMutation({ onSuccess: () refetch(), });注意这里todos的类型是自动推导的你不需要 import 任何类型定义。createTodo.mutate({ title: 买菜 })的参数也有类型检查传错字段编辑器立刻报错。这种“类型跟着数据流走”的感觉是 t3code 最让人上瘾的地方。我实测下来这套东西在中小型项目里开发效率提升非常明显。但有一个前提团队成员得接受 TypeScript 的严格约束。如果团队里有人习惯用any绕过类型检查那整套体系的价值会被稀释。所以推行 t3code 之前先统一团队对类型严格度的认知比技术选型本身更重要。4. 代码分层t3code 真正拉开差距的地方4.1 为什么不能把业务逻辑写在 router 里刚上手 tRPC 的人很容易把所有逻辑塞进 procedure 里。反正ctx.prisma就在手边直接查库返回看起来很方便。项目小的时候确实没问题但一旦业务变复杂router 文件会迅速膨胀成几百行的怪物而且逻辑没法复用——两个 procedure 需要同样的数据处理你只能复制粘贴。t3code 风格的做法是引入 service 层。router 只负责三件事接收输入、调用 service、返回结果。真正的业务逻辑放在 service 里。这样做的直接好处是service 可以被多个 router 调用也可以被定时任务、脚本调用不依赖 HTTP 上下文。测试的时候直接测 service不用起整个 tRPC 服务。我自己的项目里目录结构大概是这样src/server/ api/ routers/ todo.ts trpc.ts services/ todoService.ts db.tstodoService.ts里导出纯函数接收 prisma 实例和参数返回数据。router 里薄薄一层调用。这个结构看起来多了一层但维护成本低很多。4.2 输入校验和业务校验要分开zod 在 input 里做的校验我称之为输入校验——检查数据类型对不对、必填项有没有、字符串长度够不够。但还有一类校验叫业务校验比如“这个用户有没有权限删这条记录”“这个订单状态能不能取消”。这两类校验不该混在一起。输入校验放在 procedure 的.input()里业务校验放在 service 里。原因是业务校验往往需要查数据库而.input()阶段还没有执行查询。我见过有人把权限检查硬塞进 zod 的 refine 里结果 zod 变成了异步的类型推导也变复杂了得不偿失。4.3 错误处理TRPCError 的正确用法tRPC 提供了TRPCError类可以指定错误码。常见的有UNAUTHORIZED、FORBIDDEN、NOT_FOUND、BAD_REQUEST。在 service 里抛这些错误前端能拿到结构化的错误信息。if (!todo) { throw new TRPCError({ code: NOT_FOUND, message: 待办不存在 }); }这里有个经验错误信息不要直接暴露数据库细节。比如 Prisma 抛出的唯一约束冲突原始信息里可能包含表名和字段名直接透传给前端不够安全。在 service 里捕获这类错误转换成友好的提示再抛出。另外前端处理错误时error.data?.code可以用来做差异化 UI比如UNAUTHORIZED跳登录页NOT_FOUND显示空状态。5. 实测中那些文档不会告诉你的坑5.1 开发环境热更新导致 Prisma 客户端实例爆炸这个问题我踩了两次才长记性。在 Next.js 开发模式下每次热更新都会重新执行模块代码。如果你在db.ts里直接export const prisma new PrismaClient()热更新几次之后就会创建一堆 Prisma 实例每个都占着数据库连接很快就报“too many connections”。正确的做法是用全局变量缓存const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const prisma globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma prisma;这个模式在 Prisma 官方文档里有提但很多人初始化项目时不会仔细看。生产环境因为不会热更新所以问题只在开发时出现容易被忽略。5.2 tRPC 的 superjson 配置不对导致日期变字符串tRPC 默认用 JSON 序列化而 JSON 不支持 Date 类型。数据库里查出来的createdAt是 Date 对象传到前端就变成了字符串。如果你在前端用date.getTime()之类的方法会直接报错。解决办法是配置 superjson 作为 transformer。在trpc.ts里import superjson from superjson; export const t initTRPC.contextContext().create({ transformer: superjson, });前后端都要配置缺一不可。我见过只配了后端没配前端的情况结果类型提示显示是 Date实际运行时是 string非常隐蔽。配好之后Date、Map、Set 这些类型都能正确传输。5.3 构建时类型检查拖慢 CI 的应对思路t3code 项目类型多、推导链路长tsc --noEmit跑起来可能要好几十秒。CI 里如果每次提交都全量检查等待时间会让人烦躁。我的做法是本地开发用编辑器的增量检查CI 里才跑全量。另外可以把类型检查拆成独立 job和构建并行跑不阻塞部署。还有一个技巧skipLibCheck: true能跳过 node_modules 里的类型检查对速度提升明显。这个选项有争议因为会漏掉第三方库的类型错误但实际项目里第三方库类型出问题的概率不高收益大于风险。5.4 数据库迁移在团队协作中的冲突处理多人协作时Prisma 迁移文件容易冲突。两个人同时改了 schema各自生成了迁移文件合并代码时迁移历史就对不上了。我的经验是schema 变更前先在群里说一声避免同时改。如果已经冲突了不要手动改迁移文件而是删掉本地未提交的迁移拉取最新代码后重新生成。生产环境迁移更要谨慎。prisma migrate deploy只执行已有迁移不会生成新的。部署前先在 staging 环境跑一遍确认没问题再上生产。我见过直接在生产跑migrate dev的那个命令会尝试重置数据库后果不堪设想。6. 这套东西适合什么场景不适合什么场景6.1 中小型全栈项目是甜点区t3code 这套组合在单人开发或小团队的全栈项目里效率最高。类型链路打通之后一个人从数据库改到前端 UI中间不需要切换思维模式也不需要写接口文档。我做过一个内部工具从零到上线大概一周其中类型安全帮我省掉了大量调试时间。另一个适合的场景是快速原型验证。想法来了定义几个模型写几个 procedure前端页面直接调当天就能看到能点的东西。这种速度在需要快速试错的阶段非常宝贵。6.2 大型项目要谨慎评估团队成本项目规模上去之后t3code 面临几个挑战。第一类型推导在超大代码库下会变慢编辑器偶尔卡顿第二新人上手有门槛tRPC、Prisma、zod 这些概念需要时间消化第三前后端必须同仓同语言如果团队里有专门的 Java 或 Go 后端这套东西就玩不转。我的建议是如果团队已经有一套成熟的 REST 体系不要为了追新强行切换。t3code 的价值在于减少类型断层如果现有体系已经通过其他方式解决了这个问题切换的收益就不明显。6.3 和纯前端项目的区别纯前端项目用不上 tRPC 和 Prisma但 t3code 里的一些思路仍然适用。比如用 zod 做表单校验和类型推导用 service 层隔离业务逻辑保持 TypeScript strict 模式。这些习惯和具体技术栈无关是通用的工程实践。我见过一些前端项目把所有逻辑写在组件里一个组件文件上千行。这种代码用不用 t3code 都难维护。分层意识比技术选型更根本。7. 我在这套实践里攒下的几条硬经验7.1 类型不是越复杂越好刚用 tRPC 的时候容易上头想把所有类型都推导得严丝合缝。但有些场景下过度推导反而让代码难读。比如一个 procedure 的返回类型嵌套了五六层泛型编辑器提示一长串看半天不知道是什么。这时候显式定义一个 interface 反而更清晰。类型安全的目的是减少错误不是炫技。7.2 先跑通再优化别在配置上卡太久我见过有人花两天时间纠结 ESLint 规则、Prettier 配置、husky 钩子结果业务代码一行没写。工具配置是为了服务开发不是目的。我的做法是用脚手架生成默认配置先写业务遇到具体问题再调配置。大部分默认配置已经够用过度定制是拖延症的变种。7.3 日志和监控要提前埋类型安全能挡住编译期错误但挡不住运行时问题。数据库连接断了、第三方接口超时、内存泄漏这些类型系统管不了。项目初期就要把日志和错误上报接好。我用的是简单的结构化日志加一个错误上报服务出问题时能快速定位。别等到线上炸了才想起来没日志。7.4 定期回顾 schema 设计Prisma schema 是整套体系的根基但很多人定义完就不管了。随着业务演进字段会越加越多关系会越来越复杂。我习惯每个月花半小时过一遍 schema看看有没有废弃字段可以删、有没有冗余关系可以简化。schema 干净上面的类型链路才清爽。7.5 文档写在代码旁边t3code 项目里类型本身就是一种文档。但有些决策是类型表达不了的比如“为什么这个字段用软删除而不是硬删除”“为什么这个 procedure 要限流”。这些解释我习惯写在代码注释里或者放在同目录的 README 里。别搞一个独立的 wiki没人会去看代码旁边的注释才是活的。这套东西说到底核心就一句话让类型在数据库、后端、前端之间自由流动把人为的接口对接工作降到最低。技术选型会变但这个思路我觉得会一直有用。你要是正在搭新项目不妨从一个小模块开始试跑通了再逐步铺开。踩几个坑是正常的我上面列的这些能帮你少走点弯路就值了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →