t3code:以类型安全为核心的全栈代码生成器实战 t3code 这个项目起因其实特别朴素我实在受不了在每次新起一个全栈项目时都重复一遍“搭脚手架、配数据库、写接口类型、再手动对齐前后端参数”这种体力活。那段时间正好在复盘自己经手的几个中型项目发现大量时间都浪费在“类型没对齐”引起的低级 bug 上。所以我开始动手写一个以类型安全为核心的代码生成器也就是 t3code它专门负责把一套包括前端请求层、后端路由层、数据库模型层在内的全栈骨架通过命令行工具自动生成出来。这篇文章不打算做成产品说明书而是从一个实际开发者角度把 t3code 的核心设计、技术选型、实现细节、踩坑记录都摊开讲一讲给想做类似脚手架、代码生成器或者想理解全栈类型安全实践的人一点参考。1. 项目背景与目标拆解1.1 t3code 解决的到底是什么问题先描述一个非常常见的开发场景你和团队新起一个 Next.js 项目后端用 NestJS 或者 Next.js API Routes数据库用 Prisma校验逻辑打算上 Zod。项目初始化本身很快真正的心智负担在于三层之间的对齐。前端调用接口时请求参数是否和后端 DTO 一致后端返回数据是否真的和前端声明的 interface 一致数据库表结构变更之后之前的接口类型是不是已经悄悄失效了很多团队靠“约定”约束这个问题靠 code review 检查偶尔靠运行时的报错提醒。但这些都属于事后补救真正能做到“写代码的时候就不容易错”的方案还是依赖把类型定义作为唯一事实来源再通过工具自动生成其它层的代码。t3code 做的工作就是你提供一份集中式 schema 描述我用了 Zod schema 作为唯一事实来源它自动生成 Prisma model、tRPC router、前端 API 调用函数和类型定义。生成的代码保证类型一致并且带有完整的 TS 类型确保你在编译期就能发现结构不匹配的问题。1.2 为什么选择“类型安全优先”作为核心路线我在做技术选型的时候其实没有第一时间就想做全栈代码生成中间也对比过其它思路。一种做法是手动维护多个目录比如 postman 集合、openapi.yaml、前端 types.ts、后端 DTO 文件。这种做法的问题很直观新增一个字段可能要同时改五个地方少改一处就会留下隐患。另一种做法是在运行时做数据校验比如后端入口处统一校验前端统一处理错误提示。这样可以兜底但前端 UI 上的类型推导仍然是空的。t3code 的中心思想是“用编译期工具代替运行时排查”。代码生成之后前端直接拿到了精确到字段级别的响应类型根本不存在“手动声明 interface 但实际数据不匹配”的问题。再加上 tRPC 天然的端到端类型推导前端调用一个服务端方法参数类型和返回类型都是自动推导出来的。这一套对我来说是收益最大的因为运行时校验只能防住坏数据而类型对齐能从源头防住“错误的调用方式”。1.3 适合什么人参考这个项目我最初设想 t3code 的服务对象是自己后来发现身边不少同事也受这个问题困扰。它最适配的人群有三类。第一类是自己维护多个全栈小项目的人这类人不希望为每个项目重新配置一遍数据库模型和请求层希望开箱即用。第二类是团队里正在推行“类型驱动开发”的工程师他们需要一个可见、可控的生成流程而不是靠自觉遵守约定。第三类是对代码生成器本身感兴趣的开发者t3code 的实现思路、模板渲染方式、交互式命令行设计都可以直接借鉴。怎么判断你的场景适不适合用代码生成我自己的经验是看“模板化程度”和“类型关联密度”。如果项目里实体多、层级深、字段多而且前端后端都围绕同一批数据结构转那代码生成几乎就是最优解。如果项目只是个小接口孤岛两个文件之间没有强关联那手工维护反而更简单。t3code 不是银弹它是为了解决“中等复杂度以上的全栈项目”这种具体问题存在的。2. 技术选型与整体架构2.1 核心依赖与选型理由t3code 是一个 Node.js 命令行工具采用 TypeScript 编写。技术栈的核心是这几项组件选型理由运行时Node.js 20生态成熟CLI 工具开发效率高语言TypeScript与生成目标一致便于复用类型逻辑schema 定义Zod v3既有运行时校验又能提取静态类型模板引擎EJS语法简单支持 include 和逻辑分支代码生成自研 renderer prettier保证生成内容统一格式化CLI 交互commander prompts参数解析和交互式问答都够用数据库 ORMPrisma生成目标对 zod schema 的 model 映射很直接API 层tRPC生成目标端到端类型推导效果好返回值天然类型安全我特别要解释一下为什么用 EJS 而不是更现代化的模板方案。代码生成器和普通页面模板不太一样它需要处理大量“这个字段有默认值就渲染 default没有就省略”的逻辑EJS 的直接% %语法在这种场景下反而最直观。调试起来也容易模板渲染报错可以直接定位到具体行号。相比之下JSX 模板或者 AST 转换的方案学习成本高维护成本也不低。2.2 t3code 顶层目录结构和数据流t3code 项目本身的结构我按“解析层 / 模型层 / 生成层 / 渲染层”四层来拆t3code ├── bin │ └── index.ts # CLI 入口 ├── src │ ├── commands │ │ ├── init.ts # 初始化项目 │ │ ├── generate.ts # 核心生成逻辑 │ │ └── add.ts # 追加新的 model │ ├── parser │ │ └── schema.parser.ts # 将 zod schema 解析为中间模型 │ ├── models │ │ └── entity.model.ts # Entity、Field、Relation 等结构体 │ ├── renderers │ │ ├── prisma.renderer.ts │ │ ├── trpc.renderer.ts │ │ └── client.renderer.ts │ └── templates │ ├── prisma │ ├── trpc │ └── client └── tests数据流在设计上保持单向用户定义 Zod schema → parser 读取并转换为中间模型 EntityModel → renderer 基于模板和中间模型生成字符串代码 → prettier 格式化后写入目标目录。这样设计的好处是模板只管渲染parser 只管解析中间模型成为稳定契约。以后如果要支持另一个 schema 来源比如 JSON Schema 或者手写 YAML只需要新写一个 parserrenderer 完全不动。2.3 中间模型为什么是 EntityModel代码生成最怕的是“边解析边渲染”的耦合写法那样做看起来省事实际一旦模板复杂度上来几乎没法测试和复用。我花了不少精力设计中间模型。一个核心的 EntityModel 结构简化下来长这样interface EntityModel { name: string; tableName: string; fields: FieldModel[]; relations: RelationModel[]; } interface FieldModel { name: string; zodType: string; // z.string() / z.number() 等原始类型 isOptional: boolean; isNullable: boolean; hasDefault: boolean; defaultValue?: string; isUnique: boolean; isId: boolean; } interface RelationModel { type: one-to-one | one-to-many | many-to-many; targetEntity: string; foreignKey: string; backRelationName: string; }很多刚写代码生成器的人会犯一个错误直接把用户输入的 schema 当成模型用。比如解析 zod 时发现z.string().optional()就直接往模板里塞“string optional”这种描述。但等到要生成 Prisma model 时你会发现 Prisma 的 optional 和 nullable 是不同概念Zod 的 optional 也不完全等价于数据库字段的 optional。如果没有中间层做语义转换模板里就会堆满各种条件判断甚至出现“这个模板只适用于某个特定结构”的硬编码情况。t3code 的 parser 做的就是把 zod 类型“翻译”成 EntityModel这一步把z.string().email()提取成{ zodType: z.string().email(), constraints: { email: true } }再由 renderer 决定该约束在 Prisma 里怎么表达、在 tRPC 输入类型里怎么表达。语义统一之后每种 renderer 只需要关注自身领域的映射规则。3. 核心功能拆解与实操记录3.1 init 命令从零生成一个全栈项目t3code 的init命令承担着“项目初始化”的重任。执行后它会做几件事检查当前目录是否为空生成基础配置文件package.json、tsconfig、prisma schema 入口、env 示例创建 src 目录结构安装依赖。我在设计交互流程时最关注的是减少决策成本。init不会问太多问题只问三个项目名、包管理器偏好、是否包含示例代码。其它全部采用默认值等用户通过后续命令修改。这符合脚手架工具的使用预期毕竟用户最想要的是“先把项目跑起来”而不是一上来就被十几个配置问题淹没。初始化完成后生成的核心配置文件大概是这样的t3code init my-app --pm pnpmmy-app ├── package.json ├── tsconfig.json ├── .env.example ├── prisma │ └── schema.prisma └── src ├── server │ └── trpc.ts └── client └── api.ts跑完这一步项目已经可以pnpm dev启动这时候只有一个示例的 ping 路由用来验证链路通不通。真正有用的东西还在后面。3.2 用 Zod schema 定义实体并生成三端代码这是 t3code 的核心使用流程。我在示例项目里定义了一个非常典型的场景用户和文章。import { z } from zod; export const userSchema z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(1), createdAt: z.date(), }); export const postSchema z.object({ id: z.string().uuid(), title: z.string().min(1), content: z.string(), authorId: z.string().uuid(), published: z.boolean().default(false), tags: z.array(z.string()).default([]), });在 schema 目录里记录下这两个对象之后执行t3code generatet3code 会自动完成以下操作首先在 Prisma schema 中生成 model User 和 model Post并且根据authorId识别出 user 到 post 的一对多关系其次生成 tRPC router包含按 id 查询、列表查询、创建三种基础操作最后在前端生成api.ts的调用函数直接导出getUserById、getPosts、createPost这些类型安全函数。生成的 tRPC 路由简化后大致是这个样子import { router, publicProcedure } from ../trpc; import { z } from zod; import { prisma } from ../prisma; export const userRouter router({ getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(({ input }) { return prisma.user.findUnique({ where: { id: input.id } }); }), });对应前端生成的调用函数import { trpc } from ../client; import type { User } from ../types; export const getUserById (id: string) { return trpc.user.getById.query({ id }); }; export type { User };这里需要强调一个细节t3code 不会把 Prisma 的类型直接暴露给前端而是通过 tRPC 的输出类型天然推导。你在前端 IDE 里输入getUserById(...)返回值类型就自动是 User 完整对象。这比“生成一堆 export interface”的方案更优雅因为 tRPC 已经替你维护了类型的一致性。3.3 add 命令增量追加实体项目进入中期后通常会频繁添加新实体。t3code 的add命令专门处理这种增量场景它的流程是先扫描现有 schema 文件列表询问需要关联哪些已有实体再生成单独的 model 文件最后更新 router 聚合入口。增量生成最怕的是“重复跑生成把已有代码覆盖掉”。我的设计原则是t3code 只处理自己生成的那部分内容凡是检测到目标文件不是由 t3code 生成的没有对应的标记注释就跳过写入并给出警告。这样既保证了生成可靠性又不会破坏用户手动扩展的代码。标记注释长这样// t3code-generated export const userRouter router({ ... });简单但实用。你永远不会担心跑一次生成命令把辛辛苦苦手写的自定义查询覆盖了。4. 开发过程中的关键细节与踩坑4.1 模板转义与 EJS 语法冲突使用 EJS 作为代码生成模板会遇到一个很隐蔽的问题生成的代码本身包含 JavaScript 模板字符串、泛型尖括号和正则表达式。EJS 的默认标签和模板字符串的${}混在一起时非常容易被错误解析。我在早期就被Type ${string} is not assignable to type这种报错坑过好几回。查到最后发现是模板里写着一个前端的模板字符串const url /user/${id};EJS 解析器会尝试把${id}当作自己的插值语法处理然后报错。解决方案有两种第一种是把模板字符串拆开写避免和 EJS 冲突const url /user/ id;第二种是使用 EJS 的escape函数或者在模板里临时切换自定义标签。实际上最稳妥的做法是在模板里写一个辅助函数统一处理字符串拼接。因为我生成的是 TypeScript 代码完全可以把 URL 拼接转换成函数调用从源头避开冲突。经验之谈代码生成器的模板不要贪图“在模板里直接写目标语言里的一切表达”。越复杂的模板越容易踩转义坑。宁可让辅助函数多一些也要保证模板的渲染逻辑简单可预期。4.2 Prisma 与 Zod 的默认值语义差异这是我在整个开发过程中踩过最深的坑也是最后悔没有提前做语义分析的地方。Zod 的z.date().default(() new Date())和 Prisma 的default(now())看起来语义相近实际生成的代码不能直接划等号。比如在 Zod 里default的作用是“当输入值未定义时给出一个默认值”。但在 Prisma 里default是数据库层面的默认值。如果两者同步生成生成出的代码就可能出现一个尴尬情况前端调用createPost时不传createdAtZod 会生成一个new Date()然后这个值被传给数据库数据库的default(now())反而永远不会触发。这个问题本身不会报错但会让数据真实性和代码一致性产生隐患。t3code 的处理方式是把 Zod 的 default 明确分为“仅生成端默认值”和“数据库默认值”两种语义由用户用dbDefault这种扩展标记显式指定。如果没有显式指定生成 Prisma model 时就不会为这个字段写default语句避免两套默认值机制互相干扰。4.3 关系识别的心智模型刚开始实现关系识别时我试图通过字段命名猜测关系比如某个字段叫authorId就默认指向 User 实体的 id。这种做法在小范围内很灵但一旦遇到多字段关联、联合主键、非规范命名的情况就漏洞百出。后来我决定把关系声明也纳入 schema 定义用 T3 的扩展方法增强 ZoduserSchema.t3relation({ name: posts, target: post, type: one-to-many, foreignKey: authorId, });这样关系信息不是通过“猜测”得到而是由用户显式声明。虽然多了一点学习成本但是可靠性完全不一样生成的 Prisma relation 和 tRPC 查询函数才不会出现错连、漏连的情况。这也让我再次确信代码生成器应该尽可能把隐式约定变成显式配置省心才是长期价值。5. 常见问题排查与性能优化5.1 生成失败时的排查顺序表我整理了一张当时在项目 issue 里反复回答的排查表对新手尤其有用现象优先检查原因生成后前端类型报错tRPC router 输出是否正常后端路由名或输出类型不匹配Prisma client 类型缺失是否执行过 prisma generate生成代码依赖 db 类型关系字段不存在schema 里是否显式声明 t3relationt3code 不自动猜关系模板渲染报错目标代码里是否存在模板字符串EJS 对${}解析冲突Zod validation 报错是否有 optional/default 混用运行时生成了 undefined格式化后文件大改prettier 配置是否缺失生成代码内格式不统一初学者最容易忽略的是第 5 条。Zod 的.optional()和.default()同时使用时值可能是合法但不直观的。比如z.string().optional().default()和z.string().default()这两个行为不完全相同。t3code 里我统一建议用.default()表示“总是有值”.optional()表示“允许显式传 undefined”。这个心智模型比“optional 等于可以缺省”要精确得多。5.2 生成器性能瓶颈分析t3code 生成几百个文件的场景并不少见。这时主要瓶颈不在模板渲染上而在两步一是 EJS 模板里大量使用字符串拼接和条件判断二是在完成后全量调用 prettier 格式化。实际上 prettier 格式化在文件数量超过 300 个以后会明显变慢。我做过一次 Profile发现格式化耗时占了整个生成流程的 75%。解决方案是只对最终落盘的文件做校验式格式化并且通过prettier --check跳过内容完全未变的文件。另外EJS 编译也有优化空间t3code 在首次加载后会把编译好的模板函数缓存起来后面的实体只需要复用函数不用重复编译模板。一个细节是模板中对共同片段的提取也能显著提升性能。例如 Prisma 的 scalar 类型映射表数百个字段反复渲染String db.VarChar(255)完全没必要走一遍复杂判断逻辑预编译成一个常量映射表就好。5.3 我用压力测试验证生成结果为了验证生成的代码在大规模数据模型下仍然可靠我构造了一个包含 50 个实体、500 个字段、80 个关联关系的测试模型。生成时间稳定在 1.8 秒左右包含 prettier 格式化生成的代码能够通过 TypeScript 严格模式编译没有任何any类型泄漏。这个压力测试的价值不在于展示跑得有多快而在于证明生成器在成规模的模型面前没有“手忙脚乱”。小模型下很多设计问题会被掩盖只有规模上来模板的缓存设计、语义中间层的稳定性、类型导出的完备性这些细节才会被真正检验到。那次测试之后我重构了 printer 的调度逻辑把所有生成任务统一排队执行并支持并行渲染文件写入则保持串行避免 IO 竞争。最终生成时间从 4 秒降到了 1.8 秒。这个优化过程一点都不神秘就是先找出瓶颈再针对性处理。6. 从 v1 到 v2 的迭代心得6.1 用户反馈逼出来的三种能力t3code 最开始是我个人工具直到我把它开源并给朋友试用之后才逐渐往通用方向发展。第一阶段收到最多的反馈是“生成的代码风格和团队不一致”。有人用单引号有人用双引号有人习惯末尾不加分号有人必须加。这个问题的本质是代码生成器只生成了内容没有适配特定团队的代码风格。v2 版本直接内置 prettier 配置继承机制从宿主项目的 prettier 配置中读取偏好生成代码后统一格式化彻底解决了这个问题。第二阶段反馈是“我想生成的不只是 CRUD还有业务逻辑”。这属于我一开始刻意回避的领域。但后来的方案是允许用户在 schema 里写自定义 handler 名称t3code 会为它生成空的 router 函数体供用户手动填充。这平衡了机器生成与人工扩展的边界。第三阶段反馈是“需要支持多数据库”。Prisma 本身已经支持多数据库但 Zod 的字段映射到不同数据库方言时存在差异比如z.string()在 PostgreSQL 里是text在 MySQL 里可能需要取varchar(191)。后来我在 EntityModel 的 FieldModel 里增加了dbType字段允许用户覆盖默认映射最终达到了“数据库方言可适配”的能力。6.2 我自己用 t3code 重写旧项目的真实感受如果说以上都是理性层面的迭代那我自己用它重写一个旧项目的经历则更说明问题。那是一个做了两年的后台管理系统原来类型分散在各处改一个实体字段要小心翼翼搜索所有引用。我把核心 schema 提取出来通过 t3code 重新生成底层服务之后最大的感受是“改字段不再心虚了”。以前改一个字段要同时检查数据库迁移、后端 DTO、前端表单校验、详情页展示、列表列定义。现在只需要改一处重新跑 generate更新数据库迁移其他层的类型和接口会跟着变。如果生成后编译报错一定是我改漏了业务逻辑而不是类型结构不一致。这种“错误发生得越早越好”的体验让我对类型驱动的代码生成越来越有信心。6.3 给同样想做代码生成器的人三条建议第一条建议很实际先定义你的中间模型再写任何模板。中间模型是代码生成器里最重要的抽象它决定了你能生成多少种目标框架。没有中间模型代码生成器就是一个高级的“查找替换工具”有中间模型它才是一个产品。第二条建议是模板里减少逻辑所有复杂逻辑尽量放入 renderer 层。这样模板保持简单团队其他人理解和维护时不需要推导太多心智模型。第三条建议是设计好“生成标记”和“用户自定义区”的分界。让用户知道哪些文件可以放心让工具覆盖哪些文件是人工定制区。t3code 通过文件头部注释清晰地划分这条边界避免了多次生成后文件被覆盖的灾难。最后想说的是t3code 这套生成流程未必适合所有项目但它背后“以类型为核心、以模板为外延、以生成为手段”的思路我相信对做全栈工程的人都有一点参考价值。如果你正好也在为“类型不一致”“接口层重复劳动”这些问题头疼不妨从自己的项目里挑一个小模块用同样的思路做一个最小验证。很多时候一次小实验比读十篇架构文章更能让你找到答案。