t3code:用类型安全重构全栈TypeScript工程化实践
什么才是好代码我入行前五年脑子里全是“能跑就行、上线再说”直到被人按着头重构了一套自以为很熟练的业务系统才意识到代码的底气不是来自会多少框架而是来自类型。t3code这个名字第一次出现在我面前时我还以为是某个新出的脚手架后来跟着项目用了两个多月才明白它其实是一整套把“类型安全”当成一等公民的代码组织规范。一句话概括t3code是面向现代全栈 TypeScript 项目的工程化实践方案核心思路是把后端数据模型、接口入参出参、前端请求层全部收拢到同一套类型体系里让编译器帮你盯住数据流转中的每一个细节。它能解决的最大痛点是前后端各写各的类型、接口一改前端崩一片的经典问题适合正在使用 Next.js、React、Node 或 Prisma 等技术栈的团队也适合想提升代码可维护性的独立开发者参考。这套方案不挑具体框架真正值钱的是如何用一套类型定义串起数据库、服务端校验、路由和前端状态管理。下面我把整个设计思路、关键细节和实际落地过程完整拆开来讲。1. 整体设计思路为什么要“类型驱动一切”我刚接触t3code时最直观的感受是它设计的起点不是目录结构也不是代码风格而是“类型定义”。整个项目从数据库表结构到前端 UI 组件所有东西都围绕着同一份类型描述运转这种设计看似增加了前期成本实际上是对长期维护的一种现金流优化。1.1 静态类型与运行时校验的分工逻辑很多团队到现在还在争论“要不要上 TypeScript”其实这个问题早就该换成“静态类型和运行时校验如何分配”。打个比方静态类型就像大楼的设计图纸告诉你每一堵墙、每一根梁该放在哪里运行时校验则是施工现场的监理确保运进来的钢筋水泥真的符合图纸规格。t3code的做法是把两者拆开TypeScript 类型管编译期Zod 这类校验库管运行期二者配合而不是互相替代。之前见过很多人写代码时只用 TypeScript 的 interface 定义入参然后在函数里手写 if 判断参数格式这种方式等于既没吃到静态类型的红利也没享受到运行时校验的兜底。t3code的做法是用一份 Schema 同时产出两个东西一个是运行时的校验器一个是静态的 TypeScript 类型。数据进来先过 Zod 校验过了之后 TypeScript 就已经知道这个变量的精确形状后续逻辑里再也不用疯狂写类型断言。这种设计的真正价值在于“单一事实来源”数据库模型、API 入参、前端表单共用同一套 Schema改一处就同步到位。否则会出现最常见的那种事故后端改了字段名前端 TypeScript 编译过了因为前后端各自维护了一份类型但线上接口返回的数据根本没有那个字段页面直接白屏。1.2 t3code 与全栈类型安全的取舍说到全栈类型安全很多人第一反应是 T3 Stack 那套方案tRPC 加 Prisma 加 Next.js 的组合确实把类型体验拉满了。t3code从设计哲学上吸收了这套思路但做了两个很务实的调整一是让类型体系可以脱离具体框架独立存在二是强调在业务边界上显式校验而不是依赖框架魔法。我先说第一个调整。用了 tRPC 的人都知道端到端类型推导有多爽但随之而来的问题是强绑定如果你的服务不是 Node.js、或者前端不是 React这套类型推导就不成立。t3code在设计时把“Schema 描述契约”做成了中间层底层就算换成 Fastify、Express、Koa甚至未来换数据库只要 Schema 层的语义没变前端代码可以基本不动。第二个调整是针对很多人盲目追求“全端类型安全”走的弯路。实际开发中有一个场景特别典型第三方开放平台的回调、登录态校验、文件上传的元信息这些数据根本不在你的类型体系里硬用 tRPC 去套反而会让代码充满类型断言和 any。t3code建议的做法是在这些外部边界上手动建立信任点用 Zod 校验一次之后立刻转换成内部类型之后才进入类型安全的走廊。这个取舍比追求 100% 的类型覆盖更重要。1.3 Monorepo 目录如何支撑这套体系类型要打通代码仓库的边界必须先理顺。t3code默认采用 monorepo 结构但不是为了炫技而是为了物理上隔绝不同职责的代码同时让共享类型自然流通。我当时按它的推荐搭了一套这样的结构apps/ web/ # Next.js 前端应用 api/ # Node 服务端应用提供 API packages/ db/ # Prisma schema 与数据库访问层 validators/ # Zod schema前后端共享的契约层 config/ # eslint、tsconfig 等共享配置 utils/ # 与类型无关的工具函数这个结构解决了一个关键约束apps/web和apps/api都不能直接定义业务类型所有类型要么来自validators包要么来自db包。这样做看起来很严格但实际体验是写代码时心里特别有底因为你知道任何跨端的数据结构都有唯一归属地不会出现同一个 UserProfile 在两个包里有两份实现。另一个好处是依赖关系单向流动。validators和db是底层包apps依赖它们但它们不依赖任何上层。一旦有人想在里面塞 HTTP 请求逻辑代码评审时一眼就能拦住。这种人在团队里肯定出现过觉得“这个类型转换函数放工具包里多方便”结果工具包越来越重最后改个 ES 版本都可能引发连锁报错。2. 核心细节解析与实操要点理念讲完聊点能直接上手的。t3code的核心细节主要集中在 Schema 规范、路由组织、数据模型和边界防护这四个层面每个层面都需要一些实际执行标准。2.1 用 Schema 而非 interface 定义类型契约如果你打开t3code项目的packages/validators会发现里面的代码几乎没有手写的 TypeScript interface取而代之的是统一的 Zod Schema 写法。这个差异很值得展开。传统方式是先写 interface 再写一个 validator或者干脆不写 validator// 传统方式 interface CreateUserInput { email: string age: number tags: string[] } function validateUserInput(input: unknown) { // 手动判断容易漏 }t3code的做法是只维护一份 Zod Schema然后让 TypeScript 类型从它自动推导import { z } from zod export const createUserSchema z.object({ email: z.email(), age: z.number().int().min(18).max(120), tags: z.array(z.string()).max(10).default([]), }) export type CreateUserInput z.infertypeof createUserSchema为什么坚持不用手写 interface核心原因是避免“两处维护”。interface 是结构类型Zod 是运行时校验器它们描述的是同一个东西一旦其中一边更新遗漏编译器和运行时的行为就会不一致。比如你给 interface 加了isAdmin: boolean但忘了在 Zod 里补那服务端校验时这个字段被剥离前端拿到 undefined又因为 TypeScript 类型上写着 boolean代码里直接把它当布尔值用——这个 bug 隐蔽到你要查很久才能明白问题出在类型定义边界。Zod 还有一个 interface 替代不了的刚需默认值处理。createUserSchema里给tags加了.default([])这意味着输入里完全可以不传tagsZod 校验后会自动补成空数组返回的数据结构永远是完整的。这个特性在表单场景尤其好用前端少写大量?? []防御代码。你还需要注意 TypeScript 类型是从 Schema 推导出来的反向推导看起来也能做但z.infer的体验比z.input直观得多明确区分“输入类型”和“输出类型”是很重要的点。Zod 里输入可能含可选字段输出经过 default 之后就变成必选字段这套逻辑在类型推导上是自动完成的手写 interface 永远做不到这么丝滑。2.2 tRPC 路由组织按业务域切分而非按文件类型如果你用了 tRPCt3code对路由组织有一个实战性很强的建议按业务域一刀切而不是按“查询/变更”这类动词来分行。很多初学者会把所有 query 放在一个文件、所有 mutation 放在另一个文件这在项目小的时候好像挺清爽业务复杂起来就会发现一个用户域的功能散落在三个文件里改一个需求要来回跳转。我按推荐的模式落地过一套server/ routers/ user/ index.ts # user 路由聚合 profile.ts # 与用户资料相关的 query/mutation account.ts # 与账号安全相关的 query/mutation post/ index.ts list.ts detail.ts editor.ts每个子路由尽量控制在 200 行以内超过就说明这个业务域拆得还不够细。index.ts只做聚合比如user路由把profileRouter和accountRouter合并进createTRPCRouter({ profile: profileRouter, account: accountRouter })前端调用时就能天然形成api.user.profile.update这种接近口语的语义链。大项目里还需要注意中间件。t3code推荐把鉴权、日志、租户上下文这类横切关注点放在 procedure 的 middleware 层而不是每个 handler 里重复写。一个典型的受保护 procedure 是这么组织的import { initTRPC, TRPCError } from trpc/server const t initTRPC.context{ userId?: string }().create() export const protectedProcedure t.procedure.use(async ({ ctx, next }) { if (!ctx.userId) { throw new TRPCError({ code: UNAUTHORIZED }) } return next({ ctx: { userId: ctx.userId } }) })这样写的好处是让你把权限逻辑集中在一条链路上配合 TypeScript 的类型收窄效果后面的 handler 自动知道ctx.userId一定存在不用再写一堆防御性判断。2.3 数据建模Prisma 优先但别让它污染业务层t3code的另一大核心是 Prisma 作为 ORM 层但很多人用 Prisma 时有个坏习惯直接在业务代码里查询并返回 Prisma 的模型对象。这样做短期没问题长期会踩到几个坑Prisma 对象包含所有数据库字段包括passwordHash这类敏感字段一旦你把它直接塞进 API 响应审计追责时很被动。t3code里的标准姿势是让 Prisma 只管持久化暴露给 tRPC 的内容必须经过 Schema 层的挑选和转换。实现起来也挺简单在db包里定义查询函数返回值统一映射成业务层类型export async function findUserSafe({ id }: { id: string }) { const user await prisma.user.findUnique({ where: { id } }) if (!user) return null const schema userSchema.parse(user) return schema }这里有一个细节容易踩坑userSchema.parse如果校验失败会直接抛异常导致接口返回 500。更好的方式是先用safeParse判断或者让 Query 层保证查询结果一定符合 Schema。我一般推荐后者因为数据库表字段和业务 Schema 本质上就不完全等价直接 parse 等于把数据库问题暴露到了接口层。讲到 Prisma 和 Schema 的配合还有一个绕不开的映射问题后续我会专门展开。2.4 边界防护乐观更新与错误处理的平衡类型安全不等于永远不出错网络请求有可能失败、数据库可能抖动、第三方回调可能畸形。t3code在边界防护上强调三个层次第一层是入参校验tRPC 的input里塞进 Zod Schema 后非法请求会在进入 handler 之前就被拦截这是第一道防线。第二层是服务端输出校验在用prisma.find*查询之后建议对复杂聚合结果跑一次 Zod 校验确保发送给前端的数据结构是预期的。这层开销很小但能拦截不少低级 bug。第三层是前端错误处理。如果你是 React 技术栈t3code直接复用 TanStack Query 的机制把错误映射到 UI 状态。比如乐观更新时如果 mutation 失败了需要从服务端拿错误信息回滚本地状态。这一层有个核心经验不要只把错误显示成一个 toast还要在 form 里保留用户输入。很多表单在提交失败后直接把状态清了用户得从头填一遍这是体验事故。t3code在 React Hook Form 和 Zod 之间做了一个很聪明的整合用zodResolver把校验信息注入表单状态失败时每个字段的错误提示自动对应到具体输入项用户可以就地改而不是重来。3. 实操过程从零搭一套 t3code 风格的任务管理功能理论说多了容易飘我拿一个“任务管理”的经典场景完整走一遍流程。从初始化到前端复用类型所有代码都是可以直接复制的常见实践。3.1 初始化项目与安装依赖假设你有一个 npm 管理的空目录先初始化 monorepo 基础配置。这里我以 pnpm 为例因为它的 workspace 支持最顺畅mkdir t3code-demo cd t3code-demo pnpm init然后创建pnpm-workspace.yamlpackages: - apps/* - packages/*接下来创建三个子包前端apps/web、服务端apps/api、共享契约packages/validators。为了避免篇幅过长我不逐行贴所有脚手架代码但关键依赖你要清楚前端next、react、react-dom、tanstack/react-query、trpc/client、trpc/react-query、zod服务端trpc/server、prisma、prisma/client、zod契约包zod和typescriptvalidators包的作用在于前后端都能引入它里面对业务对象做统一 Schema 定义。这个包不能依赖任何 HTTP 层框架因为它就是纯粹的契约描述。3.2 定义任务模型Prisma Schema 和 Zod Schema 的联动现在定义“任务”的数据结构。数据库层面我用 Prisma 定义model Task { id String id default(cuid()) title String description String? status TaskStatus default(TODO) createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String } enum TaskStatus { TODO IN_PROGRESS DONE }然后是在validators包里定义对应的 Zod Schema。这里需要重点处理 Prisma 枚举和 TypeScript 字符串字面量的映射import { z } from zod export const taskStatusSchema z.enum([TODO, IN_PROGRESS, DONE]) export type TaskStatus z.infertypeof taskStatusSchema export const taskSchema z.object({ id: z.string().cuid(), title: z.string().min(1, 标题不能为空).max(100), description: z.string().max(500).nullable(), status: taskStatusSchema, createdAt: z.date(), updatedAt: z.date(), userId: z.string().cuid(), }) export const createTaskInputSchema taskSchema .omit({ id: true, createdAt: true, updatedAt: true, userId: true }) .extend({ title: z.string().min(1, 标题不能为空).max(100), }) export type CreateTaskInput z.infertypeof createTaskInputSchema这里有个实操经验让taskSchema和 Prisma 的返回结构保持形态一致但不共享同一个类型。因为 Prisma 的 DateTime 返回的是 Date 对象前端如果直接拿 JSON 传输Date 会被序列化成字符串所以你在 API 边界最好做一次显式处理。不过在 tRPC 场景下因为序列化协议是 JSONz.date()校验会出问题你需要给 Zod 加一个z.coerce.date()或者在前端把字符串转 Date。我踩过一次这个坑服务端返回createdAt: DatetRPC 传到浏览器后变成了 ISO 字符串前端代码里如果期望 Date 对象调getTime()就挂了。解决方案有两个一是用superjson做 transformertRPC 官方推荐的方案二是在 Schema 层把日期字段定成z.string()前端再手动格式化。我建议用方案一因为它对全局类型体验更友好。3.3 实现 tRPC Router受保护路由和任务 CRUD服务端路由核心代码如下这是简化但完整的流程import { initTRPC, TRPCError } from trpc/server import { z } from zod import { prisma } from ../db import { createTaskInputSchema } from validators const t initTRPC.context{ userId?: string }().create() const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.userId) { throw new TRPCError({ code: UNAUTHORIZED }) } return next({ ctx: { userId: ctx.userId } }) }) export const taskRouter t.router({ list: protectedProcedure .input(z.object({ status: taskStatusSchema.optional() })) .query(async ({ ctx }) { return prisma.task.findMany({ where: { userId: ctx.userId, ...(status ? { status } : {}), }, orderBy: { createdAt: desc }, }) }), create: protectedProcedure .input(createTaskInputSchema) .mutation(async ({ ctx, input }) { return prisma.task.create({ data: { ...input, userId: ctx.userId, }, }) }), updateStatus: protectedProcedure .input( z.object({ id: z.string().cuid(), status: taskStatusSchema, }) ) .mutation(async ({ ctx, input }) { return prisma.task.updateMany({ where: { id: input.id, userId: ctx.userId }, data: { status: input.status }, }) }), })注意updateStatus用了updateMany而不是update。为什么不直接用update因为update不带where条件里没有userId时会允许用户把别人创建的任务改掉这是典型的越权 bug。updateMany会把匹配数返回如果为 0 你还能继续判断是任务不存在还是没有权限做到更精细的错误提示。3.4 前端接入类型自动推导带来的体验提升前端接 tRPC 的 QueryClient 时我把 transformer 配好然后组件里就能享受到端到端类型import { api } from ~/utils/api export function TaskItem({ taskId }: { taskId: string }) { const utils api.useUtils() const { data, isLoading } api.task.list.useQuery({}) const updateStatus api.task.updateStatus.useMutation({ onSuccess: () { utils.task.list.invalidate() }, }) if (isLoading) return div加载中.../div return ( ul {data?.map((task) ( li key{task.id} span{task.title}/span button onClick{() updateStatus.mutate({ id: task.id, status: DONE }) } 完成 /button /li ))} /ul ) }这段代码里最扎实的体验是updateStatus.mutate的参数类型和api.task.list的返回类型全部是编译期自动推导出来的。如果你在服务端改了createTaskInputSchema前端这里不重新生成任何代码TypeScript 就会在本地报错提醒你调整传参。这种“改一端、另一端编译器帮你兜底”的体验用过一次就回不去了。3.5 完整跑通的验证方法项目基建搭好后建议在 CI 里加三条命令保证类型体系不腐烂pnpm typecheck pnpm lint pnpm testtypecheck是核心保障它会跑每个包的tsc --noEmit。如果你在validators包里改了一个 Schema 字段前端和服务端在同一个 PR 里都会报错评审阶段就能发现问题而不是等上线后用户发现页面白屏。4. 常见问题与排查技巧实录好方案落地时总会遇到实际问题。下面这些坑我不敢说是全部但每个都是真实项目里折腾过好几轮的。4.1 Zod 与 TypeScript 类型不同步Zod 的z.infer是足够聪明的但有个地方容易出问题当你从数据库查询后手动组装对象时字段是可选的还是没有可能跟 Schema 不一致。比如const raw await prisma.user.findUnique(...) const data { ...raw, nickname: raw?.nickname ?? , }这里nickname在 Prisma 模型里可能为string | null但业务上你想表达的是“空字符串表示未设置”于是转换成了string。如果userSchema里写的也是z.string()parse 能过如果你偷懒没写 Schema 而直接返回给前端那前端的 TypeScript 类型推断就是string | null一堆组件里就开始出现user.nickname?.trim()这种充满防御的写法。解决方案是坚持所有跨端数据必须经过 Zod Schema 转换哪怕只是简单映射也要显式 parse 一次。这样数据到了前端类型就是完全确定的可以少写很多无意义的空值判断。4.2 tRPC 的中间件类型扩展导致 handler 里类型变宽很多人第一次写受保护 procedure 后在 handler 里发现ctx.userId还是string | undefined折腾半天找不到原因。这通常是因为中间件里没有正确声明ctx的返回类型。正确写法是export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.userId) { throw new TRPCError({ code: UNAUTHORIZED }) } return next({ ctx: { ...ctx, userId: ctx.userId, }, }) })关键点在于return next({ ctx: { ...ctx, userId: ctx.userId } })必须把中间件上下文合并而不是只返回{ userId }。否则后续 handler 拿到的ctx会丢失其他上下文字段类型推导也会变得很奇怪。说白了TypeScript 不会魔法般推断出“你的中间件已经保证 userId 存在”它只认你显式返回的上下文。4.3 Prisma 枚举与 Zod 枚举的映射陷阱Prisma 的枚举和 Zod 的z.enum看起来很像但它们本质上不是同一个类型系统。你在 Prisma Schema 里定义了TaskStatus在validators包里又定义了taskStatusSchema这两者必须保持字符串值一致否则序列化传参时前端传IN_PROGRESS数据库却只认识InProgress直接报错。一个常见做法是让taskStatusSchema的枚举值和 Prisma 枚举的名字严格一致export const taskStatusSchema z.enum([TODO, IN_PROGRESS, DONE])但如果你用了z.nativeEnum直接从 Prisma 的枚举类型生成 Schema又会引入一个问题prisma/client的类型只能在服务端使用validators包如果被前端引用构建时会把 Prisma 客户端带进前端包增大体积。所以我更推荐两边都维护一份字符串枚举用 ESLint 规则或人为约束保证值一致或者干脆在validators包里自己定义export const TASK_STATUSES [TODO, IN_PROGRESS, DONE] as const再由 Prisma Schema 的迁移脚本去对齐。这算是一个组织层面的权衡没有绝对最优解但维护成本可以做到很低。4.4 部署时的环境变量与构建顺序t3code这类 monorepo 项目构建时有个特别容易翻车的细节依赖顺序。因为validators包被前后端同时引用构建时一定要先构建底层包否则前端构建会拿到一个过期的类型快照甚至找不到模块。实际操作中我用turbo编排{ scripts: { build: turbo run build --filterrepo/validators --filterrepo/db --filterrepo/api --filterrepo/web } }换成 pnpm 的--filter也一样核心是保证链路顺序。另外环境变量也要注意Prisma 的DATABASE_URL在编译和运行时都要能读到推荐用.env文件并让各个包从根目录统一加载。4.5 性能陷阱不要所有接口都用端到端类型最后这条可能和很多人的直觉相反。t3code极力推荐类型安全但它也明确反对把类型安全的边界扩展到不合适的场景。比如你有一个报表接口返回几百行聚合数据里面字段动态生成、列名由用户自定义这就不适合用强类型 Schema 约束。这种情况下比较好的做法是把它当成“外部不可信输入”在边界处定义一个相对宽泛的类型或者干脆用z.record(z.string(), z.unknown())在具体逻辑里再做局部收窄。过度设计类型反而会让代码为了满足编译器的要求做很多无意义转换降低可读性。还有一类场景是批量写接口比如批量导入任务。如果你用 tRPC mutation 一次传 500 条数据Zod 校验会变得很重同时 tRPC 的序列化也会对超长请求有压力。遇到这种情况我会建议把批量导入设计成一次独立请求Schema 用z.array(...)并手动控制大小上限而不是强行用逐条的 mutation 去循环调用。5. 一些在实际使用中沉淀下来的体会t3code不是银弹它只是把“类型安全”从一个口号变成了一套可执行的工程规范但落地效果很大程度取决于团队的执行力和对边界的理解。就我个人经验来说有两点体会最深。第一点是类型安全的价值在重构时最能体现。上线半年后改了一个用户模型的字段原本以为要在十几个文件里跑断点排查结果编译阶段就报了七八处引用错误一次性修完联调一次通过。那一刻我才真正理解为什么大家说“编译器是最好的单元测试”。第二点是不要让类型体系变成束缚。t3code给出了很多规范但你可以根据团队节奏逐步落地不需要第一天就全部到位。比如先从validators包开始统一入参校验再逐步把 Prisma 返回改造为 Schema 映射最后再引入 tRPC 这类端到端工具。渐进式改造比一次性推倒重来成功率高。另外分享一个扩展玩法t3code这套“Schema 即契约”的思路可以延伸到 API 文档生成。你可以在 CI 里加一个脚本把validators包里的所有 Zod Schema 转换成 JSON Schema然后交给文档工具渲染成 API 文档。这样文档和代码永远同步再也不用你手工维护一个 Markdown 文件然后看着它过期。最后想说的是如果你单看某个代码片段t3code里做的事情似乎每一件都不稀奇Zod 不稀奇、Prisma 不稀奇、tRPC 也不稀奇真正的价值在于把这三者的边界和协作方式定得很清楚。当你手头维护的项目开始频繁因为“前后端类型对不上”产生线上事故时不妨从今天讲到的这套方案里挑一条开始实践先从一份共享 Schema 改起比你想的要简单很多。