1. 从一份类型定义到前后端双端代码:t3code 要解决的根本问题
1.1 项目初期的痛点:字段总是对不上
大概在半年前,我牵头重构一个中后台系统,前后端加起来十来个开发。当时最折磨团队的不是接口性能,也不是组件复杂度,而是每天都在发生的"字段同步"问题。
场景基本是这样:后端同学按数据库字段返回user_id,前端文档里写的是id;后端说avatar_url,前端代码里叫avatar。联调前大家各自都觉得"应该没问题",结果联调当天一下子冒出七八处字段对不上,翻接口文档、翻代码、翻聊天记录,半天就没了。
比字段对不上更烦人的是校验逻辑的重复维护。前端用 zod 写一遍入参校验,后端再用 class-validator 写一遍,明明说的是同一份数据,两边的规则却经常不一致。加一个必填字段,前端改了后端忘改,线上直接 500。这种问题不致命,但每隔一阵就来一次,特别消耗士气。
所以我决定找一个"只维护一份契约"的方案:所有接口相关的类型定义只写一次,前后端需要的校验、客户端请求函数、服务端处理器骨架,都由工具自动生成。这个工具就是 t3code。
1.2 为什么没有直接用 tRPC 或者 OpenAPI 生成器
动手前我把市面上的方案过了一遍,最大的两个备选是 tRPC 和基于 OpenAPI 的代码生成器,但都有让我不痛快的地方。
| 方案 | 优点 | 我最终放弃的原因 |
|---|---|---|
| tRPC | 端到端类型安全,体验确实好 | 要求后端整体改造到 tRPC 运行时体系,项目里已有的 REST 接口和第三方回调没法平滑迁移;前后端不在同一个仓库时部署耦合很重 |
| OpenAPI + swagger-codegen / openapi-typescript | 规范标准化,工具链成熟 | 得先维护一份 YAML/JSON 规范,写规范本身就很痛苦;生成的客户端代码质量参差不齐,很多情况下还需要手改成适合项目的形态 |
| 自己写生成器 | 完全可控 | 要投入开发时间,且需要持续维护 |
我的真实需求其实更朴素:团队已经有一批用 TypeScript 写好的接口类型定义,我不想推翻重来,只想让这些类型定义成为唯一的真相来源,然后自动产出我需要的代码。t3code 的定位就是这样一个轻量级代码生成器,它不吃进整个框架,只吃进"类型",再吐出"代码"。
1.3 名字里三个 T 的含义
t3code 的"t3"是三个 T 的缩写:Type-first(类型优先)、Template-driven(模板驱动)、TypeScript-native(原生 TypeScript)。
- Type-first 意味着所有接口契约都用
.ts文件里的 interface / type 表达,不引入额外的 DSL。 - Template-driven 意味着每种输出目标(zod 校验器、客户端请求函数、服务端处理器骨架)都是一个独立模板,你可以定制渲染逻辑。
- TypeScript-native 意味着它直接基于 TypeScript 编译器的类型信息做解析,而不是正则抓文本,所以对泛型、联合类型、索引访问类型的处理是可靠的。
一句话总结:让一份类型的定义,同时驱动校验、调用和实现三处代码,消除"人肉同步"。
2. 架构选型:为什么选了"代码生成"而不是运行时反射
2.1 两条路线的取舍
规划 t3code 时,我面临一个关键选型:是像 tRPC 那样在运行时通过 Proxy 做类型推断,还是在编译期把类型"翻译"成普通的代码文件。
运行时方案的好处是代码量少,调用时直接用方法名,IDE 补全也舒服。但它有几个硬伤:后端必须挂着 tRPC 中间件,前端必须通过它提供的 client 才能拿到类型,老接口和第三方回调很难纳入这套体系;而且运行时的错误排查链路很长,字段校验失败时你得去翻 Proxy 内部实现才知道发生了什么。
代码生成方案恰恰相反,它多一道"生成文件"的工序,但换来的是透明和可控。生成出来的 zod 校验器和 fetch 函数都是普通代码,任何一个开发都能直接读懂,出问题也能直接在生成文件里打断点。我最终选了代码生成,核心原因只有一条:团队信任建立在可读的产物上,而不是魔法上。
2.2 三条命令和一个配置文件
t3code 的使用面保持得尽量窄,核心命令就三条,另加一个诊断命令:
| 命令 | 作用 |
|---|---|
t3code init | 初始化t3.config.ts配置文件 |
t3code generate | 读取类型文件,生成所有目标代码 |
t3code watch | 监听类型文件变更,增量重新生成 |
t3code doctor | 检查配置、入口文件、依赖版本是否正常 |
配置文件长这样:
import { defineConfig } from 't3code'; export default defineConfig({ entry: './contracts/**/*.ts', outputs: [ { target: 'zod', dir: './src/validators' }, { target: 'client', dir: './src/api/client' }, { target: 'server', dir: '../server/src/handlers' }, ], prettier: true, banner: '// Code generated by t3code. DO NOT EDIT.', });配置本身也是 TypeScript 文件,用 zod 校验,写错了会在命令行直接给出友好报错。这一步虽然不起眼,但非常值得,因为工具一旦进入团队使用,绝大部分问题都出在配置而不是生成逻辑上。
2.3 管道式处理流程
t3code 内部是一条清晰的管道,我把它拆成了五个阶段:
- 收集入口:根据
entry的 glob 找到所有类型文件。 - 解析类型:用 ts-morph 建立 AST,提取 interface 和 type alias。
- 构建 IR:把 AST 转换成中间表示(Intermediate Representation),也就是一棵
T3Node树。 - 目标渲染:每个 output target 插件消费 IR,生成目标代码字符串。
- 落盘:统一写文件,跑 prettier,可选 dry-run 或
--check。
为什么要有 IR 这层?因为 AST 是 TypeScript 编译器专属的,如果我直接让渲染层依赖 ts-morph 的对象模型,以后想支持 JSON Schema、OpenAPI 做输入源,就得改所有渲染器。有了 IR,解析器和渲染器完全解耦,新增输入源只需要写一个新的解析器。IR 的定义也不复杂:
type T3Node = | { kind: 'object'; name: string; fields: T3Field[] } | { kind: 'union'; name: string; options: string[] } | { kind: 'array'; name: string; element: T3Node } | { kind: 'primitive'; name: string; tsType: 'string' | 'number' | 'boolean' } | { kind: 'reference'; name: string; target: string };这个设计帮我省了很多事,后面加 OpenAPI 输入源的时候,只需要把 OpenAPI schema 转成T3Node树,就能复用所有输出模板。
2.4 为什么 TypeScript AST 是关键
很多代码生成工具用正则表达式解析类型文件,开头跑得飞快,遇到泛型、条件类型、索引访问类型就崩了。t3code 从一开始就锚定 TypeScript 编译器 API,具体用的是 ts-morph 封装。原因很简单:
interface User { role: 'admin' | 'member' }里的 role,只有编译器能准确告诉你这是字面量联合类型,而不是普通 string。type UserRole = (typeof userRoles)[number]这种从常量数组推导的类型,只有编译器能做类型求值。Pick<User, 'id' | 'name'>这种工具类型,正则完全无能为力。
直接用编译器还会带来一个额外好处:入口文件如果存在语法错误或类型错误,t3code 能第一时间给出精确的行列位置,而不是生成一堆残缺代码后再让你去猜。
3. 核心实现:类型到代码的映射怎么落地
3.1 用 ts-morph 解析入口文件
解析阶段是整个工具的地基,我用了 ts-morph 的内存文件系统来做,避免污染项目目录:
import { Project } from 'ts-morph'; function parseContracts(entry: string[]) { const project = new Project({ useInMemoryFileSystem: true }); project.addSourceFilesAtPaths(entry); const nodes: T3Node[] = []; for (const sourceFile of project.getSourceFiles()) { for (const iface of sourceFile.getInterfaces()) { nodes.push(parseInterface(iface)); } for (const alias of sourceFile.getTypeAliases()) { nodes.push(parseTypeAlias(alias)); } } return nodes; }这里有个容易忽略的细节:useInMemoryFileSystem会让所有文件的读取都在内存中进行,不落盘、不留临时文件,速度比走磁盘快很多。但代价是如果你引用了.ts文件之外的依赖,比如.json配置文件导入,就得额外把这些依赖也加入addSourceFilesAtPaths,不然类型检查可能失败。doctor 命令做的就是这类体检,把所有 entry 涉及的 import 关系扫描出来。
3.2 类型到 Zod Schema 的映射函数
IR 建好之后,zod 渲染器的工作就不复杂了。核心是一个递归的字符串拼接函数:
function zodFor(node: T3Node, registry: Map<string, T3Node>): string { switch (node.kind) { case 'primitive': return { string: 'z.string()', number: 'z.number()', boolean: 'z.boolean()', }[node.tsType]; case 'array': return `z.array(${zodFor(node.element, registry)})`; case 'union': return `z.enum([${node.options.map((o) => `'${o}'`).join(', ')}])`; case 'object': return `z.object({\n${node.fields .map((f) => ` ${f.name}: ${zodFor(f.type, registry)}${f.optional ? '.optional()' : ''}`) .join(',\n')}\n})`; case 'reference': return `${node.target}Schema`; } }以这个类型为例:
export interface User { id: string; name: string; role: 'admin' | 'member' | 'viewer'; tags: string[]; profile?: { avatar?: string; bio?: string }; }生成出的 zod schema 是:
export const userSchema = z.object({ id: z.string(), name: z.string(), role: z.enum(['admin', 'member', 'viewer']), tags: z.array(z.string()), profile: z .object({ avatar: z.string().optional(), bio: z.string().optional(), }) .optional(), }); export type User = z.infer<typeof userSchema>;注意role字段,它命中了我前面说的"字面量联合类型"分支:只有编译器能确认这是三个合法枚举值,所以渲染成z.enum([...]),运行时传入其他值直接校验失败,等于把接口契约的约束也顺带落到了前端校验层。
3.3 生成类型安全的 fetch 客户端
客户端渲染器做的事情是把 schema 文件里的类型和方法映射成带类型的请求函数。约定很简单:GET /users/:id对应getUser(id),POST /users对应createUser(body)。生成结果类似这样:
export async function getUser(id: string): Promise<User> { const res = await fetch(`/api/users/${encodeURIComponent(id)}`, { headers: { 'Content-Type': 'application/json' }, }); if (!res.ok) throw new ApiError(res.status, await res.text()); return userSchema.parse(await res.json()); }这里最关键的步骤是userSchema.parse()。如果后端返回的数据不符合契约,会在前端第一时间抛错,而不是等到页面渲染时才白屏。实测下来,这个行为的调试价值极高,前端同学收到的报错会精确到"role字段期望 'admin' | 'member',实际收到了 'owner'",可以直接甩给后端,不再需要截图猜谜。
3.4 递归类型与循环引用的处理
树形结构在业务里太常见了,比如组织架构、评论回复。处理递归类型是 t3code 踩坑最多的地方,先看正确结果应该是什么样:
export interface TreeNode { value: string; children: TreeNode[]; }对应生成:
export const treeNodeSchema: z.ZodType<TreeNode> = z.lazy(() => z.object({ value: z.string(), children: z.array(treeNodeSchema), }) );这里有两个必须处理细致的点。
第一,z.lazy的回调必须延迟执行。我第一版实现里图省事,在渲染reference节点时直接调用zodFor去取被引用类型的 schema 字符串,遇到TreeNode引用自身就陷入了无限递归,生成过程直接栈溢出。正确做法是渲染成字符串z.lazy(() => treeNodeSchema),让运行时再去解析。换句话说,生成阶段永远不要展开递归引用,把展开交给运行时。
第二,循环引用涉及两个 interface 互相引用时,z.ZodType<TreeNode>这个显式标注不能少。没有它,TypeScript 会因为"implicitly has type 'any'"报错,生成的文件根本过不了编译。我是在生成后统一跑一次tsc --noEmit的冒烟测试才发现这个问题的。
4. 真实使用流程:从 init 到固化进团队工作流
4.1 初始化与第一次生成
安装和生成走一遍,整体时间不超过五分钟:
npm i -D t3code npx t3code init npx t3code generateinit 会在项目根目录生成t3.config.ts,默认的入口是./contracts/**/*.ts。我建议第一次生成时先跑一次 dry-run,看看每个文件会被渲染成什么样,再决定要不要落盘:
npx t3code generate --dry-rundry-run 输出的是每个目标的文件清单和代码预览,能帮你在正式提交前确认模板是否符合团队口味。首次生成的结果我强烈建议提交到 Git 并让所有人 review 一遍,这相当于给团队建立"生成文件长这样"的心理预期。
4.2 watch 模式与 IDE 集成
开发阶段最常用的是 watch 模式。t3code watch 内部用 chokidar 监听入口文件目录,文件变更后做 300ms 的 debounce,再增量解析和生成,实测在十来个类型文件的项目里,重建耗时基本在 50ms 以内。
我更推荐团队配合 VS Code 的 task 使用。在.vscode/tasks.json里配一个后台任务,启动项目时顺手跑起来:
{ "version": "2.0.0", "tasks": [ { "label": "t3code watch", "type": "shell", "command": "npx t3code watch", "isBackground": true, "problemMatcher": [] } ] }这样改完 contract 文件后,生成的校验器和 client 会自动刷新,开发体验是最顺滑的。唯一要注意的是 watch 模式下生成文件可能被用户手动改坏,所以生成文件头部必须带 "DO NOT EDIT" 横幅,这点我在配置里已经默认打开了。
4.3 与 lint-staged 和 CI 的配合
团队协作里最怕的是"你改 schema 忘了生成,提交了一堆过期的生成代码"。我是这样兜底的。
在package.json里加 lint-staged 配置,让提交 schema 文件时自动重新生成并重新 stage:
{ "lint-staged": { "contracts/*.ts": ["t3code generate --staged", "git add"] } }--staged模式只处理 Git 暂存区里变动的文件,比全量生成节省一堆无关 diff。CI 里再加一道保险,用--check模式验证生成结果是否和仓库里的一致:
npx t3code generate --check这个命令会把最新生成结果输出到临时目录,和当前工作区的文件逐一对比,有差异就直接以非零码退出。配合 GitHub Actions 或者 GitLab CI 的 MR 检查,基本杜绝了"过期代码入库"的问题。
4.4 生成代码该不该提交
这是个团队里一定会吵起来的话题。我的做法是分情况讨论:
- zod 校验器:提交。因为它是前端运行时依赖的一部分,而且 diff 直观,review 时可以看见类型改动带来的影响。
- fetch 客户端:提交。它的行数不多,提交后跨分支、跨仓库使用都方便。
- 服务端处理器骨架:忽略。因为服务端代码经常会做业务定制,一旦手工改过,再生成的合并冲突就非常痛苦,不如只在需要重建时重新生成。
总的原则是:生成产物要么完全交给工具自动管理,要么完全不进 Git;最怕的是半自动半手工混合。
5. 踩过坑与排查实录:代码生成不是一锤子买卖
5.1 字面量类型被悄悄拓宽成 string
这大概是我在 t3code 上踩过最深的一个坑,值得完整还原排查过程。
起因是同事提交了一个 contract 文件,里面这样写:
export const userRoles = ['admin', 'member'] as const; export type UserRole = (typeof userRoles)[number]; export interface User { role: UserRole; }生成完之后,role 字段对应的 zod 是z.string()而不是z.enum(['admin', 'member'])。这意味着任何字符串都能通过前端校验,契约形同虚设。
排查从 IR 开始。我先在解析阶段打日志,发现UserRole被解析成了primitive(string),说明类型求值环节已经把联合类型折叠掉了。继续追,问题出在我当初写的一个"优化"逻辑:
if (unionTypes.every((t) => t.isString())) { return { kind: 'primitive', tsType: 'string' }; }这段代码的本意是处理type A = string | string这种重复类型的场景。但问题是,字面量类型'admin'在编译器眼里isString()也为 true,这段优化把枚举值给吞了。修复方案是判断字面量性要先于字符串折叠:
if (unionTypes.every((t) => t.isLiteral())) { return { kind: 'union', options: unionTypes.map((t) => t.getLiteralValue()) }; } if (unionTypes.every((t) => t.isString())) { return { kind: 'primitive', tsType: 'string' }; }这个 bug 的教训是:代码生成器对类型语义的简化必须极其保守,任何"看起来等价"的折叠都可能丢掉关键信息。我给这个情况加了 Level 1 告警,一旦检测到 literal string 被折叠,立刻打出警告日志,从工具层面拦截这类回归。
5.2 递归类型导致的运行时栈溢出
另一个印象深刻的坑出现在把 t3code 接到真实业务的第一周。项目里有个评论接口,评论可以回复评论,contract 里很自然地写了递归的CommentNode。生成代码在本地测试一切正常,但部署到测试环境后,接口一调用就抛Maximum call stack size exceeded。
排查链路是这样的:先看前端调用,发现commentSchema.parse()抛栈溢出;再检查生成代码,发现z.lazy(() => commentNodeSchema)的标注没有问题;最后回到解析阶段,发现原因不在渲染,而在 IR 构建。
我在构建引用关系时,用一个Map<string, T3Node>存节点,但如果同一个类型被两个文件分别导出,生成的两个 schema 名字一致,后生成的覆盖了先生成的,导致z.lazy引用到了错误的 schema,形成 A 引用 B、B 又引用回 A 的死循环。
解决办法是给 schema 命名加上完整的模块路径前缀,比如userModuleCommentNodeSchema,从根本上杜绝命名冲突。同时也修了渲染器的防御逻辑:如果检测到引用链出现环,就强制在循环点打z.lazy,而不是继续展开。这两处修复之后,递归类型在十几个业务场景里都没有再出问题。
5.3 生成结果的幂等性与换行符问题
代码生成工具最怕"不确定性":同一份输入,两次生成结果不一致,会让 Git diff 变得无比混乱。我遇到三个具体的幂等性破坏源:
- 对象字段顺序:TypeScript interface 的字段声明顺序本身是稳定的,但用 ts-morph 获取时,如果没有显式按源码顺序收集,有可能拿到不同的排列。
- 导出顺序:从
Map遍历输出文件时,插入顺序在不同 Node 版本下可能不稳定。 - 换行符:Windows 团队成员的 Git 配置会把 CRLF 带进仓库,生成的代码和格式化的代码混在一起,diff 全是红色。
前两个问题处理方式是"排序兜底":所有 schema 名、字段名、文件路径,在输出前都做一次字典序排序,把顺序变成确定性行为。换行符问题更隐蔽,我用了一个笨办法根治:所有生成文件统一写成\n换行,配合仓库里的.gitattributes:
*.ts text eol=lf并且在generate --check里对每个文件做一次字节级对比,CRLF 差异也会被识别成变更,逼着团队统一环境。
5.4 团队上手阶段最容易出现的三个错误
工具推广到团队后,我记录了下前两周新用户最常犯的错:
| 错误 | 现象 | 根因 | 对策 |
|---|---|---|---|
| 在 contract 文件里写运行时逻辑 | 生成时报Cannot find name 'process'之类的错 | contract 应该只有类型和as const常量 | doctor 命令显式提醒:contract 目录只允许类型声明 |
| 多人同时改 schema 忘记生成 | 代码合并不冲突,但生成产物过期 | 本地 watch 没开,又没有检查机制 | CI 加--check,提交前置 lint-staged |
| 手工改生成文件 | 下次生成直接覆盖,改动神秘消失 | 没有接受"生成文件不能改"的约定 | 生成文件头加 DO NOT EDIT 横幅,并在 README 里写清流程 |
6. 跑了两个月后,我对这类工具的几点真实看法
t3code 从我自己用的脚本变成团队工作流的一部分,已经跑了两个多月。如果让我说这类"类型驱动代码生成"工具最重要的体会,我会说三点。
第一,生成的代码不一定比手写的代码质量高,但它一定比手写的代码"一致"。一致性带来的好处是 review 成本大幅下降——所有人都知道生成的 zod schema 长什么样,review 只需要看 diff 里的字段变化,不需要逐行读逻辑。
第二,工具的成败很大程度取决于你的"类型文件纪律"。t3code 把复杂度从"维护两块代码"转移到了"维护一份类型定义",这份类型定义一旦失控,比如塞满了泛型黑魔法和嵌套工具类型,生成器就会跟着遭殃。所以团队约定里最重要的一条是:契约文件用基础类型、联合类型、少量泛型,保持人类可读。
第三,如果你想在自己的项目里复刻类似的东西,强烈建议一开始就留一张"黄金快照"。我会在仓库里维护一个__snapshots__目录,里面放着每个 contract 文件对应的期望生成结果。每次改动生成逻辑,先跑快照对比,看哪些输出变了、变化是否符合预期。这套机制看起来笨,但它在重构渲染器时给了我非常大的安全感。
最后分享一个小技巧:t3code 的客户端生成器支持自定义 URL 前缀和鉴权 header 注入。我把它接在了内部网关的Authorization逻辑上,前端开发者再也不用手写 token 拼接,生成代码里直接带上当前的认证凭据逻辑。像这种针对团队具体痛点的定制,才是一个自研工具真正超越通用脚手架的地方。