我最早用 t3code 这个名字,其实只是给一套全栈种子仓库随便起的代号,没想到它后来成了我所有参考工程里最常用的起点。在这之前,我其实对"端到端类型安全"这种说法不太当回事,觉得 TypeScript 都普及到这个程度了,前后端类型还能对不上吗?直到把一个项目从接口定义一路做到前端页面,才发现问题根本不在"有没有用 TS",而在"类型到底有没有被当成一等公民来管理"。
简单说一下 t3code 是什么:这是一套现代全栈应用的种子工程,把"类型优先、按需定制、默认可测试"三个原则固化进项目配置里,基于 Next.js、tRPC、Prisma、Zod、Tailwind 这套组合,让你从第一行代码开始,前后端共享同一套类型系统。接口、校验、数据库模型之间不再脱节,后端删了一个返回字段,前端编译期直接报红。
这篇文章会把 t3code 从设计思路、选型逻辑、完整搭建过程,到我在真实业务里踩过的坑,一条条梳理清楚。适合想把自己的全栈工程做得更规范、对类型安全有执念的前后端开发者,也适合准备从脚手架转型到完整工程体系的朋友参考。
1. t3code 的设计思路与整体拆解
1.1 先搞清楚它到底要解决什么问题
很多项目表面上没毛病:用了 TypeScript、写了 API 文档、建了代码规范,但开发效率就是上不去。我在维护 t3code 之前,被这几个问题反复折磨了很多次。
第一个是类型割裂。后端接口返回的对象,前端往往要再另写一个 interface 或 type 去描述。一次两次没问题,字段一多就开始失控。后端改了一个字段名,前端还按老名字取数据,页面上一行字都渲染不出来,但编译期完全无感知,只能靠运行时 console 排查。
第二个是脚手架重复劳动。每开一个新项目,都要重新配 ESLint、Prettier、tsconfig、路径别名、环境变量模板、日志规范。这些配置我从 GitHub 上一个项目复制到另一个项目,每次都要修半天,修完发现版本还不一致。
第三个问题更隐蔽,就是"约定只存在于文档里"。团队规定 API 参数必须校验、数据库访问必须走统一封装,但规范文档放在 Wiki 里吃灰,代码评审靠人盯,只要有一个成员图省事,规则就破了。
t3code 的设计目标,就是要让这些约定从"人自觉"变成"系统强制"。类型不对编译不过,参数不校验写不进 router,格式不统一连提交都不让提。把规矩融入工具链,而不是印在文档里。
1.2 三个 T 的含义:Type-first、Tailored、Testable
t3code 里的 t3,不是我随便凑的三个字母,它代表三条设计原则。
第一条是 Type-first,类型优先。数据从数据库模型、API 入参出参、前端组件状态,整条链路只有一个类型源头。数据库 Schema 改了,Prisma Client 类型自动变;Router 接口改了,前端 useQuery 的返回值类型自动跟着变。你不需要在多个地方反复声明同一份类型,改一处,全局生效。
第二条是 Tailored,按需定制。t3code 不是一个冗余的全家桶,它的依赖是"按场景拼装"的。项目要用数据库就引入 Prisma,要用认证就引入 NextAuth,不用就不选。这样新项目起步时不需要背着几十个用不到的依赖跑,维护成本低,升级也不需要等所有包对齐。
第三条是 Testable,默认可测试。类型安全解决的是"能编译",但业务逻辑对不对,还得靠测试兜底。t3code 里把单元测试、组件测试、端到端测试的脚本和目录结构都预留好了,还接入了提交前的质量闸门。测试不是"以后有空再补",而是从第一天起就是工程的一部分。
1.3 哪些场景适合,哪些场景别硬上
t3code 不是一个万能框架,它有自己的边界。我实际用下来的感受是,下面几类场景非常适合:
- 中小型全栈应用:比如后台管理、内容平台、工具类产品,CRUD 多,业务实体之间关系复杂。
- 快速验证的 MVP,但又预感到会长久维护:前期靠脚手架快,后期靠类型安全稳。
- 希望建立团队开发规范的场景:与其靠 leader 一遍遍提醒,不如让编译器和提交钩子来卡。
但有些场景,我不建议强行套它。如果你所在的组织已经是微服务架构,各个服务用不同语言,前后端完全分离部署,tRPC 的"同仓共享类型"优势就发挥不出来,这时候上 t3code 反而别扭。还有一个更现实的问题:如果团队里大部分人 TypeScript 基础薄弱,连泛型都还没吃透,端到端类型安全带来的维护优势会被学习成本抵消掉。技术选型不是追新,是看团队现状能不能消化。
2. 技术选型:这条类型安全链路是怎么串起来的
2.1 为什么是 Next.js,而不是 Vite + Express 两套服务
全栈项目最常见的方案是前端 Vite + 后端 Express 或 NestJS,两套服务、两个端口、两套部署流程。我早年也是这么干的,后来发现一个问题:前后端类型共享这件事,在两套独立服务里特别难做。
你当然可以通过 OpenAPI 生成前端类型,但前提是后端必须严格维护好 schema 定义,一旦手写接口和 schema 不同步,生成出来的类型就是错的。每次接口变更,都要重新跑一遍代码生成器,跑完还要处理类型映射的边界情况。
Next.js 的价值在于,把渲染层和服务层放进同一个 TypeScript 项目里。App Router 的 Server Component 可以直接读取数据库,也可以直接调用 tRPC 的 service 层,不需要跨域、不需要网络请求封装、不需要额外的 RPC 协议。部署时也简单,Vercel、Docker、Node 环境都能跑,一个应用就是一个整体。
当然,也有 trade-off。如果公司已经有很成熟的后端服务,微服务加起来几十个,前端只是其中一个消费者,那你应该继续走传统前后端分离,而不是为了类型安全把后端搬进前端项目。t3code 的全栈模式,更适合"一个团队负责一个完整业务模块"的形态。
2.2 tRPC 把 API 从文档变成了函数调用
在没有 tRPC 之前,常见的 API 对接方式有两种。REST 风格要维护 OpenAPI 规范,前端根据规范生成客户端,虽然也算自动化,但中间多了一层"规范和代码必须保持一致"的约束。GraphQL 更麻烦一点,你需要在 schema 定义语言里声明类型,再在 resolver 里写业务逻辑,类型声明和业务实现天然就分了两层。
tRPC 的思路很直接:把后端暴露的函数当成一个普通对象,前端直接调用这个对象的方法。没有额外的 IDL 语言,没有生成步骤,服务端定义 router,前端通过类型推导就能拿到完整的入参和返回类型。
这套机制用生活化的类比讲:传统 API 对接像是两个不同公司的员工交流,得通过翻译、邮件、合同来对齐信息;tRPC 像是同一个团队里的两个同事,直接面对面沟通,对方递过来的东西是什么结构,你一眼就能看懂,而且有 TypeScript 编译器帮你盯着。
有一点必须说清楚:tRPC 的优势建立在"前后端同在一个 TS 项目"这个大前提上。如果你以后要开放 API 给第三方客户端,或者要做移动端原生应用,tRPC 就不是最优解了,那时还是老老实实上 REST 或 GraphQL。t3code 默认把 tRPC 放在核心位置,所以在选它之前,先想清楚你的 API 消费者到底是谁。
2.3 Prisma:让数据库模型成为整条链路的起点
数据库是几乎所有业务的数据终点,也是类型漂移最容易发端的地方。表结构里加了一个字段,后端查询里漏了,前端页面里没展示,整个链路可能只有数据库自己知道。t3code 选择 Prisma,看中的就是它"Schema 驱动"的能力。
Prisma 用一个 schema.prisma 文件描述数据模型,你可以用 migrate 命令生成可追溯的 SQL 迁移文件,也可以直接用 prisma generate 生成类型安全的客户端。关键在于,这个客户端是直接从 Schema 编译出来的,表里有什么字段,查询结果就有什么字段,类型完全对应,没有手写映射层。
我在实际使用中觉得,Prisma 对开发效率的提升主要来自两件事:一是 migration 文件可以放进版本控制,数据结构的演进历史完整可查,同事拉代码后跑一遍 prisma migrate dev 就能同步最新结构;二是查询 API 的返回类型天然嵌套了外键关系,不需要写一堆 join 和类型断言,前端拿到的对象本身就是结构化好的。
代价也有:Prisma 在复杂查询上的灵活性不如手写 SQL,特别是一些账单统计、报表汇总的场景,你可能会被迫用 raw query。我的建议是常规 CRUD 交给 Prisma,极端复杂查询单独写 SQL,然后在类型层做好封装,别让裸 SQL 穿透到业务代码里。
2.4 Zod:把编译期的安全感延续到运行时
TypeScript 类型有一个众所周知的短板:它是编译期的,运行时会消失。你可以在编译期保证函数的入参是 string,但永远无法保证 HTTP 请求发过来的 body 真的是 string。防御性校验还是得回到运行时。
Zod 在这里的价值,是让运行时校验和 TS 类型合体。你定义一个 zod schema,它既能做运行时校验,又能通过 z.infer 推导出 TS 类型,一份定义,两处使用。更进一步,tRPC 的 input 可以直接接收 zod schema,省掉了一堆手写类型校验的样板代码。
比如一个创建文章的接口,你要求 title 不能为空,且长度不能超过 100。在传统方案里,你得写一个校验函数,再写一个 TS 类型,再保证前端传入的格式和后端一致。在 t3code 里,只要写一个 zod schema,tRPC 自动完成校验和类型推导,前端调用时传错类型直接编译报错。
运行时校验这件事,新手容易觉得多余,但我在生产环境里被坑过太多次了。客户端被攻击、第三方回调数据格式异常、数据库里混入手工修正的脏数据,这些场景编译期完全看不见,Zod 是最后一道闸门。
2.5 外围质量闸门:lint、typecheck、prettier 的配合
t3code 里配置了一整套质量检查工具,它们各管一段,缺一不可。我用表梳理一下:
| 工具 | 检查内容 | 在 t3code 中承担的角色 |
|---|---|---|
tsc --noEmit | TypeScript 类型检查 | 保证类型正确性,这是类型安全的地基 |
| ESLint | 代码规范与错误模式 | 检查未使用变量、反模式、React Hooks 依赖等 |
| Prettier | 代码格式 | 统一缩进、引号、分号,消灭格式化争论 |
| Vitest | 单元测试 | 覆盖工具函数与核心业务逻辑 |
| Playwright | 端到端测试 | 模拟真实用户操作,验证关键流程 |
| Husky + lint-staged | 提交前自动执行 | 只在暂存文件上跑检查,避免全量检查太慢 |
这条链路的思路是:本地提交前先挡住低级错误,CI 里再跑全量检查兜底。如果把质量检查都压到 CI 才做,开发者的反馈回路太长,改一次代码等一次流水线,效率损失很大。放在提交前,每个提交都是经过类型检查、代码规范、单元测试三重过滤的产物,代码评审只需要关注业务逻辑本身,省下大量精力。
3. 实操过程:从 create-t3-app 到第一个类型安全的 CRUD
3.1 初始化项目
t3code 的基座来自 create-t3-app,它会让每一步都通过交互式命令行选择,避免新手一次性接触太多概念。执行:
pnpm create t3-app@latest随后会有一系列选项,我对每个选项的取舍逻辑是:核心栈固定选前的,外围组件按需选。一个典型的初次配置是勾选 Next.js、Tailwind CSS、tRPC、Prisma、ESLint,认证模块看你项目是否需要再决定,日志库和打包分析工具可以先不开。
这里有个新手容易犯的错误:一上来把能勾的都勾上。心智负担先不提,光是 NextAuth、Drizzle、Tailwind 这些模块互相之间还有版本联动问题,报错时根本分不清是哪个包出的问题。我建议第一次接触 t3code 时,只保留自己马上会用的模块,跑通之后再补装。
初始化完成后,项目自带一套登录示例和一课一练的注释结构。你可以直接删掉 demo 代码,或者保留看陈设。我的习惯是先把整体目录读一遍,搞明白每一层是干什么的,再动手改。
3.2 理解目录结构
create-t3-app 生成的目录结构非常讲究,核心就是划分客户端和服务端的边界:
prisma/ schema.prisma # 数据模型定义 src/ app/ # Next.js App Router 页面 api/trpc/[trpc]/route.ts layout.tsx page.tsx server/ api/ root.ts # tRPC 根 router routers/ post.ts # 业务 router 示例 trpc.ts # tRPC 实例、上下文、鉴权封装 db.ts # Prisma Client 单例 trpc/ server.ts # 服务端 tRPC 调用器封装 react.tsx # 前端 React Query hooks最核心的规则是:客户端代码不能直接访问src/server目录里的东西。src/server/db.ts只能在服务端使用,前端页面要拿数据,必须经过 tRPC router 或者 Server Component 的封装层。这个边界在文件系统层面就被 tsconfig 的路径区分加上代码评审守住了,不会出现前端一股脑把数据库连接引进去的灾难。
src/trpc/react.tsx是前端独有的封装,它把 tRPC 的 query、mutation 转换成了 React Query 的 hooks。你写业务时不需要关心底层是 React Query 怎么缓存,只需要调用api.post.list.useQuery()就好了。
3.3 定义数据模型并迁移
我以经典的"文章+作者"模型为例,展示 t3code 里数据建模的完整流程。打开prisma/schema.prisma,添加:
model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User? @relation(fields: [authorId], references: [id]) authorId String? @@index([authorId]) } model User { id String @id @default(cuid()) name String? email String @unique posts Post[] }这里有几个细节值得展开说。@default(cuid())比自增数字 ID 更适合分布式和多环境部署,不容易撞号。@updatedAt会在每次更新时自动写入时间,省了手动维护更新时间戳的麻烦。@@index([authorId])是为外键字段加索引,Post 表里按 author 查文章是很高频的操作,没有索引表数据一多查询就变慢。
定义好模型后执行迁移命令,让数据库结构和 Schema 对齐:
pnpm prisma migrate dev --name add_post这条命令会生成一个prisma/migrations/下的迁移文件,同时重新生成 Prisma Client。我强烈建议把这个迁移文件提交到 Git,团队成员拉下来后跑pnpm prisma migrate dev就能同步结构,整条 Schema 演进历史可回溯,出问题还能看当时改了什么。
3.4 写第一个 tRPC router
数据模型有了,接下来写 API。t3code 里不会出现"路由层+控制器层+服务层"三层设计,tRPC router 本身就是接口层,业务逻辑可以直接写,也可以拆到单独 service 文件里。以文章接口为例:
import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, }); }), byId: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id } }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });publicProcedure表示无需登录即可访问,protectedProcedure会先走 NextAuth 会话检查。ctx上下文里已经挂好了db和session,你不用在业务里反复处理鉴权逻辑。Zod schema 直接作为 input 校验器的作用也体现出来了:前端调用时传入的 title 如果为空或者超过 100 字,会在运行时被拦截,而类型层面前端拿到的错误提示也极其明确。
写完后,要把postRouter挂到根 router 上:
export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, });有个小细节容易踩坑:只新建 router 文件、忘记挂到 root 上,前端调用api.post.list时会一直提示类型不存在。我见过不止一个同事被这个问题卡住,排查半天发现只是挂载遗漏。
3.5 前端页面接入,类型自动推导
后端接口定义完,前端调用页面不需要手写任何请求类型。一个最简单的文章列表页面长这样:
"use client"; import { api } from "~/trpc/react"; export function PostList() { const { data: posts, isLoading } = api.post.list.useQuery(); const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: async () => { await utils.post.list.invalidate(); }, }); if (isLoading) return <div>加载中...</div>; return ( <div> {posts?.map((post) => ( <div key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </div> ))} </div> ); }注意看:useQuery返回的posts类型,是 tRPC 后端 router 自动推导出来的。如果你在后端给 Post 模型加了字段,前端这里不用动任何代码,类型就自动带上新字段;如果后端删了字段,这里直接编译报错。这就是端到端类型安全的直接体现。
onSuccess里调用utils.post.list.invalidate()的作用是让 React Query 重新拉取列表数据,这样新增文章后页面自动刷新,不用手动 reload。React Query 的缓存机制和 tRPC 深度集成后,写前端数据流的感觉接近"本地状态管理",不需要手动维护请求状态和 loading 状态。
3.6 把质量闸门接进提交流程
真正的工程化不能靠自觉,要把它接到流程里。t3code 里我用 husky 加 lint-staged 实现提交前自动检查。先保证 package.json 里的脚本齐全:
{ "scripts": { "lint": "eslint .", "typecheck": "tsc --noEmit", "format": "prettier --check .", "test": "vitest run" } }然后配置 husky 的 pre-commit 钩子:
pnpm exec husky add .husky/pre-commit "pnpm lint-staged"lint-staged 的配置可以这样写:
{ "lint-staged": { "*.{ts,tsx}": ["eslint --fix", "prettier --write"], "*": ["prettier --write --ignore-unknown"] } }这套配置的意义在于:每次执行git commit,只会检查暂存区里准备提交的文件,而不是全项目扫一遍。改动多时全量检查会很慢,很多人为了省时间会绕过钩子,lint-staged 把检查范围缩小后,几秒钟就完成,开发者就不抵触了。
typecheck 要不要放进 pre-commit,我曾经犹豫过。项目大了以后全量类型检查也要十几秒,有人会觉得烦。但我坚持保留,因为类型错误是影响面最大的一类问题,与其等到 CI 跑完再反馈,不如在本地提交那一刻就堵住。
3.7 部署时最容易忽视的几件事
部署环节我踩过不少坑,这里挑三个最常见的说。第一个是环境变量。Next.js 构建时,DATABASE_URL、AUTH_SECRET、NEXTAUTH_URL这类变量必须在部署平台配置好,本地.env里的值不会自动同步到线上。第二个是 Prisma Client 的生成时机。很多 CI 环境在安装依赖后会执行postinstall脚本,但如果没有,需要手动在构建前加一步pnpm prisma generate,否则线上构建会报找不到 Prisma Client。
第三个是服务器端数据库连接的连接数问题。开发环境用 SQLite 无所谓,生产环境用 PostgreSQL 时,默认连接池配置需要根据并发量调整。如果某个 Serverless 平台会为每个请求创建新实例,要特别小心连接数耗尽,必要时配置连接池或限制实例数。t3code 默认用 SQLite 起步适合本地演示,真正上线前一定要切换成 PostgreSQL 并验证连接行为。
4. 踩过的坑:t3code 在真实开发中的排查记录
4.1 Prisma 迁移报错,表结构变更那点事
有一次我给 Post 表加一个枚举字段,执行prisma migrate dev时直接报错了。原因是枚举类型和已有数据之间产生了冲突:迁移脚本尝试给已有行添加新字段时,需要用默认值或者 nullable,但我定义成了必填且没有默认值。类似的问题多得很,比如给已有表加唯一约束但数据里有重复值,比如删字段时外键关系还挂着。
我的处理习惯是:改 Schema 之前先想"存量数据怎么办"。能设置默认值就设默认值,不能设默认值就先允许 nullable,跑一个数据补齐脚本后再收紧约束。如果迁移文件已经生成但执行失败,先去prisma/migrations里看下一个迁移文件的内容,确认 SQL 是否符合预期,再谨慎执行migrate reset。reset 会清空数据,只适合开发环境,生产环境千万不要手滑。
4.2 tRPC 类型不更新,害我折腾一下午
我遇到过一件很烦的事:后端 router 明明加了新方法,前端调用时编辑器里一直提示类型不存在。重启开发服务器也没用。后来发现是 dev server 的增量编译缓存问题,tRPC 的新类型没有触发 React Query 的类型声明重新加载。还有一次,新 router 文件创建了但忘了挂到 root,前端api.newModule一直不存在,这类问题单看报错信息根本看不出来。
经验是:遇到 tRPC 类型不更新的情况,先检查三件事——router 是否挂到 root、tsc --noEmit是否能通过、dev server 是否重启过。另外,把src/trpc/react.tsx里的类型导出做一个显式暴露,也能减少怪异问题。如果实在没头绪,直接重启 VSCode 的 TS Server:插件或者缓存版本不一致时,这个操作能解决 80% 的"明明没错但报错"。
4.3 在 Server Component 里硬套 useQuery,直接报错
App Router 刚出来时,我很自然地想在 Server Component 里调用api.post.list.useQuery(),结果浏览器控制台直接报错,提示 hooks 只能在 Client Component 里使用。这是"use client"边界不清导致的。每个使用到 hooks、浏览器 API、React Context 的组件,都需要显式声明"use client"。
Server Component 里要获取数据,t3code 提供了createCaller这种方式:
import { createCaller } from "~/server/api/root"; export default async function Page() { const caller = createCaller(); const posts = await caller.post.list(); return <PostList initialPosts={posts} />; }这个思路是把数据在服务端先查好,作为 props 传给客户端组件,客户端再基于初始数据渲染。好处是首屏渲染更快,SSR 的 Markup 里直接包含内容,不会闪加载态。坏处是必须处理好"服务端数据"和"客户端 React Query 缓存的同步",否则刷新交互后状态对不上。
4.4 水合错误:页面突然白屏,控制台提示 hydration failed
水合错误是 Next.js 全栈开发绕不开的坑。它发生的原因是:服务端渲染出来的 HTML,和客户端第一次渲染的结果不一致,React 在"接管"页面时发现两边的结构对不上,只能报错。常见触发场景有三个:在组件顶层直接调用Math.random()、直接读取Date.now()生成渲染内容、依赖 window 对象做判断但服务端没有 window。
t3code 的默认模板基本不会触发这个问题,但业务代码里经常踩到。我的规避方法是:把随机数、当前时间、浏览器本地状态这类"环境相关值"全部放到useEffect里延迟生成,服务端和客户端初次渲染都用稳定数据。比如倒计时组件,服务端先渲染一个通用的占位时间,客户端挂载后再开始走秒,两边渲染的 HTML 完全一致,水合就过了。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 前端调用 api 报类型不存在 | router 未挂载到 root 或 dev 缓存 | 检查 root 挂载,重启 dev server |
| Prisma migrate dev 报错 | Schema 与存量数据冲突 | 加默认值或设为 nullable,先备份数据再 reset |
| Server Component 里 useQuery 报错 | 把 hooks 用在了服务端组件 | 改用 createCaller 或移入 "use client" 组件 |
| 控制台报 hydration failed | 服务端和客户端渲染不一致 | 把随机数等放到 useEffect 中生成 |
| 线上 TypeScript 编译失败 | 环境缺少 prisma generate | 在构建命令前补pnpm prisma generate |
5. 从 t3code 继续生长:扩展路径与个人经验
5.1 从种子项目到完整业务的推进顺序
t3code 不是终态,它只是一个高质量的起点。从它往完整业务系统走,我建议按这样推进:先把认证闭环彻底跑通,确保用户能注册、登录、退出,并能通过 protectedProcedure 保护需要登录的接口;再把第一组领域模型建好,比如电商里的商品和订单,把它们的 CRUD 和业务规则用 tRPC router 完整实现。
之后再做权限模型、文件上传、后台管理、支付回调这些外围能力。每一步都坚持一条铁律:数据和接口的类型必须保持共享,不能让某个模块绕过 tRPC 裸写 fetch。某次如果实在需要调用第三方 HTTP API,也要在外围封装一层能推导类型的方法,再暴露给业务代码。
从什么时候开始需要 monorepo?我发现当项目拆成管理后台、客户端前台、独立定时任务三个以上应用,且它们共享大量数据库模型和工具函数时,就该把它拆成 pnpm workspace 了。t3code 的单仓结构这时会成为瓶颈,但过渡并不痛苦,因为核心的类型模型和 router 文件几乎可以原样搬进 monorepo 的 packages 里。
5.2 在团队里推行类型安全的小技巧
推行任何工程规范,最难的不是技术,而是让团队成员相信"这事值得做"。我这几年带团队的经验是:不要上来就要求全员理解泛型、infer、类型体操,而是定几条简单的硬性规则,靠工具强制落地。
第一条,code review 时必须确认"前端没有手写接口返回类型"。如果看到责任人有声明的interface PostDTO和后端模型对应,就可以直接退回,要求改成从 tRPC 的调用结果去 inference。只要坚持一个迭代,团队就尝到了改字段不用全局搜索的甜头。
第二条,禁止绕过 tRPC 的前端裸 fetch。新增的接口都必须走 router 暴露,特殊场景需要第三方接口时,包一层可推导类型的 service。规则简单,执行成本低,效果立竿见影。
第三条,在 CI 里加一个类型检查任务,任何 PR 如果 typecheck 失败就不能合并。技术债能还就还,团队执行力是逼出来的,不是靠自觉。
5.3 维护 t3code 几年后,我最想强调的三句话
第一句:类型不是限制自由,而是把人的脑力从机械的对齐接口工作中解放出来。你省下的时间,会实实在在地变成对业务逻辑的思考。
第二句:种子项目要做的是"省掉重复配置",而不是"把原理藏起来"。哪怕 t3code 把一切都配好了,我也建议新手把 tsconfig、Prisma Schema、tRPC 上下文这三块源码反复读懂。工具会升级,原理不会过时。
第三句:质量建设要放在门槛最低的位置。提交前自动检查比 CI 人工审核有效,类型推导比文档约定有效,运行时校验比口头承诺有效。把最容易跳过的地方堵上,制度的牢靠程度完全取决于最低门槛那一个环节。
我自己维护 t3code 时有一个固定的习惯:每次新建业务页面,先让自己接受一个拷问——如果后端接口在某个夜里悄悄改了字段,我能不能在第二天的编译期就发现?能,是因为类型链路还通着;不能,那就别急着写业务,先回去把缺口堵上。这套工程化体系没有给我带来任何炫技的快感,它只是把我从无穷无尽的"接口又对不上了、字段又找不到了、线上又出错了"里一点点捞了出来。如果你也在经历同样的拉扯,不妨从一份种子工程开始,把类型安全这条护城河挖起来。