资讯动态

t3code:一条命令生成完整TypeScript REST模块的代码生成器

发布时间:2026/10/28 5:45:00 来源:尧图企业网站定制
如果你也经历过这种场景——后端接口文档刚更新前端就跟着改了三十个类型定义新建一个 CRUD 模块从 controller 到 repository 得一层层复制粘贴再挨个改字段名单元测试文件永远是空的因为实在没精力补——那你应该能理解我为什么会硬挤出两周业余时间做了一个叫 t3code 的命令行工具。t3code 不是框架也不是什么惊天动地的架构设计。它就是一个被我打磨到顺手的代码生成器你给它一份模块描述它给你输出一套带类型定义、测试骨架和日志追踪的完整代码文件。我坦白说市面上比它功能全的脚手架工具多得是但 t3code 是我自己按实际项目痛点一点点调出来的这几百次生成跑下来它确实把写样板代码这件事从我的工作里删掉了。这篇文章想把 t3code 从设计思路到实操细节完整讲一遍。内容包括我为什么不用现成脚手架、模板和类型生成之间的诡异冲突、一条命令生成 REST 模块的具体流程、以及我在 Windows 和 CI 环境下踩过的几个真实深坑。如果你也在维护一个中大型 TypeScript 项目或者正准备给团队做一套代码生成工具这些东西应该能让你少走不少弯路。1. t3code 的由来受够了手写样板代码的日子1.1 一个接口从需求到上线要手写多少重复代码先说一个最普通的场景产品提了个需求后端要加一个GET /api/users?page1size20的分页查询接口。如果按传统分层架构来写后端这边至少有五层要动controller 接参数、service 写业务逻辑、repository 查数据库、DTO 做参数校验、VO 做返回结构封装。前端那边也不轻松要写 API client 函数、定义请求参数类型、定义分页响应类型、处理 error 分支甚至可能还要 mock 一套数据。这几层之间的代码看上去长得不一样实际内容高度重复。尤其是类型定义// 后端 DTO export class QueryUserDto { IsInt() page: number; IsInt() size: number; } // 前端 API client 的参数类型 export interface QueryUserParams { page: number; size: number; } // 测试里的 mock 数据 const mockUsers { page: 1, size: 20, total: 0, list: [], };同一个page、同一个size在三处各写一遍。手写倒不怕怕的是改字段。比如说size字段改成pageSize你得同步改控制器、DTO、前端类型、测试 mock漏掉一个运行起来就是类型对不上或者接口悄悄返回了 undefined。我在项目里见过太多次这种低级错误引起的线上故障根因根本不是业务逻辑而是复制粘贴时漏改了一个字段名。还有一层隐藏成本是新人。新同学接到任务光搞清楚这个项目里一个模块要拆哪几个文件、每个文件里要写什么就可能花一两天。等他终于照着老代码模仿出一套风格大概率跟老代码有微妙差异有的人用 class 封装 service有的人用 function有人抽了 BaseController有人全部平铺。时间一长代码库就变成了风格拼盘谁维护谁头疼。我当时的想法很直接如果这些重复结构是必然会出现的那就让工具去生成而不是让人去复制。人只负责写这部分跟那部分不一样的地方——也就是业务描述。1.2 T3 三原则Type-safe、Testable、Traceable工具起名 t3code不是因为我蹭某个知名技术栈的热度纯粹是想表达三个以 T 开头的原则也是我在设计生成器时给自己定的三条红线。第一条是Type-safe类型安全。生成出来的代码类型定义必须只有一个来源不能出现后端一份类型、前端一份类型各写各的。t3code 的做法是让类型从模块描述文件里派生后端 DTO、前端 client 参数、测试 mock 全都引用同一份类型数据。这样改字段时只改描述文件一处再执行生成命令所有相关文件同步更新。第二条是Testable可测试。生成器不能只生成业务代码而不生成测试骨架。我们团队一直说要写单测但到了冲刺阶段总有人以时间不够为由跳过。t3code 的默认行为是每个生成的 service 都会配套一个.test.ts文件里面至少包含一个成功路径和一个异常路径的测试用例Mock 数据也从描述文件自动生成。就算开发不自己补用例测试文件也不是空的CI 跑起来能看到基础覆盖后续再往里加用例就容易很多。第三条是Traceable可追踪。生成的所有接口方法都预留了日志和链路追踪的切入点。比如 controller 层自动打印请求入口和耗时service 层自动带 requestId 往下游传。这样线上出问题时能顺着日志把一次请求从入口到数据库的完整链路拉出来。我在后面会展示实际生成的代码长什么样。有了这三条红线t3code 的设计方向就定了它生成的不是一次性代码而是符合团队约束、能长久维护的代码骨架。这也是它跟很多代码生成就完事的工具最大的区别。2. 工具设计中的关键决策模板、命名与类型生成2.1 为什么不用现成的脚手架生成器决定做工具之后我的第一反应也是找现成的。当时试了 Yeoman、Plop 和 Hygen各有各的好处Yeoman 生态大Plop 灵活Hygen 轻快。但用下来之后我发现它们跟我的需求之间有一道很难跨越的鸿沟它们解决的是生成文件这个通用问题而不关心生成的文件是否符合我们团队的架构约定。举个例子。Plop 里写一个 controller 模板你只能定义要生成一个文件、文件名是什么、填充哪些变量。但团队里真正要固化的东西远比这个复杂controller 要继承哪个基类、DTO 要放在单独目录还是跟 controller 放一起、service 里必须注入哪几个依赖、导出要统一走 index.ts、文件头要加什么 License 注释……这些约束用通用脚手架不是不能写但约束一旦多了模板代码会变得非常庞大而且每个项目都要重新配置一遍。t3code 的定位是约定优先的模块级生成器。它不只问要生成什么文件还会问你希望生成出来的模块长什么结构、文件之间如何引用、类型从哪里派生。模板系统和默认项目约定是绑在一起的哪一层该生成什么文件、用什么命名工具按规则来。你要改可以但默认值就是团队内沉淀下来的最佳实践。如果你的团队已经有了很成熟的 Yeoman 生成器那继续用没有问题。但如果你跟我一样核心诉求是把团队约定固化成代码那一个自带约定的专用工具比一个完全通用的脚手架要省心得多。2.2 模板引擎选型与变量注入规则模板引擎我最后选了 EJS而不是 Handlebars。理由很简单EJS 的语法是% if %、% var %跟 JavaScript 本身几乎一模一样团队成员基本不需要额外学习模板语法。Handlebars 那套{{#if}}、{{else}}的块级写法虽然也很流行但对要在模板里写一点逻辑的场景EJS 写起来更顺手。这里有个关键设计模板文件里不直接写业务变量名而是统一从上下文对象取。上下文对象由模块描述文件解析而来长这样type TemplateContext { moduleName: string; // User moduleNameLower: string; // user fields: FieldDef[]; // 字段定义数组 isPagination: boolean; // 是否分页 hasSoftDelete: boolean; // 是否逻辑删除 crudOperations: string[]; // 需要生成的 CRUD 操作 requestIdEnabled: boolean; // 是否开启请求链路 };模板里只引用ctx.moduleName这类已规范化的字段所有转化逻辑比如user转User、user_id转userId统一在解析器里完成。这样模板本身保持傻瓜式的干净不掺太多条件逻辑。每个模板文件还有 .tpl 后缀生成时去掉templates/ ├── api-client.ts.tpl # 前端 API client 模板 ├── controller.ts.tpl # 后端 controller 模板 ├── service.ts.tpl # service 模板 ├── dto.ts.tpl # DTO 模板 ├── types.ts.tpl # 类型定义模板 └── service.test.ts.tpl # 测试模板在后面的实操部分你会看到这些模板组合起来是怎么工作的。2.3 类型生成的边界从数据库表到 TypeScript 类型这是 t3code 里技术含量最高的一部分也花了我最多时间去调。要让类型只有一个来源最简单的方案是让用户把字段类型在描述文件里手写一遍fields: - name: id type: number - name: email type: string但这样做的问题是类型定义虽然集中了却还是靠人肉填写数据库里改了字段长度、改了枚举值描述文件很容易忘记同步。更理想的状态是直接从数据库表或 ORM schema 反推 TypeScript 类型。我在 t3code 里做了一套映射器支持从 Prisma schema 文件读取模型定义并生成对应的 TS 类型。核心映射规则是这样一张表Prisma 类型TypeScript 类型说明Stringstring若定义db.VarChar(50)可生成带长度约束的 Branded TypeIntnumber无特殊处理Booleanboolean无特殊处理DateTimestring默认序列化为 ISO 字符串也可配置为DateJsonRecordstring, unknown避免用anyEnumstring或字面量联合类型自动生成type Status ACTIVE | DISABLED映射逻辑本身不难工作量主要在边界情况。比如 Prisma 的Optional字段应该映射为 TS 的可选属性field?: string但嵌套对象里的null和undefined处理方式完全不同。再比如枚举如果太多生成的联合类型会很长这时我会建议降级为string 运行时校验而不是硬生生生成一个几百字的联合类型。这条边界也给我带来一个深刻教训工具不要太聪明。npx t3code generate确实能从 schema 自动推类型但如果你在描述文件里手写了类型手写版本永远优先。自动映射只作为没有显式声明时的默认值。否则你就会陷入为什么我明明在数据库里改了生成出来还是旧类型这种无尽困惑。3. 实操一条命令生成一个完整 REST 模块3.1 安装与初始化 t3codet3code 发布在 npm 上所以安装是一句话的事npm install -D t3code建议装成开发依赖而不是全局安装这样 lockfile 能锁版本团队所有人都用同一个版本不会出现你本地生成出来是 A 风格我这边是 B 风格的尴尬。初始化时在项目根目录执行npx t3code init它会自动探测你的项目结构生成一个t3code.config.ts。下面是我在一个真实项目里的配置import { defineConfig } from t3code; export default defineConfig({ templateSource: templates, // 本地模板目录 moduleDir: modules, // 模块描述文件目录 defaultCrud: [list, detail, create, update, remove], typeSource: prisma, // 类型来源prisma / manual prismaSchemaPath: ./prisma/schema.prisma, output: { server: ./src/modules, // 后端输出目录 client: ./src/api, // 前端类型与 client 输出目录 tests: ./src/modules, // 测试输出目录 }, emitLog: true, // 自动生成日志埋点 indent: 2, });注意typeSource: prisma这一项。它表示 t3code 在生成类型时会先去读项目的 Prisma schema把它当成权威数据类型来源描述文件里只要写引用哪个模型即可不用重复列字段。3.2 定义模块描述文件一个模块描述文件就是一个 YAML 文件放在modules/目录下。还是用用户分页查询这个例子modules/user.yaml长这样module: user table: users typeSource: prisma model: User fields: - name: id comment: 主键 - name: email comment: 登录邮箱 unique: true - name: status comment: 账号状态 enum: [ACTIVE, DISABLED] api: list: enabled: true pagination: true detail: enabled: true create: enabled: true update: enabled: true remove: enabled: false # 不做物理删除 softDelete: true注意这里的fields我没有写类型。因为在typeSource: prisma模式下类型由 schema 里的model User推导。描述文件只需要补充业务语义注释、是否唯一、是否是枚举、接口开关。这样就避免了两份数据源打架的问题。3.3 生成的代码结构长什么样执行生成命令npx t3code generate user.yaml输出结构大概是这样的src/modules/user/ ├── user.controller.ts ├── user.service.ts ├── user.repository.ts ├── user.dto.ts ├── user.types.ts ├── user.service.test.ts └── index.ts src/api/ ├── user.client.ts ├── user.types.ts └── index.ts我随便挑一个生成出来的user.controller.ts给你看注意看日志埋点import { Request, Response } from express; import { UserService } from ./user.service; import { QueryUserListDto } from ./user.dto; import { logRequest } from ../common/logger; export class UserController { private service new UserService(); async list(req: Request, res: Response) { const startedAt Date.now(); try { const dto: QueryUserListDto req.query as unknown as QueryUserListDto; const result await this.service.list(dto); logRequest(user.list, { dto, duration: Date.now() - startedAt }); res.json(result); } catch (err) { logRequest(user.list.error, { error: (err as Error).message }); res.status(500).json({ message: internal error }); } } async detail(req: Request, res: Response) { // 结构同上省略 } }这个代码风格是模板里写死的但日志函数名、路径前缀这些都来自配置。实际项目里你可以让模板完全匹配你们团队现有的 logger 和 base controller把 t3code 当成一次性的骨架定型工具来用。生成的user.service.test.ts也不是空壳import { UserService } from ./user.service; import { mockUserList, mockUser } from ./user.types; describe(UserService, () { let service: UserService; beforeEach(() { service new UserService(); }); it(should return paginated list, async () { const result await service.list({ page: 1, size: 10 }); expect(result.total).toBe(mockUserList.total); expect(result.list).toHaveLength(mockUserList.list.length); }); it(should throw when detail not found, async () { await expect(service.detail(9999)).rejects.toThrow(not found); }); });3.4 与已有代码集成时的注意点生成只是第一步工具不会替你接路由、跑 migration事务还是得人来处理。以我的经验生成完之后总要手动做三件事第一注册路由。t3code 会把index.ts生成好里面把所有文件都 export 了但路由挂载点比如app.use(/api/users, userRouter)它不会帮你动。这是刻意的因为路由前缀、鉴权中间件、限流这些跟具体业务环境强相关不该由生成器决定。第二创建数据库 migration。如果这个模块对应的表还不存在你得自己去跑prisma migrate dev或者手写 SQL。生成器管的是代码层不管数据库 schema 变更。第三检查测试里的 mock 数据。生成器会从描述文件里自动创建一些示例 mock 值但真实的业务数据形态比如 email 格式、状态枚举的占比需要你根据自己的场景补几行。我强烈建议把这三件事写成一张 checklist 贴在 Wiki 里让每个接手的开发都心里有数。工具能消掉的重复劳动是结构性的但业务认知性的活永远得留给人。4. 实测踩坑记录模板转义、覆盖策略与路径兼容4.1 模板中的 $ 符号与 TypeScript 泛型冲突这是我遇到的第一个大坑。EJS 模板本身用% %作为控制标签这点跟 TypeScript 代码混在一起没问题。麻烦的是 TypeScript 里有两个符号跟 EJS 的语法产生冲突第一个是泛型比如PromiseResultT。我一开始在模板里直接写export async function listT(params: QueryDto): PromisePagedResultT {看起来没问题但一旦这段代码里出现%或者包含了会被 EJS 错误解析的内容整个模板就编译不过。其实这里的主要问题不是符号本身而是你在模板里写 TS 泛型时如果模板里有逻辑分支EJS 会在同一行里反复切换控制流可读性会迅速下降。第二个是模板字符串里的${}。在 EJS 模板里如果你想生成这样一段 TS 代码const cacheKey user:${userId};直接写会出问题因为 EJS 会把${userId}里的内容当成自己的表达式去解析。我的解决办法是模板中尽量少用内联逻辑复杂的拼接统一丢给helper函数。涉及$的地方用一个虚拟占位符比如__DOLLAR__生成后再统一替换回来。// helper.ts export function dollar(value: string) { return ${ value }; }然后在模板里写const cacheKey user:% dollar(userId) %;虽然丑一点但它既避开了转义问题也让模板的可读性变得更强——看到dollar(...)你就知道这里要生成一个运行时模板字符串。如果你用的是 Handlebars 一类的模板引擎这类冲突会少一些但逃逸规则还是要做。我的建议是设计模板时把模板语法和目标语言语法当成两个 Channel 来考虑遇到不兼容就先转义后还原不要在模板里硬刚。4.2 二次生成时的覆盖策略diff 合并 vs 强制覆盖第一次生成一个全新模块的时候很开心真正让我失眠的是第二次生成。场景很典型模块上线后开发在生成的user.service.ts里补了一段业务逻辑比如给列表加了关键字过滤async list(dto: QueryUserListDto) { const filters buildFilters(dto); // 手工添加的业务逻辑 return this.repository.findByFilters(filters); }过了一周产品说用户表要加一个字段nickname。开发觉得简单改一下user.yaml跑一下npx t3code generate user.yaml结果一打开user.service.ts发现刚才那几行手写代码被覆盖没了。这个问题我前前后后调了三版策略策略优点缺点全量覆盖实现最简单生成结果可预期手写改动全丢最容易出事故碰到已存在文件就跳过手写代码安全字段新增时类型文件不更新等于生成了个寂寞生成前 diff 备份比较安全能知道改了哪里每次生成要人工确认CI 里很难自动化最终我采用的做法是文件头写生成标记 生成时逐文件 diff 自动备份。// Code generated by t3code. DO NOT EDIT manually. // This file was generated at: 2025-01-18T10:00:00.000Zt3code 会识别这个标记凡是带标记的文件生成时跟已有文件做 diff如果没有差异就跳过有差异就把旧文件复制为.bak在终端打印具体 diff给出三个选项overwrite覆盖、keep保留旧的、merge手动合并。这个过程在交互式终端里很顺手但在 CI 里无法弹选项。于是我又加了一个--no-interactive参数默认走overwrite同时把.bak文件保留五个版本。出问题就找最近一个.bak恢复记录里永远有后悔药吃。这里我强烈建议生成器生成的代码一定不能混入太多手写逻辑。如果某个文件注定要频繁被手写改那这个文件就不该由 t3code 完整生成应该改成模板只生成骨架或者干脆生成到另一个目录由你手写一层薄的粘连层。这是代码生成工具使用的大原则越早想明白后面越舒服。4.3 Windows 与 CI 环境下的路径/换行坑团队里不是所有人都用 macOS 或者 Linux总有同学在 Windows 上开发。t3code 在 Windows 下的第一个问题是路径分隔符。Node.js 的fs在 Windows 上会把src/modules/user写成本地路径src\modules\user模板里如果用了字符串匹配来生成 import 路径很容易错乱。解决方法是所有路径操作都用path.join或path.posix.join并且用一个工具函数统一import path from path; export function toPosixPath(p: string): string { return p.split(path.sep).join(/); }所有进入模板的路径变量一律先跑一遍toPosixPath。这样无论在 Windows 还是在 Linux 上生成的 import 语句都是/分隔前端打包、Node 解析都没问题。第二个坑是换行符。Windows 上默认是 CRLF生成出来的文件一旦被提交进 Git再被 CI 拉下来跑 lint很容易出现full-line trailing whitespace或者Expected linebreaks to be LF之类的报错。我在配置里做了三件事模板文件统一存成 LF生成时显式用\n拼接内容.gitattributes里加上* textauto eollf。echo * textauto eollf .gitattributes只是这一条就能避免团队一半的 Windows 换行引发的问题。第三个坑出现在 CI 里。我们的 CI 流程会在干净环境里跑npx t3code generate但有些依赖在安装时可能没装全或者模板目录因为.gitignore配置没被拉下来导致生成出的代码跟本地不一致。我们最后的约定是CI 里不跑完整的 generate而是跑npx t3code verify只比对当前分支生成的代码和仓库里已提交的代码是否一致。不一致就让开发者回到本地重新生成再提交。这个模式我非常推荐它把生成工具变成了一种代码规范校验器比走代码评审时肉眼盯模板输出靠谱得多。5. 进阶玩法团队模板仓库与 CI 自动生成5.1 把模板做成独立的 git 仓库如果你只是一个人用 t3code那本地模板目录就够了。但如果你想让团队所有人都遵守同一套架构约定就必须把模板本身当成一个像样的代码资产来管理。我在公司内部就做了一个独立的team-t3code-templates仓库里面只放模板文件和配套的 helper 函数。t3code 的templateSource支持从一个 git 仓库拉取模板npx t3code init --template-source https://git.company.com/team/t3code-templates.git每次生成前t3code 会检查本地缓存跟远程仓库是否一致不一致就拉最新版。关键的是模板仓库要打 tag比如v1.2.0在项目的t3code.config.ts里锁定templateSource: { git: https://git.company.com/team/t3code-templates.git, ref: v1.2.0, }锁定版本极其重要。否则某个同事心血来潮改了一版模板并推到主干其他所有人的生成结果全部跟着变代码风格一夜之间漂移连 diff 都看不懂。反过来如果模板本身有 bug你也不用去每个项目里改只要在模板仓库修掉并发一个新版本各项目按节奏升级就行。模板也是代码要走代码评审、要写 changelog、要打版本号。这可能是把 t3code 推广到团队这件事里我最想强调的一点。5.2 与 CI 集成自动生成 API clientt3code 的另一个高频使用场景是前后端接口联调。我们的后端有一个 OpenAPI 描述文件每次接口有变更前端就希望拿到最新的 API client 类型包。手工让前端同学去同步效率太低觉悟高一点的前端可能会写脚本自己拉但更多情况下是后端改了接口前端毫不知情直到联调才发现。后来我在前端仓库的 CI 里加了这样一个流程name: sync-api-from-openapi on: push: paths: - openapi.yaml jobs: generate: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 20 - run: npm ci - run: npx t3code generate openapi --input openapi.yaml --out src/api - uses: peter-evans/create-pull-requestv6 with: title: chore: sync api client from openapi branch: bot/sync-api只要后端把更新后的openapi.yaml推到仓库CI 就会自动生成一份新的前端 API client并开一个 PR。前端同学只需要 review 一下 diff、合掉 PR 即可甚至不用知道后端今天改了什么类型已经对上了。这个流程跑通之后我明显感觉到前后端联调时的类型撕扯少了很多。以前是改一个字段要在两个仓库各提一个 PR现在改一个openapi.yaml前端 PR 自动出现。省下来的时间每个人都能感受到这也是 t3code 在公司内部被接受的最直接原因。5.3 度量收益一周能省下多少重复劳动聊点实在的t3code 到底值不值得折腾我拿一个真实的迭代数据来说话。有一次迭代我们手写了 12 个业务模块。按老办法每个模块大概包含 controller、service、repository、dto、types、测试这六类文件平均一个模块要手写约 450 行代码。12 个模块就是 5400 行。换成 t3code 之后每个模块只需要写一个 60 行左右的 YAML 描述文件12 个模块也就 720 行。当然生成完还要改路由、补业务逻辑、调 mock 数据这部分不会被完全省掉但最少能省下百分之七八十的重复劳动量。不过要说清楚节省行数不是 t3code 最大的收益。行数只是省时间真正值钱的是架构统一。同一个项目里12 个人写出来的 controller 各自为政代码评审时天天讨论风格问题用 t3code 之后生成的代码风格完全一致评审只需要关注真正有业务含量的 diff。这个价值很难量化但做后端 Leader 或者技术负责人的人一定懂。另外一个隐藏收益是新人上手。新同学来了之后不用再花三天时间翻老代码猜约定直接看user.yaml就知道这个模块提供哪些接口、有哪些字段、有没有分页和软删除。学习成本降了一个量级。最后再多说一句使用上的心得。t3code 在三个内部项目里跑了大半年我最大的体会是一个代码生成工具只有在团队真的愿意遵循同一套约定时才会发挥价值。工具负责把约定固化成代码让每个人产出的东西整齐划一但约定本身是什么呢、要不要更新、谁来评审这些还得靠人来维护。不要指望一个命令行工具能解决所有架构问题它能做的是把架构问题里的重复手写这部分替人干掉把精力留给真正需要思考的业务逻辑。如果你也想做一套自己的代码生成工具我的建议是别一上来就造框架。先把项目里最烦的三类重复代码找出来写死模板跑通一个小模块再逐步扩展开关和配置最后加上类型推导和 CI 校验。t3code 就是这么一步步长出来的。用它去生成第一行代码可能比看十篇经验文章都有用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑