類型到全棧類型安全:TypeScript + Zod + tRPC 實(shí)踐指南)
1. 先說(shuō)結(jié)論無(wú)類型開(kāi)發(fā)為什么讓我踩坑踩到懷疑人生1.1 所謂 Typeless其實(shí)是在賭“運(yùn)行時(shí)不報(bào)錯(cuò)就是沒(méi)毛病”先交代一下背景。我接手的是一個(gè)跑了兩年多的 Web 項(xiàng)目技術(shù)棧很“主流”Express React JavaScript沒(méi)有 TypeScript也沒(méi)有任何運(yùn)行時(shí)數(shù)據(jù)校驗(yàn)方案。當(dāng)時(shí)團(tuán)隊(duì)里流行一種說(shuō)法只要業(yè)務(wù)跑得通類型不重要寫起來(lái)快就是王道。這種心態(tài)本質(zhì)上就是 Typeless 開(kāi)發(fā)——不花精力定義類型不關(guān)心數(shù)據(jù)在每一個(gè)邊界上的真實(shí)形態(tài)靠“人腦記住接口長(zhǎng)什么樣”來(lái)維持開(kāi)發(fā)。一開(kāi)始確實(shí)爽。新功能上線速度極快改一個(gè)字段不用去翻十幾個(gè)類型定義console.log 一打要啥數(shù)據(jù)直接取??蛇@種快樂(lè)的保質(zhì)期通常不超過(guò)三個(gè)月。真正讓我開(kāi)始動(dòng)搖的是一段線上事故用戶反饋“下單成功但訂單列表里沒(méi)有記錄”排查了一整晚最后發(fā)現(xiàn)是后端某個(gè)字段命名從created_at改成了createTime而前端和數(shù)據(jù)庫(kù)腳本里仍舊用舊字段名。整個(gè)鏈路沒(méi)有任何一層報(bào)錯(cuò)數(shù)據(jù)靜默丟失。這還不是最離譜的最離譜的是這種問(wèn)題在一個(gè)月內(nèi)出現(xiàn)了三次。1.2 壓垮我的三個(gè)典型場(chǎng)景接口返回值變更沒(méi)有任何靜態(tài)檢查能攔住。文檔寫了可代碼不會(huì)校驗(yàn)測(cè)試也漏了上線等于裸奔。一個(gè) utils 函數(shù)在多個(gè)模塊里被復(fù)用參數(shù)從字符串悄悄變成了null跑到某個(gè)深層邏輯才拋TypeError排查成本極高。新同事接手模塊完全靠猜測(cè)來(lái)補(bǔ)業(yè)務(wù)邏輯。沒(méi)有類型定義IDE 沒(méi)有任何提示一個(gè)簡(jiǎn)單的對(duì)象字段寫錯(cuò)能卡半天。這些場(chǎng)景其實(shí)指向同一個(gè)本質(zhì)問(wèn)題Typeless 開(kāi)發(fā)把“類型正確性”這個(gè)本該提前解決的問(wèn)題推遲到了運(yùn)行時(shí)。而運(yùn)行時(shí)的問(wèn)題一旦爆發(fā)輕則報(bào)錯(cuò)重試重則數(shù)據(jù)錯(cuò)亂。我還記得自己當(dāng)時(shí)做的最后一個(gè)“無(wú)類型”操作給一個(gè)列表頁(yè)寫篩選邏輯把filterStatus傳成了filterStats由于字段名太像讀代碼的時(shí)候根本發(fā)現(xiàn)不了最終靠線上監(jiān)控才找到。那天我在群里發(fā)了很長(zhǎng)一段自我反思然后默默打開(kāi)了 TypeScript 的官方文檔。2. 決定替換方案之前我把“類型安全”拆成了三層2.1 靜態(tài)類型只解決編譯期運(yùn)行時(shí)的坑還得運(yùn)行時(shí)來(lái)填很多人以為引入 TypeScript 就萬(wàn)事大吉了這是最大的誤解。TypeScript 的類型是編譯期抹除的它能在你寫代碼的時(shí)候攔住低級(jí)錯(cuò)誤但攔不住“外部傳入的數(shù)據(jù)不符合預(yù)期”這種運(yùn)行時(shí)問(wèn)題。換句話說(shuō)TS 管的是“代碼層面的形狀”管不了“數(shù)據(jù)層面的真實(shí)值”。我在設(shè)計(jì)替代方案時(shí)給自己定了三層目標(biāo)編譯期用 TypeScript 嚴(yán)格模式確保代碼內(nèi)部函數(shù)之間、模塊之間的數(shù)據(jù)形狀是可推導(dǎo)的。運(yùn)行時(shí)在所有“信任邊界”加校驗(yàn)比如 API 入?yún)?、第三方回包、?shù)據(jù)庫(kù)讀出來(lái)之后的數(shù)據(jù)。契約層前后端共享同一份 schema 定義讓接口不再靠“口頭約定”。這三個(gè)層級(jí)缺一不可。只做第一層和沒(méi)用差不太多只做第三層但沒(méi)有運(yùn)行時(shí)校驗(yàn)代碼生成出來(lái)的類型再漂亮線上照樣可能被臟數(shù)據(jù)打穿。后來(lái)我接觸到 Zod、tRPC 這些工具發(fā)現(xiàn)它們本質(zhì)上就是在補(bǔ)第二層和第三層的空缺。換句話說(shuō)替代 Typeless 的并不是某一個(gè)庫(kù)而是一整套把“數(shù)據(jù)流動(dòng)路徑”顯式化的思路。2.2 沒(méi)有類型閉環(huán)替代方案的體驗(yàn)還會(huì)反彈我還吃過(guò)另一個(gè)虧花了一周把項(xiàng)目從 JS 遷到 TS以為完事了結(jié)果上線后該報(bào)錯(cuò)的地方照樣報(bào)錯(cuò)。原因很簡(jiǎn)單接口返回的數(shù)據(jù)沒(méi)有校驗(yàn)TS 只能告訴你“這個(gè)字段是 string”但如果后端某天真的返回了undefined你照樣在運(yùn)行時(shí)炸掉。所以單純換語(yǔ)言不是解藥。解藥是讓類型從數(shù)據(jù)庫(kù)、到 API 層、再到前端組件形成一條完整的鏈路。比如數(shù)據(jù)庫(kù)表結(jié)構(gòu)變更后后端類型自動(dòng)更新前端調(diào)用接口時(shí)的類型也跟著變化多出來(lái)的字段、缺少的字段在編譯期就能暴露而不是等運(yùn)維報(bào)錯(cuò)。我最終接受的組合是TypeScript Zod tRPC Prisma。下面這張對(duì)比表是我當(dāng)時(shí)做選型時(shí)的核心依據(jù)場(chǎng)景Typeless舊方案類型安全組合方案接口入?yún)⑿r?yàn)手動(dòng) if 判斷經(jīng)常漏Zod schema 統(tǒng)一校驗(yàn)編譯期類型提示無(wú)TypeScript 嚴(yán)格模式前后端類型同步靠人肉同步易漂移tRPC 或 OpenAPI 代碼生成數(shù)據(jù)庫(kù)類型來(lái)源手寫類型容易過(guò)期Prisma 生成后直接推斷線上排錯(cuò)效率靠日志靠猜校驗(yàn)失敗能定位到具體字段這套組合的核心優(yōu)勢(shì)不是“類型無(wú)所不在”而是“數(shù)據(jù)類型在每一次跨越邊界時(shí)都有據(jù)可查”。截至現(xiàn)在我已經(jīng)在這個(gè)方案上跑了快一年最大的變化不是 bug 數(shù)量減少了多少而是心理負(fù)擔(dān)變小了——改代碼的時(shí)候不再擔(dān)心“不知道哪些地方會(huì)因?yàn)檫@個(gè)改動(dòng)被牽連”。3. 落地實(shí)操?gòu)?Typeless 到類型安全架構(gòu)的遷移記錄3.1 第一步把 TypeScript 嚴(yán)格模式打開(kāi)不留例外遷移的第一步不是把.js全部改成.ts而是先讓工具鏈支持類型檢查。我建議從tsconfig.json開(kāi)始直接使用如下配置{ compilerOptions: { target: ES2020, module: commonjs, strict: true, noImplicitAny: true, strictNullChecks: true, noUncheckedIndexedAccess: true, exactOptionalPropertyTypes: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true } }其中strictNullChecks和noUncheckedIndexedAccess是我最看重的兩個(gè)選項(xiàng)。前者讓null和undefined不再能悄悄賦值給其他類型后者讓數(shù)組和索引簽名取出來(lái)的值帶上undefined的可能性。這兩條在遷移階段會(huì)帶來(lái)大量編譯報(bào)錯(cuò)但正是這些報(bào)錯(cuò)把之前 Typeless 狀態(tài)下被隱藏的問(wèn)題一個(gè)個(gè)暴露了出來(lái)。遷移策略上不要一次性把整個(gè)項(xiàng)目全量切換。我當(dāng)時(shí)的做法是先對(duì)新建代碼啟用.ts老文件按模塊逐個(gè)遷移并在tsconfig.json里用allowJscheckJs: false過(guò)渡。每次遷移一個(gè)模塊就順手補(bǔ)上該模塊下游的 Zod schema這樣可以避免“為改而改”。遷移過(guò)程中最典型的“報(bào)錯(cuò)風(fēng)暴”是“對(duì)象可能為 null”和“類型 string | undefined 不能賦值給 string”。這些報(bào)錯(cuò)初看非常多但清理思路很單一順著調(diào)用鏈往上找追到數(shù)據(jù)源頭確定這個(gè)值是真有可能為空還是純粹因?yàn)楣ぞ哝湵J亍H绻乔罢哒f(shuō)明原本就存在隱患正好用顯式校驗(yàn)來(lái)兜底如果是后者就用類型守衛(wèi)或者非空斷言但一定要寫注釋說(shuō)明為什么可以斷言。3.2 第二步用 Zod 把邊界處的數(shù)據(jù)徹底鎖死Zod 在替代方案里的角色不只是“校驗(yàn)庫(kù)”它同時(shí)是類型定義的唯一來(lái)源。我喜歡它的一點(diǎn)是“一個(gè) schema 同時(shí)給你運(yùn)行時(shí)校驗(yàn)和編譯期類型”不需要在兩個(gè)地方維護(hù)兩份定義。以用戶注冊(cè)接口為例這是我在項(xiàng)目里沉淀下來(lái)的標(biāo)準(zhǔn)寫法import { z } from zod; export const CreateUserRequestSchema z.object({ email: z.string().email(), password: z.string().min(8).max(32), nickname: z.string().trim().min(1).max(30).optional(), age: z.number().int().min(0).max(120).optional(), }); export type CreateUserRequest z.infertypeof CreateUserRequestSchema; export const UserResponseSchema z.object({ id: z.string().uuid(), email: z.string().email(), nickname: z.string().nullable(), createdAt: z.string().datetime(), }); export type UserResponse z.infertypeof UserResponseSchema;關(guān)鍵點(diǎn)在于我把它放在了 API 層也就是請(qǐng)求進(jìn)入后端業(yè)務(wù)邏輯前的第一道閘門。用 Express 來(lái)舉例app.post(/api/users, async (req, res) { const parsed CreateUserRequestSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ code: VALIDATION_ERROR, issues: parsed.error.flatten().fieldErrors, }); } const user await createUser(parsed.data); const response UserResponseSchema.parse(sanitize(user)); return res.json(response); });這里的兩個(gè)要點(diǎn)一是用safeParse而不是直接parse這樣校驗(yàn)失敗不會(huì)拋異常而是走正常分支返回 400二是響應(yīng)用parse因?yàn)槿绻?wù)端自己的出參都不符合契約說(shuō)明代碼邏輯有問(wèn)題應(yīng)該直接拋出來(lái)讓測(cè)試和監(jiān)控及時(shí)發(fā)現(xiàn)。類似的做法可以用在第三方 API 回包、讀取 Redis、消費(fèi)消息隊(duì)列消息等場(chǎng)景。我自己還封裝了一個(gè)withValidation的高階函數(shù)用來(lái)減少重復(fù)const withValidation T(schema: z.ZodSchemaT, handler: (data: T) Promiseany) { return async (req: Request, res: Response) { const parsed schema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ code: VALIDATION_ERROR, issues: parsed.error.flatten() }); } return handler(parsed.data); }; };這樣一來(lái)路由注冊(cè)變得非常簡(jiǎn)潔app.post(/api/users, withValidation(CreateUserRequestSchema, async (data) { const user await createUser(data); return res.json(UserResponseSchema.parse(sanitize(user))); }));3.3 第三步用 tRPC 或者 OpenAPI 代碼生成把前后端類型打通邊界校驗(yàn)搞定之后下一個(gè)要解決的是“前后端類型同步”。Typeless 時(shí)代前端和后端各有一份自己理解的接口定義后端改了字段前端不一定知道前端改了調(diào)用后端也無(wú)感知。替代方案中我推薦兩種主流做法全棧 TypeScript 項(xiàng)目直接用 tRPC前后端共享同一套 schema 定義不需要生成代碼天然同步。前后端分離、后端不是 Node 的場(chǎng)景用 OpenAPI openapi-typescript把后端接口文檔作為單一事實(shí)來(lái)源前端通過(guò)代碼生成獲得類型。tRPC 是我個(gè)人最順手的一套方案。它的思路很簡(jiǎn)單把后端函數(shù)直接暴露給前端調(diào)用中間不需要手寫 REST 路徑也不需要手動(dòng)寫 fetch 封裝。配合 Zod 輸入校驗(yàn)類型可以一路從數(shù)據(jù)庫(kù)推到前端組件里。一個(gè)最小可用的 tRPC router 示例import { initTRPC } from trpc/server; import { z } from zod; const t initTRPC.create(); export const appRouter t.router({ getUser: t.procedure .input(z.object({ id: z.string().uuid() })) .query(async ({ input }) { const user await prisma.user.findUnique({ where: { id: input.id } }); return UserResponseSchema.parse(user); }), createUser: t.procedure .input(CreateUserRequestSchema) .mutation(async ({ input }) { return createUser(input); }), }); export type AppRouter typeof appRouter;前端 React 組件里調(diào)用時(shí)類型是自動(dòng)透?jìng)鞯膇mport { trpc } from ../utils/trpc; function UserCard({ userId }: { userId: string }) { const { data, isLoading } trpc.getUser.useQuery({ id: userId }); if (isLoading) return Skeleton /; return div{data?.email}/div; }這里的data類型不是前端手寫的而是后端 schema 推導(dǎo)出來(lái)的所以即使后端改了返回結(jié)構(gòu)前端編譯時(shí)會(huì)直接報(bào)錯(cuò)而不是等運(yùn)行時(shí)才發(fā)現(xiàn)。這才是真正能替代 Typeless 的地方——類型不是事后補(bǔ)救而是從源頭就開(kāi)始約束。如果項(xiàng)目已經(jīng)用了 OpenAPI沒(méi)法短時(shí)間換 tRPCopenapi-typescript 也能達(dá)到類似效果。它會(huì)把openapi.yaml生成一份.d.tsnpx openapi-typescript ./openapi.yaml -o ./src/types/api.ts這樣前端調(diào)用fetch的時(shí)候手動(dòng)指定返回值類型即可雖然比 tRPC 多了一層“同步文檔”的開(kāi)銷但比完全 Typeless 的狀態(tài)強(qiáng)一個(gè)量級(jí)。3.4 第四步數(shù)據(jù)庫(kù)模型也走類型生成鏈路最后一步往往被忽略但它決定了整套方案能不能長(zhǎng)久數(shù)據(jù)庫(kù)模型層的類型必須自動(dòng)化。我的做法是引入 Prisma用schema.prisma定義數(shù)據(jù)模型然后通過(guò)prisma generate自動(dòng)生成類型。舉個(gè)例子model User { id String id default(uuid()) db.Uuid email String unique password String nickname String? age Int? createdAt DateTime default(now()) updatedAt DateTime updatedAt }執(zhí)行npx prisma generate后代碼里可以直接用import { User } from prisma/client; function toUserResponse(user: User): UserResponse { return UserResponseSchema.parse(user); }只要數(shù)據(jù)庫(kù)結(jié)構(gòu)變了你只需要修改schema.prisma然后重新生成TS 類型就會(huì)同步更新。那些需要手寫的、容易過(guò)期的類型定義基本上可以全部刪掉。這里還有一個(gè)細(xì)節(jié)值得推薦用zod-prisma-types這個(gè)工具直接從 Prisma schema 生成對(duì)應(yīng)的 Zod schema。這樣數(shù)據(jù)庫(kù)、運(yùn)行時(shí)校驗(yàn)、類型定義三條鏈路徹底合一npx zod-prisma-types ./prisma/schema.prisma ./src/generated/zod-schemas.ts之后API 入?yún)⑿r?yàn)和數(shù)據(jù)庫(kù)出參校驗(yàn)都用同一份 schema避免手動(dòng)重復(fù)定義。4. 遷移過(guò)程中的常見(jiàn)問(wèn)題與排查實(shí)錄4.1 老項(xiàng)目改造時(shí)的“類型爆炸”怎么處理遷移過(guò)程中最打擊士氣的是大量手寫類型定義被塞進(jìn)types.ts文件越寫越長(zhǎng)最后根本沒(méi)人知道哪個(gè)類型在用、哪個(gè)已經(jīng)廢棄。后來(lái)我用三個(gè)辦法解決能用z.infer/Prisma.xxx推導(dǎo)的類型一律不手寫。公共基礎(chǔ)類型放到domain/目錄按業(yè)務(wù)模塊分包而不是集中在一兩個(gè)文件里。用ts-prune定期掃未導(dǎo)出的死類型發(fā)現(xiàn)直接刪。另外一個(gè)實(shí)戰(zhàn)心得是除非萬(wàn)不得已不要寫“所有字段都可選”的類型。這會(huì)重新滑向 Typeless 的深淵。如果某個(gè)字段確實(shí)可空應(yīng)該讓 schema 和數(shù)據(jù)庫(kù)模型都顯式標(biāo)出nullable這樣調(diào)用方才能感知到處理空值的責(zé)任。4.2 加了運(yùn)行時(shí)校驗(yàn)之后性能會(huì)不會(huì)崩“Zod 會(huì)不會(huì)影響 QPS”是我聽(tīng)到最多的問(wèn)題。我實(shí)測(cè)下來(lái)的結(jié)論是常規(guī)業(yè)務(wù)接口幾乎感知不到差異但如果接口本身極端高頻并且你整條鏈路做了多層重復(fù)校驗(yàn)?zāi)欠e累起來(lái)確實(shí)有損耗。我的建議是精確控制校驗(yàn)點(diǎn)請(qǐng)求入口校驗(yàn)一次數(shù)據(jù)庫(kù)讀出來(lái)如果有必要再校驗(yàn)一次業(yè)務(wù)中間層不要反復(fù)parse同一個(gè)對(duì)象。如果某個(gè)對(duì)象是熱路徑盡量讓 schema 簡(jiǎn)單一點(diǎn)不要嵌套多層深結(jié)構(gòu)任何校驗(yàn)庫(kù)面對(duì)深嵌套對(duì)象的代價(jià)都是平方級(jí)增長(zhǎng)的。還有一個(gè)進(jìn)階思路在開(kāi)發(fā)環(huán)境保留完整校驗(yàn)生產(chǎn)環(huán)境對(duì)某些不重要的內(nèi)部接口跳過(guò)校驗(yàn)或者用z.fastParse如果有這種輕量模式。不過(guò)這個(gè)優(yōu)先級(jí)很低絕大多數(shù)項(xiàng)目根本到不了需要優(yōu)化 Zod 的程度。4.3 團(tuán)隊(duì)協(xié)作時(shí)怎么讓大家不再“偷懶”繞開(kāi)類型技術(shù)方案能不能推行下去一半靠工具一半靠約束。我們團(tuán)隊(duì)當(dāng)時(shí)定了三條不可妥協(xié)的規(guī)矩新接口必須有 Zod schema沒(méi)有 schema 的接口不允許合入主干。前端調(diào)用接口時(shí)禁止把返回值強(qiáng)轉(zhuǎn)成自己手寫的類型as unknown as Xxx這種寫法直接結(jié)不了單。Pull Request 流水線里增加tsc --noEmit和 ESLint 檢查類型不過(guò)一律不能合并。前兩條靠代碼 Review 守住第三條靠工具強(qiáng)制。前面一兩個(gè)迭代會(huì)有些反抗情緒但大家后來(lái)發(fā)現(xiàn)提交代碼時(shí)的報(bào)錯(cuò)提示比線上告警好處理得多。我還把常見(jiàn)的類型錯(cuò)誤整理成了一個(gè)小文檔掛在項(xiàng)目 README 里比如“不要在 schema 外單獨(dú)寫 interface”“不要為了省事把校驗(yàn)函數(shù)放在被循環(huán)調(diào)用的 utils 里”“tRPC 的 input 不支持 date 類型要記得轉(zhuǎn) string”。這些小的規(guī)范細(xì)節(jié)能幫新成員快速進(jìn)入狀態(tài)。4.4 一個(gè)問(wèn)題速查表問(wèn)題現(xiàn)象大概率原因排查思路編譯報(bào)錯(cuò)對(duì)象可能為 null數(shù)據(jù)庫(kù)字段可空但 TS 沒(méi)推斷出來(lái)檢查strictNullChecks是否開(kāi)啟必要時(shí)用校驗(yàn) schema 收窄接口返回了預(yù)期外的字段DTO 沒(méi)有做嚴(yán)格過(guò)濾響應(yīng)統(tǒng)一用 schema.parse多余字段直接不讓過(guò)前后端類型對(duì)不上但編譯沒(méi)報(bào)前端手寫了返回類型繞過(guò)了推導(dǎo)刪除手寫類型改成z.infer或 tRPC 自動(dòng)推導(dǎo)修改了數(shù)據(jù)庫(kù)字段代碼沒(méi)提示Prisma 沒(méi)有重新 generate執(zhí)行npx prisma generateZod 校驗(yàn)失敗但錯(cuò)誤信息不友好直接用了 throw改用 safeParse并在返回層做 flatten老模塊一直沒(méi)遷移類型檢查形同虛設(shè)allowJs 開(kāi)著JS 文件不檢查規(guī)劃遷移節(jié)奏逐步把 allowJs 關(guān)掉這套表一直保留在我們團(tuán)隊(duì)的內(nèi)網(wǎng) Wiki 上后來(lái)還補(bǔ)充了不少新成員踩過(guò)的坑。比如有一次新同事在 tRPC query 里直接返回了 Date 對(duì)象導(dǎo)致前端序列化出錯(cuò)我們?cè)诒砝镉旨恿艘粭l“跨進(jìn)程傳輸?shù)臄?shù)據(jù)必須可序列化”的提示。另外遷移初期還有一個(gè)特別容易忽略的問(wèn)題process.env里的環(huán)境變量別偷懶直接當(dāng)成string用。我一開(kāi)始就吃過(guò)虧某個(gè)配置項(xiàng)的讀取在本地正常、在測(cè)試環(huán)境變成undefined排查了好久才發(fā)現(xiàn)是部署配置漏了。后來(lái)我用一個(gè)小的環(huán)境變量 schema 統(tǒng)一校驗(yàn)const EnvSchema z.object({ NODE_ENV: z.enum([development, test, production]).default(development), DATABASE_URL: z.string().url(), REDIS_URL: z.string().url(), API_INTERNAL_TOKEN: z.string().min(16), }); const env EnvSchema.parse(process.env); export const config { nodeEnv: env.NODE_ENV, databaseUrl: env.DATABASE_URL, redisUrl: env.REDIS_URL, apiInternalToken: env.API_INTERNAL_TOKEN, };這樣任何環(huán)境變量缺失服務(wù)啟動(dòng)時(shí)就會(huì)立刻失敗而不是運(yùn)行到某個(gè)深層邏輯才報(bào)錯(cuò)。最后再分享一個(gè)很小的技巧遷移完成之后把原先那些“防君子不防小人”的注釋全部清理干凈比如// 這里的 data 可能是 null要小心、// 后端返回的字段名總變別用解構(gòu)。這些注釋本身就是 Typeless 留下的債刪掉它們意味著你不再用“提醒”來(lái)對(duì)抗不確定性而是用清晰的類型和校驗(yàn)來(lái)承接真實(shí)數(shù)據(jù)。