资讯动态

t3code:用一份Schema生成全套TypeScript全栈代码,终结类型不同步

发布时间:2026/10/27 6:51:34 来源:尧图企业网站定制
t3code 这名字乍看有点怪可能会被误会成某个第N代框架或者某个版本代号。实际上它是我断断续续维护的一个开源小工具一个面向 TypeScript 全栈项目的代码生成引擎。核心解决的事情很简单——类型不同步、CRUD 代码重复、前后端模型脱节。如果你正在写全栈应用或者在维护中后台系统经常为“改一个字段要动四个文件”这种事烦躁那这篇东西应该对你有用。我会把设计思路、核心原理、完整实操以及踩过的坑都掰开讲清楚尽量做到能让你直接照着复现。我写代码这么久最烦的不是业务复杂而是“人力同步”这种事。明明加一个字段是机械操作却要同时改数据库模型、接口层、前端类型、表单校验稍有不慎漏掉一个线上直接类型错误。t3code 的存在就是让这类问题从流程上消失——让数据契约成为全项目唯一的类型来源其余代码全部生成。1. 为什么我会写 t3code先聊聊痛点1.1 全栈项目里的“类型三不管地带”先说我自己的经历。两年前我在做一个中等规模的管理后台技术栈是 React Node.js数据库用 PostgreSQL接口层自己封装。理论上前后端共用一套 TypeScript 类型但实际情况是后端模型文件user.model.ts里定义了User类型带status字段接口层user.service.ts里可能又写了一份局部类型漏了status前端api/user.ts里又有一个UserDTO字段顺序、可选性都不一样表单校验、筛选条件里还会有第四份、第五份定义。有一次我加了一个avatarUrl字段模型、接口、前端类型、上传组件四层都改了。结果第二天测试说头像加载不出来查了半天发现是某个工具函数里自己声明了一个“简化版 User 类型”根本没走公共类型。这就是典型的“类型三不管地带”定义散落、无人统一、全靠自觉。我当时的第一个反应是“抽个公共 types 包”但很快发现这也治标不治本。公共包里只是多个文件搬到一起改字段的时候你还是要手动维护各个 DTO而且很多时候前端需要的类型和后端模型并不完全一样——前端要UserProfile后端返回的是UserRecord它们之间有转换逻辑单纯共享一份类型反而坏事。1.2 t3code 的设计目标与取名由来折腾了一段时间后我想明白了一个关键点类型不同步的本质不是“大家没共用同一个类型文件”而是“类型不是从单一事实源推导出来的”。你需要的不是把类型汇总到一个文件而是让类型从一份“更高级的定义”里自动生成。于是 t3code 的雏形出来了。它的工作方式可以概括成一句话用一种 schema 描述数据模型然后自动生成后端类型、数据库模型、接口路由骨架、前端类型、表单校验器。取名方面t3code 里的 “t3” 确实受了社区 T3 Stack 那套“类型安全全栈”理念的启发不过我并没有绑定任何具体框架。我这里的三层含义是Type definitions类型定义、Template generation模板生成、Type checking类型校验。恰好对应了 t3code 的一条核心链路用类型做输入用模板做加工最终生成出来的代码仍然能通过类型检查。这个工具适合的受众也比较明确TypeScript 全栈开发者、中后台系统开发者、经常需要“建一个新模型然后自动化生成一套增删改查”的人。它不擅长的是高度定制化的交互页面、复杂的业务状态那些还是要靠手工代码。2. 核心设计一份 Schema生成全套代码2.1 约定优先t3code 的目录布局从设计上讲t3code 遵循的是“约定优先”Convention over Configuration。你不需要在生成代码之前告诉工具“你的项目长什么样”只要把目录结构摆好它自己就能找对位置。一个最小项目的目录布局是这样的my-app/ ├── t3code.config.ts ├── schemas/ │ ├── user.schema.ts │ └── post.schema.ts ├── templates/ │ ├── model.ts.hbs │ ├── router.ts.hbs │ └── validator.ts.hbs └── src/ ├── server/ ├── client/ └── shared/其中schemas目录是输入templates目录是引擎src目录是输出目标。配置文件t3code.config.ts定义各类型文件应该输出到哪里。很多人第一反应是为什么用的是 TypeScript 文件而不是 JSON 或 YAML 来定义 schema这一点我当时也犹豫过。JSON 的好处是简单但坏处也很明显——没有类型提示没有复用能力不能写逻辑。比如一个字段的默认值要根据环境变量变化JSON 就写不了但 TypeScript schema 文件完全可以做到。再加上 t3code 本身就是给 TypeScript 项目用的用代码做配置编辑器补全和校验都是免费送的。2.2 数据契约怎么写t3code 里最核心的输入叫数据契约我习惯用类似 Zod 的 schema 风格来写。举个例子一个用户模型的定义长这样// schemas/user.schema.ts import { defineModel } from t3code; export default defineModel({ name: User, table: users, fields: { id: { type: string, primaryKey: true, default: crypto.randomUUID() }, email: { type: string, unique: true, format: email }, name: { type: string, min: 1, max: 50 }, status: { type: enum, values: [active, inactive, banned], default: active }, createdAt: { type: datetime, default: now }, updatedAt: { type: datetime, onUpdate: now }, }, relations: { posts: { type: hasMany, target: Post, foreignKey: userId }, }, });这里每一个字段的属性都会被 t3code 编译成内部中间表示IR变成生成代码时可以读取的数据结构。为什么选择用type: string这种描述式写法而不是直接写 TypeScript 基本类型因为生成器需要更丰富的元信息。比如一个字段是string它可以是email、url、uuid还可以带min、max、unique这些约束。这些约束在生成数据库模型时要变成列属性在生成校验器时要变成z.string().email().min(1)在生成前端类型时可能变成纯注释。这就是“一份契约三处消费”的核心价值你只写一次规则数据库、服务端、前端各自按自己的语法去翻译。2.3 生成器工作原理t3code 的生成过程可以拆成三步读取、编译、渲染。第一步是读取schemas目录下所有的.schema.ts文件这里用到了 TypeScript Compiler API 做 AST 解析目的是在不执行用户代码的前提下把 schema 文件里的元信息提取出来。第二步是把提取出来的信息转成统一的中间模型IR中间模型包含模型名、字段列表、字段类型、约束条件、关系等。第三步才是模板渲染。渲染阶段我最初用的是 Handlebars后来换成了 Eta。原因很简单Eta 的模板语法更接近 JavaScript在模板里写逻辑更方便。模板文件本身也不复杂比如生成一个数据库模型文件的模板长这样// templates/model.ts.hbs export interface {{model.name}} { {{#each model.fields}} {{this.name}}: {{this.tsType}}; {{/each}} }模板里的每一个变量都有来源。model.name来自 IRfield.tsType是我在编译阶段根据字段类型推算出来的 TypeScript 类型。一个type: datetime的字段推出来可能是Date或string取决于项目配置里用的是哪种序列化方式。生成代码写出来后还有一道关键流程叫“差量合并”。简单来说就是如果目标文件已经存在t3code 不会整文件覆盖而是先做 diff找到用户手动维护的区域只替换生成区域。这个机制的实现细节比较多下一节我详细说。3. 实操从一个空目录跑通 t3code3.1 安装与初始化现在从一个空目录开始完整跑一遍流程。先创建项目然后安装 t3codemkdir t3code-demo cd t3code-demo pnpm init -y pnpm add -D t3code安装完以后执行初始化命令npx t3code init这个命令会做三件事生成一份默认的t3code.config.ts、创建schemas目录和templates目录并写入一份示例 schema。初始化之后的配置文件长这样// t3code.config.ts import { defineConfig } from t3code; export default defineConfig({ schemas: [./schemas/**/*.schema.ts], templates: ./templates, outputs: { serverModel: ./src/server/models, router: ./src/server/routers, clientType: ./src/client/types, sharedValidator: ./src/shared/validators, }, formatter: prettier, watchDebounce: 300, });注意这里的outputs四类输出正好对应我之前说的“后端模型、接口路由、前端类型、共享校验器”。你做中后台系统时通常只需要这四个如果你用的是框架或技术栈有额外需求比如微服务项目需要生成 OpenAPI 文档后续可以在templates里扩展。3.2 编写第一份 Schema 并生成代码接下来我在schemas目录下写一个post.schema.ts同时复用上文的user.schema.ts。文章模型会引用用户模型形成一对多关系// schemas/post.schema.ts import { defineModel } from t3code; export default defineModel({ name: Post, table: posts, fields: { id: { type: string, primaryKey: true }, title: { type: string, min: 1, max: 200 }, content: { type: text }, authorId: { type: string, foreignKey: User.id }, publishedAt: { type: datetime, nullable: true }, }, relations: { author: { type: belongsTo, target: User, foreignKey: authorId }, }, });写好之后直接执行生成命令npx t3code generate正常情况下控制台会输出类似这样的日志[ t3code ] Found 2 schemas [ t3code ] Parsed User (6 fields, 1 relation) [ t3code ] Parsed Post (5 fields, 1 relation) [ t3code ] Generating User... [ t3code ] Generating Post... [ t3code ] 4 files written in 82ms生成的User后端模型会是这样的// src/server/models/user.ts export interface User { id: string; email: string; name: string; status: active | inactive | banned; createdAt: Date; updatedAt: Date; } export const userFields [id, email, name, status, createdAt, updatedAt] as const;生成的Post路由骨架会像这样以 tRPC 为例// src/server/routers/post.ts import { router, publicProcedure } from ../trpc; import { Post, postFields } from ../models/post; import { postSchema } from ../shared/validators/post; export const postRouter router({ list: publicProcedure .input(postSchema.listInput) .query(async () { // TODO: implement list logic }), byId: publicProcedure .input(postSchema.byIdInput) .query(async ({ input }) { // TODO: implement byId logic }), create: publicProcedure .input(postSchema.createInput) .mutation(async ({ input }) { // TODO: implement create logic }), });同时前端类型、Zod 校验器也会自动生成。你只需要把TODO部分替换成真实业务逻辑接口层的外壳就不用再手写了。3.3 增量更新与反向合并t3code 上线跑了两周后我遇到第一个真实项目的挑战生成代码已经写入了业务逻辑后面再改 schema 重新生成业务逻辑会不会被覆盖会。如果设计成整文件覆盖的话生成器就成了一个“一次性工具”改动 schema 后所有手工修改全丢。这种体验我相信谁都不想要所以后来我设计了“保护区域”Protected Region机制。具体做法是在模板里约定一种特殊注释标记凡是被标记包含的代码段生成器都不会动。模板写法如下// templates/router.ts.hbs export const {{model.name}}Router router({ // t3code-protected:custom-start // t3code-protected:custom-end });实际生成的代码里两行标记之间就是你的自留地// src/server/routers/post.ts export const postRouter router({ // t3code-protected:custom-start count: publicProcedure .input(postSchema.listInput) .query(async ({ input }) { const count await db.post.count({ where: input.filter }); return count; }), // t3code-protected:custom-end });重新执行npx t3code generate的时候t3code 会先读取磁盘上的旧文件把位于protected标记之间的内容提取出来再和新的生成内容合并最后写回。你可以用 git diff 验证重新生成之后只有 schema 对应变化的部分发生了改动手工写的count逻辑原封不动。好的工具应该做到“拿了生成的好处还能保留手工的自由”。这个保护区域的设计就是我给手写代码留的口子。3.4 自定义模板生成一个导出 Excel 模块内置模板只能覆盖通用场景真实项目总会有一两个“定制需求”。t3code 也允许你写自定义模板。举一个例子我想给每一个模型都生成一个“导出 Excel”的按钮入口。前端组件是高度定制化的完整组件我不会生成但可以把“导出需要哪些字段”这个信息从 schema 里拉出来。新建一个模板文件excelColumns.ts.hbs// templates/excelColumns.ts.hbs export const {{model.name}}ExcelColumns [ {{#each model.fields}} { key: {{this.name}}, title: {{this.label}}, width: 120 }, {{/each}} ];然后在t3code.config.ts里增加一份输出outputs: { ... excelColumn: ./src/client/excel, }生成的代码就会自动包含所有字段信息前端拿到这份列配置剩下的事情就是用现成的导出库去渲染它。这个场景特别适合“模型变了导出的列也跟着变”的需求。4. 实战中那些让我抓狂的坑与排查方法4.1 Watch 模式不生效的排查我一开始给 t3code 加了watch模式想着改了 schema 文件就自动重新生成体验肯定很流畅。结果真正用起来发现在 VS Code 里经常出现“改了一个字段、保存之后没触发生成”的情况。排查了半天问题出在两个地方。一个是编辑器自动保存太快文件写入可能还没落盘文件监听事件就触发了另一个是快速连续保存时几十毫秒内事件堆积处理逻辑跟不上。后来我加了 debounce 配置watchDebounce: 300并且把生成流程改成“先读文件、再解析、再写盘”的顺序执行事件处理才稳定下来。如果你也碰到类似问题先看一眼配置里的watchDebounce是不是太小再把文件监听从“监听单个文件”改成“监听整个 schemas 目录”用fs.watch的递归模式会更稳。4.2 生成代码和手写代码互相覆盖保护区域解决了“业务逻辑被覆盖”的问题但它也有局限性只有我预设了标记的地方才能保护。如果你在生成文件里那个TODO: implement list logic里直接写逻辑下一次生成就会被冲掉。我个人的建议是不要在任何生成区域里写字尤其是那些带TODO的地方。为了让这个规则更好执行我在 t3code 里加了 pre-generate 检查——生成之前对比一下“当前磁盘文件里的 TODO 区域是否已经包含非模板内容”如果有就中断并提示防止误覆盖。这个设计让我自己在使用中少踩了很多坑。你也要养成一个习惯生成完代码之后先 git commit 一个“base”版本之后任何生成操作都基于这个版本做 diff这样即便出了问题也能快速回滚。4.3 Prettier 与 ESLint 冲突生成器写出来的代码通常是机械拼接的缩进、引号、分号风格往往和项目里 Prettier 的偏好不一致。如果不处理每次生成代码之后git diff 都会出现大量“格式噪音”非常影响 review。解决方案其实很简单在generate流程的末尾自动运行一次 Prettier。我之前是直接在模板里控制换行和缩进后来发现这根本控制不住因为模板逻辑一旦复杂格式就会乱。更可靠的做法是让 Prettier 统一收尾模板负责内容正确即可。配置里那个formatter: prettier字段就是干这个的。如果你的项目里用的是 ESLint 的eslint --fix也完全可以接到这个钩子里。4.4 关系字段生成的陷阱关系字段是 schema 设计里最容易出 bug 的地方。我踩过的一个典型问题在User里定义了posts的hasMany关系又在Post里定义了author的belongsTo关系。生成前端类型时两个模型互相引用如果生成顺序不当会出现“生成 User 时读 Post生成 Post 时读 User”的循环引用问题。还有个更烦人的坑是可选性推断。Post.author如果authorId是必填正常情况下author也应该是必填但如果你用的是某种 DTO 序列化会出现author在后端模型里是必填在前端接收列表时却是null可选的情况。这些细节在 IR 阶段就需要做关系归并否则模板里的类型推断就会出错。我最后采用的方案是在编译阶段建立一个“模型依赖图”先按拓扑排序处理没有依赖的模型再去处理相互依赖的模型前端类型里所有关系字段默认加上nullable标记避免运行时类型断言炸掉。4.5 常见问题速查表问题现象可能原因解决办法watch 模式不触发生成文件保存太快、debounce 过小调大watchDebounce监听整个 schemas 目录生成代码被手动修改后下次丢失写入了非保护区域使用 protected region或检查 TODO 区域重新生成后 diff 噪音很大格式风格不统一生成后自动跑 Prettier关系字段类型推断错误模型依赖顺序、可选性推断不准检查 IR 里的拓扑排序关系字段默认 nullableschema 文件改动后生成内容没变模板缓存未刷新试试t3code generate --no-cache多个模型生成了同名变量模板里用了相同的命名前缀在模板里用model.name做变量名前缀5. 一点心得工具能做的和不能做的t3code 能自动生成代码但它不是在帮你写业务而是在帮你管理“容易腐烂的那一层”。模型定义、接口签名、校验逻辑、前端类型这些本质上都是对同一份业务数据的描述。只要它们能从同一份源头推导出来“改一个字段要动四个文件”的问题就自动消失了。我自己在真实项目中用下来的感受是工具最大的价值不是让你少打字而是让你少记“哪些地方需要同步改”。人的注意力应该花在业务逻辑上而不是花在“这里改完别忘了那里也要改”的记忆上。不过我也要泼点冷水。代码生成不是银弹它最适合的应用场景是中后台的 CRUD 模块、内部管理系统、微服务之间共用的数据类型这些场景有高度重复的结构非常适合生成。但它不适合高度个性化的交互页面、复杂的领域驱动设计、需要深层定制的 BFF 层。如果你把这类场景也硬塞给生成器你会发现模板代码越来越复杂最后比手写还难维护。最后再分享一个小技巧。t3code 的模板和 schema 其实都是普通 TypeScript 文件这意味着你可以在 schema 里动态构造字段列表。比如从数据库表信息里读取字段名再自动生成 schema。这样配合起来连“写 schema”这步都可以省掉大半从数据库逆向模型变成一条龙流程。我目前就在琢磨把这个能力做成独立插件方便那些已经有数据库存量表的项目快速接入。工具这种东西只有放到真实项目里反复磨才知道哪里疼。t3code 走到今天很多设计都不是一拍脑袋想出来的而是被真实问题一个个逼出来的。如果你也在做类似的全栈工程化基建欢迎拿去用也希望它能帮你省下一点“同步改类型”的时间。

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

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

免费获取报价 →
↑