
凌晨兩點我盯著屏幕上那條報錯心里只剩一個念頭再也不手寫樣板代碼了。當時我正在維護一個基于 T3 代碼棧Next.js tRPC Prisma Zod的中型項目數據模型里的字段從字符串改成了枚舉Prisma 這邊改完忘了同步 tRPC 路由里的 Zod schema。結果前端提交訂單時服務端校驗直接把請求給攔了線上反饋接二連三。問題不算大但讓我意識到一個事實整個 T3 生態(tài)最引以為傲的端到端類型安全恰恰在最容易出錯的環(huán)節(jié)——模型、校驗、路由、前端類型之間的手工同步——把壓力全留給了開發(fā)者。我就是為了解決這件事寫的 t3code。它是一個面向 T3 代碼棧的 CLI 代碼生成與同步檢測工具核心思路是把 Prisma 數據模型當作唯一事實來源自動生成 Zod 校驗 schema、tRPC 路由模板、前端類型定義和 API 調用封裝順帶在 CI 里幫你強制檢查模型改了但代碼沒跟上的情況。如果你正在用 Next.js 和 tRPC 寫全棧應用又被重復的 CRUD 樣板拖得心煩這篇文章應該能給你一點實際的參考。1. T3 代碼棧里的重復勞動從一次半夜事故說起T3 代碼棧用著確實爽路由、數據庫、校驗全在 TypeScript 的類型系統里閉環(huán)幾乎每個環(huán)節(jié)都有類型兜底。但閉環(huán)不等于自動鏈條里所有的連接件都需要你手寫去接Prisma 模型定義了數據庫結構Zod 要單獨寫一份輸入校驗tRPC 路由里要寫查詢和變更邏輯前端還得自己掛 React Query 的 hook 才能消費這些 API。四套代碼描述同一個實體任何一處不一致線上就會給你顏色看。1.1 那一次的線上故障一次字段改名引發(fā)的連鎖反應說回開頭那次事故。業(yè)務上我需要把訂單的status字段從字符串改成聯合類型PENDING | PAID | SHIPPED | COMPLETED | CANCELLED。Prisma 模型里改枚舉幾分鐘的事數據庫遷移順手做了但 tRPC 路由的入參校驗里對應的 Zod schema 還是老樣子——z.string()。前端按新枚舉提交PAID服務端校驗自然通過問題出在另一個老接口某個回調邏輯還在傳舊字符串paid小寫Zod 直接拒絕訂單狀態(tài)更新全部失敗。排查過程不算難schema 不匹配的報錯一眼就能定位。但這件事讓我真正難受的是這類問題幾乎不可防。只要模型和校驗之間存在手工同步就永遠存在改了一處、漏了一處的可能。TypeScript 能保證的是同一份代碼里類型一致可保證不了不同文件里手寫的類型定義和數據庫模型一致。一次字段改名的連鎖反應大致是這樣修改schema.prisma中Order.status枚舉定義執(zhí)行遷移并重新生成 Prisma Client手改 Zod 校驗中的status字段手改 tRPC 路由中的輸出類型手改前端對應的類型定義和枚舉常量五步里只要漏掉任何一步編譯期大概率不報錯運行期才炸。這比編譯錯誤可怕得多因為線上反饋有延遲用戶已經遭殃了你才知道。1.2 樣板代碼到底有多少統計一個典型 CRUD 模塊后來我做了一次統計更堅定了要寫工具的決心。一個典型的博客文章資源模塊只算基礎 CRUD不加任何業(yè)務規(guī)則需要手寫哪些東西文件/位置大致內容手寫行數Prisma modelPost模型的字段、關系、索引30-50 行Zod schema創(chuàng)建、更新、查詢參數的校驗80-120 行tRPC routercreatePost、getPost、updatePost、deletePost、列表查詢150-200 行前端類型從后端類型手動推導或重新聲明40-60 行API 封裝fetch/axios調用 錯誤處理60-100 行React Query hooksuseQuery、useMutation封裝80-120 行加起來一個模塊 440-650 行而且是最枯燥、最沒技術含量、最容易抄錯的那部分。我要說句實話這種代碼的價值低到幾乎為零但出錯的成本高到離譜。字段多一個、少一個類型不一致空值處理漏了都是這類手寫樣板的高發(fā)問題。t3code 的第一個目標就是把這些低價值、高錯誤率的樣板代碼從日常開發(fā)里拿掉讓你把精力留給真正重要的業(yè)務邏輯。2. t3code 的設計思路與核心取舍寫工具之前我先把需求想清楚了。當時市面上一堆低代碼平臺和可視化搭建看著很美好但你真用在生產項目里就知道生成的東西一復雜就不可維護。所以 t3code 的定位從一開始就很克制——它不是可視化平臺不跟你搶業(yè)務邏輯只是一個幫你把類型同步做好、把樣板補齊的代碼生成器。2.1 為什么是 CLI 而不是 VSCode 插件其實第一版我也考慮過做 VSCode 插件那樣保存文件自動生成體驗更順滑。但后來我放棄了原因有三CLI 可以進 CI。插件只能在本地用可模型改了代碼沒跟上這個問題最應該攔截的地方是代碼評審和 CI 階段。CLI 加一個check命令誰改了 Prisma schema 沒跑生成CI 直接紅這才是真正的防護。CLI 不挑編輯器。團隊里有人用 VSCode有人用 JetBrains有人用 Neovim插件方案會制造工具割裂。CLI 可以串流程。prisma generate t3code generate一條命令鏈解決所有生成問題插件做不到這種組合能力。所以最后我選擇了純 CLI 方案npx t3code搞定一切。這也讓工具天然具備腳本化的能力后續(xù)接 CI、接打包鉤子都很方便。2.2 一張表驅動一切以數據模型為唯一事實來源t3code 最核心的設計決策是把 Prisma schema 當作唯一事實來源其他一切代碼都從這里推導出來。為什么不從 tRPC router 反推因為 router 里混著業(yè)務代碼解析起來不干凈為什么不搞一個獨立的數據字典文件因為多一個文件就多一份約束成本開發(fā)者遲早會忘記維護。Prisma schema 本身就是團隊的數據契約你本來就要維護它拿它當源頭最省事。整個生成鏈路是這樣的schema.prisma唯一事實來源 ↓ 解析模型字段、枚舉、關系 Zod schema 生成輸入校驗、輸出類型 ↓ tRPC router 模板生成自動包含 CRUD 基礎實現 ↓ 前端類型 API 封裝 React Query hooks這條鏈路解決了一個核心問題同一個實體的描述只有一份其他全是推導結果。Post模型里的title: String生成了 Zod 的z.string()生成了 tRPC 的輸入類型生成了前端 hook 的入參類型天然一致再無手寫同步這回事。2.3 生成策略可覆蓋、可保留、可手動接管代碼生成工具最怕什么怕把你手寫的代碼覆蓋掉。t3code 的生成策略是三檔自動生成文件文件頂部帶// generated by t3code標記再次生成時整體覆蓋。這類文件包括 Zod schema、前端類型、API 封裝它們本來就該是純推導結果你不應該手改。半自動模板文件t3code 生成 tRPC router 的骨架但用// t3code-preserved-start和// t3code-preserved-end標記出保留區(qū)。保留區(qū)里的業(yè)務代碼在重新生成時不會動保留區(qū)外的模板部分會被刷新。這樣既享受生成效率又不犧牲業(yè)務靈活性。手動接管任何文件只要去掉頂部生成標記t3code 就再也不碰它。適合那種已經被你重度改造、和模板差距太大的文件。這個設計參考了代碼生成領域常見的做法生成器只負責生成交接給使用時要有清晰的邊界。模板代碼和手寫代碼混在一個文件里靠保留區(qū)做隔離是我試過的最穩(wěn)定的方案。3. 上手實操從初始化到跑通一個 CRUD 模塊光聊設計理念沒有用真正好不好用得跑一遍才知道。下面我用一個具體的例子完整演示怎么用 t3code 從零生成一個帶 CRUD 的模塊。3.1 安裝與初始化t3code 可以直接通過 npx 使用也可以裝成項目依賴。我建議裝成 devDependencies保證團隊里所有人版本一致CI 環(huán)境也好復現pnpm add -D t3code然后執(zhí)行初始化npx t3code init初始化向導會問你幾個問題項目路徑、是否啟用 React Query hooks 生成、包管理器類型等等。初始化完成后項目根目錄生成一個t3code.config.tsimport { defineConfig } from t3code; export default defineConfig({ project: ., prismaSchema: prisma/schema.prisma, output: { zod: src/server/schemas, trpc: src/server/routers, client: src/lib/api, hooks: src/hooks, }, conventions: { inputSuffix: Input, outputSuffix: Output, }, features: { reactQuery: true, swr: false, }, });注意一個容易被忽略的點配置文件里所有路徑都是相對于配置文件所在目錄的不是相對于你執(zhí)行命令的目錄。這個設計是我踩過坑之后特意確定的因為 CLI 工具最容易出的問題就是在子目錄里跑命令生成位置全錯了。后面我會在踩坑章節(jié)詳細說。3.2 第一個生成命令跑通一個 Post 模塊假設prisma/schema.prisma里有這樣一個模型model Post { id String id default(cuid()) title String content String published Boolean default(false) authorId String author User relation(fields: [authorId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }執(zhí)行生成命令npx t3code generate默認情況下t3code 會為每個模型生成一套完整的 CRUD 代碼。生成的目錄結構大致是src/ ├── server/ │ ├── schemas/ │ │ └── post.schema.ts │ └── routers/ │ └── post.router.ts ├── lib/ │ └── api/ │ └── post.ts └── hooks/ └── use-post.ts其中post.schema.ts長這樣// generated by t3code - 手動修改此文件將被覆蓋 import { z } from zod; export const PostInput z.object({ title: z.string().min(1), content: z.string(), published: z.boolean().default(false), authorId: z.string().min(1), }); export const PostOutput z.object({ id: z.string(), title: z.string(), content: z.string(), published: z.boolean(), authorId: z.string(), createdAt: z.date(), updatedAt: z.date(), }); export type PostInput z.infertypeof PostInput; export type PostOutput z.infertypeof PostOutput;post.router.ts里的 CRUD 骨架大概是這樣import { router, publicProcedure } from ../trpc; import { PostInput } from ../schemas/post.schema; export const postRouter router({ create: publicProcedure .input(PostInput) .mutation(async ({ ctx, input }) { // t3code-preserved-start // 這里可以寫自定義業(yè)務邏輯比如權限校驗、事件發(fā)布等 // t3code-preserved-end return ctx.prisma.post.create({ data: input }); }), list: publicProcedure .query(async ({ ctx }) { return ctx.prisma.post.findMany(); }), detail: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { return ctx.prisma.post.findUnique({ where: { id: input.id } }); }), });可以看到生成的文件覆蓋了創(chuàng)建、列表、詳情這三個最常用的操作。更新和刪除默認沒有生成因為更新涉及字段級別的權限控制刪除涉及級聯行為這些跟業(yè)務耦合太深模板化了反而不安全。需要的時候你在保留區(qū)里手寫就好或者把 router 文件頭部的生成標記刪掉完全手動接管。3.3 增量更新與 watch 模式開發(fā)過程中最頻繁的場景是模型加了一個字段或者改了一個字段名。跑一遍t3code generate能全量刷新但每次都手動跑很煩所以我加了watch模式npx t3code watch這個命令會監(jiān)聽prisma/schema.prisma的變化每次保存自動增量生成。請注意它不是簡單地全量重跑而是做了字段級別的 diff只有新增或修改的字段對應的 Zod、類型、API 封裝才會被重新生成其他文件保持不變。這樣 git diff 干凈評審時一眼就能看到改動影響范圍。舉個例子我把Post模型加上tags String[]保存schema.prisma的瞬間watch 會自動幫我把PostInput和PostOutput里的tags: z.array(z.string())加上同時src/lib/api/post.ts里的類型定義也跟著更新。整個過程不需要我碰任何一個手寫文件類型安全鏈條自動閉合。4. 把 t3code 接入真實項目工作流工具寫出來只是第一步讓它融入團隊日常開發(fā)流程才是關鍵。我實際推進的過程中發(fā)現代碼生成器最怕的不是功能不夠而是沒人記得跑。所以需要把生成動作嵌進已有的工作流里形成習慣甚至形成強制約束。4.1 與 CI/CD 結合讓類型同步變成強制約束前面提到模型改了但代碼沒跟上這類問題最好的攔截點其實是 CI。t3code 專門提供了一個check命令它會重新生成一遍代碼然后和當前倉庫里的文件做對比如果有差異就返回非零退出碼。在package.json里加一個腳本{ scripts: { typecheck: tsc --noEmit, codegen:check: t3code check } }然后在 CI 流程里加一步- name: Check type-safe codegen run: pnpm codegen:check有了這一步誰要是改了 Prisma schema 忘了跑 generateCI 直接紅PR 根本合不進去。這比任何代碼評審建議都管用因為機器檢查是零成本、零遺漏的人審總會漏。我甚至見過團隊把這個 check 和 lint 合并成一個 pre-push hook本地就能擋住大部分問題。4.2 與編輯器 Task 整合保存即生成無感使用CI 解決的是事后攔截但本地開發(fā)里字段改完馬上自動生成體驗更順。我不建議每個人手動開一個t3code watch終端更好的方式是交給編輯器任務。在 VSCode 里.vscode/tasks.json可以這樣配置{ version: 2.0.0, tasks: [ { label: t3code: watch, type: shell, command: npx t3code watch, isBackground: true, problemMatcher: [] } ] }配合.vscode/settings.json讓它在打開項目時自動啟動{ task.autoDetect: on }這樣團隊里每個人 clone 完項目、打開 VSCodewatch 任務自動在后臺跑起來不需要任何人背命令。這套組合拳打下來生成器才算真正隱形了——你感覺不到它的存在但它的確在替你守著類型安全。4.3 生成的代碼到底要不要提交進倉庫這個問題團隊里爭論了很久我的結論是提交且必須提交。理由很簡單tRPC 路由、Zod schema 這些生成物直接參與運行時邏輯如果只留在本地那么 CI 類型檢查、部署構建、壞境復現全部依賴先跑一次生成。但凡哪一步漏了線上跑的就是和倉庫里不一致的代碼排查起來非常痛苦。生成物提交進倉庫配合上面的check命令能保證倉庫狀態(tài)和生成器始終一致。這也是很多成熟代碼生成工具比如 Prisma Client 本身的生成產物的做法。你只需要在 README 里寫清楚改 schema 后請跑pnpm codegen并提交生成文件即可。生成的文件不用能工去考究格式反正都是機器產物git 歷史看起來丑一點也無所謂。5. 踩坑實錄我在真實項目中遇到的六個問題任何工具寫出來都要經歷真槍實彈的考驗。下面是 t3code 在落地過程中遇到的最典型的六個問題每一個都是血淚教訓寫出來幫你避坑。5.1 路徑解析的坑cwd 不等于配置文件目錄CLI 工具最常見的 bug 就是路徑錯亂。用戶可能在packages/web目錄下執(zhí)行npx t3code generate但配置文件在項目根目錄而 Prisma schema 可能又指向prisma/schema.prisma這個相對路徑。我第一版實現用的是process.cwd()來解析所有路徑立刻被隊友噴了——只要你在子目錄里跑命令生成位置就全亂了。后來改成所有相對路徑都以配置文件所在目錄為基準解析命令行參數只能是絕對路徑或相對配置文件的路徑。順手加了一條校驗如果檢測到 cwd 和配置目錄不一致打印一行提示。這個改動之后再沒出現過生成到哪去了的疑惑。5.2 Prisma Client 生成順序生成的代碼引用了還不存在的 clientt3code 生成的 router 代碼會有ctx.prisma依賴而ctx.prisma來源于 Prisma Client。如果你剛 clone 完項目、還沒跑過prisma generate就直接跑t3code generate生成的 TypeScript 文件會因為找不到prisma/client類型而報錯。解決方式是在項目腳本里把兩條命令串起來{ scripts: { codegen: prisma generate t3code generate } }這里有一個小細節(jié)prisma generate必須在t3code generate之前跑不能反過來因為 t3code 解析 Prisma schema 時需要用到 Prisma 的某些內部類型定義尤其是枚舉類型。順序反了會出現莫名的解析錯誤別問我怎么知道的。5.3 Zod 枚舉和 Prisma 枚舉的類型一致性Prisma 里的enum字段和 Zod 里的校驗看起來是一一對應的但有個隱藏的坑Prisma 生成的是 TS 的enumZod 的z.enum()接受的是只讀字符串元組。兩者形態(tài)不一致導致你從 Prisma 枚舉類型直接構造 Zod schema 時會遇到類型兼容問題。t3code 的默認處理是把 Prisma 枚舉轉成as const的對象再用z.nativeEnum()來兼容import { PostStatus } from prisma/client; export const PostStatusSchema z.nativeEnum(PostStatus);這比z.enum([DRAFT, PUBLISHED])強在哪里強在當你改 Prisma 枚舉時Zod 這里天然跟隨不需要手動維護字符串列表。如果你用z.enum手寫就等于在另一個地方復制了一份枚舉又回到了兩處同步的老問題。5.4 模板覆蓋手寫代碼保留區(qū)解析的邊界問題保留區(qū)機制好用但也不是萬無一失。最開始的版本我用正則匹配t3code-preserved-start和t3code-preserved-end來切分文件結果被隊友搞出過一個詭異 bug業(yè)務代碼里有人寫了一句注釋里面碰巧包含了保留區(qū)標記文本導致生成時保留區(qū)范圍錯亂、手寫代碼被覆蓋。后來我把保留區(qū)的識別改成了基于模板引擎的結構化注釋塊解析只認單獨一行且格式完全匹配的標記并且生成前先做一次手寫代碼被覆蓋檢查如果檢測到某個文件有未標記的手寫改動就拒絕覆蓋并輸出 warning。這個策略雖然保守一點但安全第一寧可讓你手動合并也不能靜默丟代碼。5.5 watch 模式在 monorepo 里的表現如果你在 monorepopnpm workspace、turborepo 等里跑t3code watch第一反應可能是每個子包都開一個 watch。這樣會有大問題schema 文件通常只有一個但多個 watch 實例會同時響應同一個文件的變更可能互相覆蓋生成產物。我的建議是watch 任務只在擁有 Prisma schema 的那個包啟動其他包的配置文件通過extends復用根配置。t3code 配置支持繼承機制export default defineConfig({ extends: ../../t3code.config.ts, output: { zod: src/server/schemas, trpc: src/server/routers, }, });子包只覆蓋output路徑其他設置繼承根配置。這樣 monorepo 下只有一處 watch生成物分發(fā)到各包干凈利落。5.6 命名風格自動轉換PascalCase 和 camelCase 的邊界數據庫字段和 TypeScript 代碼之間有個繞不過去的坎Prisma 模型名是PascalCase但變量名和文件名習慣用camelCase。t3code 做自動轉換看似簡單真的寫起來才發(fā)現邊界情況多到頭疼BlogPost→blogPost標準轉換沒問題APIKey→apikey還是apiKey縮寫詞到底拆不拆UserID→userId還是uid每個團隊習慣不一樣這個問題的解決方案不是技術性的而是約定性的t3code 默認只做大小寫首字母轉換不猜縮寫語義。同時暴露一個naming配置項允許你自定義轉換函數。畢竟命名這東西工具不該替團隊做決定做錯了反而讓人難受。你只需要統一模型名必須 PascalCase、生成變量名必須 camelCase這一條基礎約定就夠了。結尾說一點個人體會。t3code 寫了半年最大的收獲不是我有個工具能自動生成代碼而是想明白了一個道理代碼生成器的價值不在生成本身而在它能強制維護單一事實來源。手寫代碼時模型改字段、校驗改規(guī)則、類型改聲明全是腦力負擔隨時可能漏有了生成器這些同步變成了機器的任務人只需要管業(yè)務。開發(fā)效率的提升不是一點點而是根本上少了一類問題的煩惱。如果你想試一下我建議從小處著手先拿一兩個模型跑通生成看看保留區(qū)的業(yè)務代碼和生成模板之間的配合是不是符合你們團隊的習慣再決定要不要全面推開。工具的設計是我根據自身經驗做的取舍不一定全適合你的項目但自動同步 強制檢查這個流程思路值得每個 T3 項目借鑒。