尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从无类型到全栈类型安全:TypeScript + Zod + tRPC 实践指南

从无类型到全栈类型安全:TypeScript + Zod + tRPC 实践指南 1. 先说结论无类型开发为什么让我踩坑踩到怀疑人生1.1 所谓 Typeless其实是在赌“运行时不报错就是没毛病”先交代一下背景。我接手的是一个跑了两年多的 Web 项目技术栈很“主流”Express React JavaScript没有 TypeScript也没有任何运行时数据校验方案。当时团队里流行一种说法只要业务跑得通类型不重要写起来快就是王道。这种心态本质上就是 Typeless 开发——不花精力定义类型不关心数据在每一个边界上的真实形态靠“人脑记住接口长什么样”来维持开发。一开始确实爽。新功能上线速度极快改一个字段不用去翻十几个类型定义console.log 一打要啥数据直接取。可这种快乐的保质期通常不超过三个月。真正让我开始动摇的是一段线上事故用户反馈“下单成功但订单列表里没有记录”排查了一整晚最后发现是后端某个字段命名从created_at改成了createTime而前端和数据库脚本里仍旧用旧字段名。整个链路没有任何一层报错数据静默丢失。这还不是最离谱的最离谱的是这种问题在一个月内出现了三次。1.2 压垮我的三个典型场景接口返回值变更没有任何静态检查能拦住。文档写了可代码不会校验测试也漏了上线等于裸奔。一个 utils 函数在多个模块里被复用参数从字符串悄悄变成了null跑到某个深层逻辑才抛TypeError排查成本极高。新同事接手模块完全靠猜测来补业务逻辑。没有类型定义IDE 没有任何提示一个简单的对象字段写错能卡半天。这些场景其实指向同一个本质问题Typeless 开发把“类型正确性”这个本该提前解决的问题推迟到了运行时。而运行时的问题一旦爆发轻则报错重试重则数据错乱。我还记得自己当时做的最后一个“无类型”操作给一个列表页写筛选逻辑把filterStatus传成了filterStats由于字段名太像读代码的时候根本发现不了最终靠线上监控才找到。那天我在群里发了很长一段自我反思然后默默打开了 TypeScript 的官方文档。2. 决定替换方案之前我把“类型安全”拆成了三层2.1 静态类型只解决编译期运行时的坑还得运行时来填很多人以为引入 TypeScript 就万事大吉了这是最大的误解。TypeScript 的类型是编译期抹除的它能在你写代码的时候拦住低级错误但拦不住“外部传入的数据不符合预期”这种运行时问题。换句话说TS 管的是“代码层面的形状”管不了“数据层面的真实值”。我在设计替代方案时给自己定了三层目标编译期用 TypeScript 严格模式确保代码内部函数之间、模块之间的数据形状是可推导的。运行时在所有“信任边界”加校验比如 API 入参、第三方回包、数据库读出来之后的数据。契约层前后端共享同一份 schema 定义让接口不再靠“口头约定”。这三个层级缺一不可。只做第一层和没用差不太多只做第三层但没有运行时校验代码生成出来的类型再漂亮线上照样可能被脏数据打穿。后来我接触到 Zod、tRPC 这些工具发现它们本质上就是在补第二层和第三层的空缺。换句话说替代 Typeless 的并不是某一个库而是一整套把“数据流动路径”显式化的思路。2.2 没有类型闭环替代方案的体验还会反弹我还吃过另一个亏花了一周把项目从 JS 迁到 TS以为完事了结果上线后该报错的地方照样报错。原因很简单接口返回的数据没有校验TS 只能告诉你“这个字段是 string”但如果后端某天真的返回了undefined你照样在运行时炸掉。所以单纯换语言不是解药。解药是让类型从数据库、到 API 层、再到前端组件形成一条完整的链路。比如数据库表结构变更后后端类型自动更新前端调用接口时的类型也跟着变化多出来的字段、缺少的字段在编译期就能暴露而不是等运维报错。我最终接受的组合是TypeScript Zod tRPC Prisma。下面这张对比表是我当时做选型时的核心依据场景Typeless旧方案类型安全组合方案接口入参校验手动 if 判断经常漏Zod schema 统一校验编译期类型提示无TypeScript 严格模式前后端类型同步靠人肉同步易漂移tRPC 或 OpenAPI 代码生成数据库类型来源手写类型容易过期Prisma 生成后直接推断线上排错效率靠日志靠猜校验失败能定位到具体字段这套组合的核心优势不是“类型无所不在”而是“数据类型在每一次跨越边界时都有据可查”。截至现在我已经在这个方案上跑了快一年最大的变化不是 bug 数量减少了多少而是心理负担变小了——改代码的时候不再担心“不知道哪些地方会因为这个改动被牵连”。3. 落地实操从 Typeless 到类型安全架构的迁移记录3.1 第一步把 TypeScript 严格模式打开不留例外迁移的第一步不是把.js全部改成.ts而是先让工具链支持类型检查。我建议从tsconfig.json开始直接使用如下配置{ compilerOptions: { target: ES2020, module: commonjs, strict: true, noImplicitAny: true, strictNullChecks: true, noUncheckedIndexedAccess: true, exactOptionalPropertyTypes: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true } }其中strictNullChecks和noUncheckedIndexedAccess是我最看重的两个选项。前者让null和undefined不再能悄悄赋值给其他类型后者让数组和索引签名取出来的值带上undefined的可能性。这两条在迁移阶段会带来大量编译报错但正是这些报错把之前 Typeless 状态下被隐藏的问题一个个暴露了出来。迁移策略上不要一次性把整个项目全量切换。我当时的做法是先对新建代码启用.ts老文件按模块逐个迁移并在tsconfig.json里用allowJscheckJs: false过渡。每次迁移一个模块就顺手补上该模块下游的 Zod schema这样可以避免“为改而改”。迁移过程中最典型的“报错风暴”是“对象可能为 null”和“类型 string | undefined 不能赋值给 string”。这些报错初看非常多但清理思路很单一顺着调用链往上找追到数据源头确定这个值是真有可能为空还是纯粹因为工具链保守。如果是前者说明原本就存在隐患正好用显式校验来兜底如果是后者就用类型守卫或者非空断言但一定要写注释说明为什么可以断言。3.2 第二步用 Zod 把边界处的数据彻底锁死Zod 在替代方案里的角色不只是“校验库”它同时是类型定义的唯一来源。我喜欢它的一点是“一个 schema 同时给你运行时校验和编译期类型”不需要在两个地方维护两份定义。以用户注册接口为例这是我在项目里沉淀下来的标准写法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;关键点在于我把它放在了 API 层也就是请求进入后端业务逻辑前的第一道闸门。用 Express 来举例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); });这里的两个要点一是用safeParse而不是直接parse这样校验失败不会抛异常而是走正常分支返回 400二是响应用parse因为如果服务端自己的出参都不符合契约说明代码逻辑有问题应该直接抛出来让测试和监控及时发现。类似的做法可以用在第三方 API 回包、读取 Redis、消费消息队列消息等场景。我自己还封装了一个withValidation的高阶函数用来减少重复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); }; };这样一来路由注册变得非常简洁app.post(/api/users, withValidation(CreateUserRequestSchema, async (data) { const user await createUser(data); return res.json(UserResponseSchema.parse(sanitize(user))); }));3.3 第三步用 tRPC 或者 OpenAPI 代码生成把前后端类型打通边界校验搞定之后下一个要解决的是“前后端类型同步”。Typeless 时代前端和后端各有一份自己理解的接口定义后端改了字段前端不一定知道前端改了调用后端也无感知。替代方案中我推荐两种主流做法全栈 TypeScript 项目直接用 tRPC前后端共享同一套 schema 定义不需要生成代码天然同步。前后端分离、后端不是 Node 的场景用 OpenAPI openapi-typescript把后端接口文档作为单一事实来源前端通过代码生成获得类型。tRPC 是我个人最顺手的一套方案。它的思路很简单把后端函数直接暴露给前端调用中间不需要手写 REST 路径也不需要手动写 fetch 封装。配合 Zod 输入校验类型可以一路从数据库推到前端组件里。一个最小可用的 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 组件里调用时类型是自动透传的import { 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 推导出来的所以即使后端改了返回结构前端编译时会直接报错而不是等运行时才发现。这才是真正能替代 Typeless 的地方——类型不是事后补救而是从源头就开始约束。如果项目已经用了 OpenAPI没法短时间换 tRPCopenapi-typescript 也能达到类似效果。它会把openapi.yaml生成一份.d.tsnpx openapi-typescript ./openapi.yaml -o ./src/types/api.ts这样前端调用fetch的时候手动指定返回值类型即可虽然比 tRPC 多了一层“同步文档”的开销但比完全 Typeless 的状态强一个量级。3.4 第四步数据库模型也走类型生成链路最后一步往往被忽略但它决定了整套方案能不能长久数据库模型层的类型必须自动化。我的做法是引入 Prisma用schema.prisma定义数据模型然后通过prisma generate自动生成类型。举个例子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 }执行npx prisma generate后代码里可以直接用import { User } from prisma/client; function toUserResponse(user: User): UserResponse { return UserResponseSchema.parse(user); }只要数据库结构变了你只需要修改schema.prisma然后重新生成TS 类型就会同步更新。那些需要手写的、容易过期的类型定义基本上可以全部删掉。这里还有一个细节值得推荐用zod-prisma-types这个工具直接从 Prisma schema 生成对应的 Zod schema。这样数据库、运行时校验、类型定义三条链路彻底合一npx zod-prisma-types ./prisma/schema.prisma ./src/generated/zod-schemas.ts之后API 入参校验和数据库出参校验都用同一份 schema避免手动重复定义。4. 迁移过程中的常见问题与排查实录4.1 老项目改造时的“类型爆炸”怎么处理迁移过程中最打击士气的是大量手写类型定义被塞进types.ts文件越写越长最后根本没人知道哪个类型在用、哪个已经废弃。后来我用三个办法解决能用z.infer/Prisma.xxx推导的类型一律不手写。公共基础类型放到domain/目录按业务模块分包而不是集中在一两个文件里。用ts-prune定期扫未导出的死类型发现直接删。另外一个实战心得是除非万不得已不要写“所有字段都可选”的类型。这会重新滑向 Typeless 的深渊。如果某个字段确实可空应该让 schema 和数据库模型都显式标出nullable这样调用方才能感知到处理空值的责任。4.2 加了运行时校验之后性能会不会崩“Zod 会不会影响 QPS”是我听到最多的问题。我实测下来的结论是常规业务接口几乎感知不到差异但如果接口本身极端高频并且你整条链路做了多层重复校验那积累起来确实有损耗。我的建议是精确控制校验点请求入口校验一次数据库读出来如果有必要再校验一次业务中间层不要反复parse同一个对象。如果某个对象是热路径尽量让 schema 简单一点不要嵌套多层深结构任何校验库面对深嵌套对象的代价都是平方级增长的。还有一个进阶思路在开发环境保留完整校验生产环境对某些不重要的内部接口跳过校验或者用z.fastParse如果有这种轻量模式。不过这个优先级很低绝大多数项目根本到不了需要优化 Zod 的程度。4.3 团队协作时怎么让大家不再“偷懒”绕开类型技术方案能不能推行下去一半靠工具一半靠约束。我们团队当时定了三条不可妥协的规矩新接口必须有 Zod schema没有 schema 的接口不允许合入主干。前端调用接口时禁止把返回值强转成自己手写的类型as unknown as Xxx这种写法直接结不了单。Pull Request 流水线里增加tsc --noEmit和 ESLint 检查类型不过一律不能合并。前两条靠代码 Review 守住第三条靠工具强制。前面一两个迭代会有些反抗情绪但大家后来发现提交代码时的报错提示比线上告警好处理得多。我还把常见的类型错误整理成了一个小文档挂在项目 README 里比如“不要在 schema 外单独写 interface”“不要为了省事把校验函数放在被循环调用的 utils 里”“tRPC 的 input 不支持 date 类型要记得转 string”。这些小的规范细节能帮新成员快速进入状态。4.4 一个问题速查表问题现象大概率原因排查思路编译报错对象可能为 null数据库字段可空但 TS 没推断出来检查strictNullChecks是否开启必要时用校验 schema 收窄接口返回了预期外的字段DTO 没有做严格过滤响应统一用 schema.parse多余字段直接不让过前后端类型对不上但编译没报前端手写了返回类型绕过了推导删除手写类型改成z.infer或 tRPC 自动推导修改了数据库字段代码没提示Prisma 没有重新 generate执行npx prisma generateZod 校验失败但错误信息不友好直接用了 throw改用 safeParse并在返回层做 flatten老模块一直没迁移类型检查形同虚设allowJs 开着JS 文件不检查规划迁移节奏逐步把 allowJs 关掉这套表一直保留在我们团队的内网 Wiki 上后来还补充了不少新成员踩过的坑。比如有一次新同事在 tRPC query 里直接返回了 Date 对象导致前端序列化出错我们在表里又加了一条“跨进程传输的数据必须可序列化”的提示。另外迁移初期还有一个特别容易忽略的问题process.env里的环境变量别偷懒直接当成string用。我一开始就吃过亏某个配置项的读取在本地正常、在测试环境变成undefined排查了好久才发现是部署配置漏了。后来我用一个小的环境变量 schema 统一校验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, };这样任何环境变量缺失服务启动时就会立刻失败而不是运行到某个深层逻辑才报错。最后再分享一个很小的技巧迁移完成之后把原先那些“防君子不防小人”的注释全部清理干净比如// 这里的 data 可能是 null要小心、// 后端返回的字段名总变别用解构。这些注释本身就是 Typeless 留下的债删掉它们意味着你不再用“提醒”来对抗不确定性而是用清晰的类型和校验来承接真实数据。
返回列表