t3code:从schema自动生成TypeScript全栈CRUD代码
t3code 这个项目的起点,是我第五十次给新项目搭脚手架时的一次崩溃。
做 TypeScript 全栈开发这些年,我几乎每周都要面对同一套流程:初始化项目、配 tsconfig、写 Zod schema、照着 schema 手写 tRPC router、再给前端补一套 React Query 的 hooks。这些工作一点都不难,但极度重复,而且重复得毫无价值——真正花心思的业务逻辑还没开始,时间已经碎了一地。所以我决定写一个命令行工具,把这些"胶水代码"全部自动生成,它就叫 t3code。
t3code 是一个面向 TypeScript 全栈开发的本地代码生成器,核心功能是:读入一份 schema 描述文件,自动输出类型定义、校验规则、接口路由和前端调用 hooks。名字里的 T3 取的是 Type、Test、Trace 三个词的首字母,对应我给自己定的三条铁律:生成的代码必须类型安全、必须可测试、必须可追溯。如果你也在为"重复造轮子"感到烦躁,或者手头正好有一堆 CRUD 模块要写,这篇文章应该能给你一些参考。
1. 项目缘起:为什么我不再用"复制粘贴"搭 CRUD
1.1 在写 t3code 之前,我先列了一张"嫌弃清单"
写工具之前,我把市面上能用的方案都试了一遍,列了一个对比表:
| 方案 | 能生成的 | 缺什么 |
|---|---|---|
| Prisma | 数据库模型、迁移、客户端类型 | 不生成业务路由和前端 hooks |
| tRPC 手写 | 无自动生成 | 每个字段都要在前后端各写一遍 |
| OpenAPI codegen | API 客户端、服务端桩 | 前置依赖 OpenAPI 文档,小型项目太重 |
| create-t3-app | 初始化模板 | 只管创建,不管后续迭代 |
我的结论是:现成工具要么生成得太少,要么管得太宽。Prisma 是个好东西,但它管到数据库就停了,route 层、校验层、前端调用层全都得自己拼;OpenAPI 那套 codegen 适合大团队维护接口规范,让我为一个小模块先写几十行 OpenAPI 定义,那我还不如手写。我要的是一种"项目生命周期内的持续生成",而不是"创建项目时的一次性脚手架"。
这个定位直接决定了 t3code 的形态:它不是 create-t3-app 那种 starter 模板,而是一个可以反复执行的 CLI,schema 变了,跑一次生成,前后端同步更新。
1.2 T3 三个字母:我给自己立的规矩
t3code 这个名字最容易被误会成蹭 T3 Stack 的热度。T3 Stack 里 T3 最早是 Tyler 的网名,后来被重新解释成 TypeScript、Tailwind、tRPC,是一套技术栈组合。我的 t3code 不是技术栈,而是一个工具,所以我把三个 T 重新定义为:
- Type(类型安全):生成的类型定义、运行时校验、接口入参出参必须同源,不存在"类型对得上,运行时校验却漏了"这种情况。
- Test(可测试):生成器的输出是确定性的——同一份 schema 在相同版本下生成的结果逐字节一致。这个特性让我可以写一份快照测试,把所有模板的回归风险兜住。
- Trace(可追溯):每个生成文件顶部都带来源标记,标注 schema 文件名、内容哈希和生成时间。出问题的时候,十分钟内定位到是 schema 改错了还是模板有 bug。
这三条不是口号,后面每个设计决策都是围绕它们展开的。比如为了 Test,我选择了按内容哈希做增量生成,而不是无脑全量覆盖;为了 Trace,我在产物里嵌了校验用的指纹信息。后面你会看到这些规矩具体落在哪些代码里。
1.3 这工具适合谁
先说结论:t3code 适合两类人。第一类是像我一样的独立开发者或全栈工程师,手头业务大部分是标准 CRUD,与其每个项目手写一遍,不如把 schema 当唯一事实源。第二类是有 3-10 人前端或全栈小组的团队,成员水平参差,生成的样板代码能保证最低质量线,review 的时候不用再为"这个字段忘了加校验"浪费口水。
不适合谁呢?如果你的业务接口几乎没有规律,每个接口都是特殊的拼装逻辑、复杂的权限矩阵、或者极度依赖外部系统的数据流,那生成器反而会成为你的枷锁。生成器擅长的是"有规律的重复",不是"无规律的创新"。这一点在项目启动前想清楚,能省下很多后面推翻重来的时间。
2. 核心设计:模板、元校验与增量生成的取舍
2.1 模板引擎:为什么是 EJS 而不是 Handlebars
t3code 的模板层我最初在 EJS、Handlebars、Nunjucks 三者之间犹豫过。这里我把实际对比写出来:
- Handlebars 的优点是比较"干净",强制模板里不写逻辑,但它那个 block helper 的写法,要用起来必须自己注册一堆 helper,写 TypeScript 模板的时候非常憋屈。
- Nunjucks 功能最强,继承、宏都有,但文档和生态相对收敛,团队里新人上手成本高。
- EJS 直接在模板里写 JavaScript,对本来就是手写代码的我们来说,学习成本几乎是零,而且它能复用我在项目里已经熟悉的 JS 语法。
实际使用中 EJS 最大的坑是转义冲突,这个我在第四节详细讲。选 EJS 还有一个附加收获:它可以先在服务端把模板编译成函数,再传入数据调用,这让"给模板喂数据之前先校验数据"这个机制实现起来很自然。
2.2 元校验:让 schema 本身先通过体检
很多人写代码生成器有个误区:schema 随便写,生成的时候才发现报错,报错信息还特别难懂。t3code 的做法是反向的——schema 描述文件本身也要符合一套元结构,而这套元结构用 Zod 定义。
具体来说,我在工具内部有一个SchemaMeta的 Zod schema,它规定了字段类型、约束关键字、操作列表、关联关系的写法。当用户执行t3code generate时,CLI 第一步不是读模板,而是用SchemaMeta.safeParse(userSchema)去校验用户写的 schema,任何不合法的地方都会给出类似"第 12 行字段 age 的约束 min 不能大于 max"的具体报错。
这一步的价值在于把错误提前拦截在"代码生成之前",而不是"代码生成之后"。后来我统计过,t3code 的报错里超过六成是这种元校验拦下来的,剩下的才轮到模板执行时报错。
2.3 增量生成与文件指纹
生成器最怕的场景是什么?是用户手改了几行生成文件,然后重新生成,改动被覆盖,并且没有警告。t3code 用三个机制来解决:
- 每个生成文件头部写入
// Generated by t3code, schema: xxx.schema.ts, hash: <sha256>标记。 - 生成前先读取现有文件,如果文件带生成标记且内容哈希与"本次将要生成的内容哈希"一致,则跳过写入,不做无谓的磁盘操作。
- 如果文件带生成标记但内容被改过,默认进入保护模式——不覆盖,并在终端列出差异,让你确认是
--force覆盖还是保留手动修改。
这套机制表面上是文件管理问题,本质上是一个"生成物状态管理"问题。有了指纹标记之后,我还能在 CI 里加一步t3code check,它比对代码库里的生成文件和 schema 的哈希,不一致直接让流水线失败,从源头堵住"生成文件过期"这个隐患。当时设计这套机制,背后其实就是那个 T3 里的 Trace——任何产物都能追溯到它是由哪份 schema、哪个版本的 t3code 从哪个模板产出的。
3. 完整实操:一份 schema 生成一套 user CRUD
3.1 安装与项目初始化
t3code 是一个 npm 包,全局安装或者作为 devDependency 装进项目都可以。我的建议是装进项目本身,版本跟着仓库走,这样以后要复现某个历史版本的生成结果,直接切到对应 commit 就行。
npm install -D t3code npx t3code initinit会在项目根目录生成三类东西:t3code.config.ts(配置文件)、schemas/(schema 目录)、templates/(模板目录,默认提供 ejs 模板)。配置文件的默认样子大概是这样:
// t3code.config.ts export default { schemasDir: './schemas', templatesDir: './templates', generatedDir: './src/generated', /** * 自定义输出映射:keys 对应生成物类型 */ outputMap: { type: './src/server/entities/{name}.type.ts', zod: './src/server/entities/{name}.zod.ts', router: './src/server/routers/{name}.router.ts', hooks: './src/web/hooks/use{Name}.ts', } };这里{name}和{Name}是占位符,一个转蛇形,一个转大驼峰。后面你会发现,路径映射表才是生成器真正灵活的地方——模板可以换,输出位置可以换,schema 是唯一不动的东西。
3.2 写第一份 schema 描述
以最经典的 user 模块为例,我在schemas/user.schema.ts里写上定义。注意 t3code 的 schema 文件本质是一个 TypeScript 文件,它 export 一个对象,而不是写死 JSON,这样能保留类型提示,写的时候就能看到哪些字段名打错了。
import { defineSchema } from 't3code'; export default defineSchema({ name: 'user', table: 'users', fields: { id: { type: 'uuid', primary: true }, email: { type: 'string', format: 'email', unique: true }, displayName: { type: 'string', min: 2, max: 32 }, age: { type: 'number', min: 0, max: 120, optional: true }, tags: { type: 'array', items: { type: 'string' }, default: [] }, createdAt: { type: 'datetime', auto: true }, }, operations: ['list', 'get', 'create', 'update', 'delete'], relations: { posts: { type: 'hasMany', target: 'post', foreignKey: 'userId' }, }, });这里每段都值得说两句。id用 uuid 而不是自增,是为了在生成前端 mock 数据和测试数据时不用依赖数据库返回;email的format: 'email'会同时在 Zod 里生成z.string().email(),在数据库层生成唯一索引,这是字段描述一次、多处生效的核心体现。operations数组决定了生成哪些接口——不需要 delete 接口的模块直接去掉,产物里就不会有 delete 路由,这在真实项目中非常实用,因为不少内部模块确实不应该暴露删除能力。
说到 relations,t3code 目前支持hasMany和belongsTo两种,生成器会在 router 里自动补充关联查询的入参和返回结构,但不会去生成数据库表本身。这里要说明:t3code 定位是"业务代码生成",数据库迁移这件事我选择交给 Prisma 或 Drizzle 干,工具之间各管一段,不搞大而全。
3.3 执行生成,看看产物长什么样
一切就绪后,运行:
npx t3code generate第一次跑会创建配置里指定的输出目录,并打印这次生成的产物清单。我们挑两个最重要的产物看。
第一个是 Zod 校验 schema。它的作用是在运行时替我们守住数据入口,避免出现"TypeScript 类型写了 string,前端却传了个 number"这类运行时炸弹:
// Generated by t3code, schema: user.schema.ts, hash: 6f2a91... import { z } from 'zod'; export const UserCreateInput = z.object({ email: z.string().email(), displayName: z.string().min(2).max(32), age: z.number().min(0).max(120).optional(), tags: z.array(z.string()).default([]), }); export const UserUpdateInput = UserCreateInput.partial();注意UserUpdateInput用的是partial(),这就是为什么 schema 里不用单独写"更新时哪些字段可选"——更新操作天然是部分更新,生成器替你推出来了。这种"从一个定义推出多个衍生形态"的能力,是手写代码很难保持一致性的地方。
第二个是 tRPC router。t3code 默认模板会生成protectedProcedure和publicProcedure两种路由,并自动接入刚才的 Zod 校验:
// Generated by t3code, schema: user.schema.ts, hash: 6f2a91... import { router, protectedProcedure } from '../trpc'; import { UserCreateInput, UserUpdateInput } from '../entities/user.zod'; import { db } from '../db'; export const userRouter = router({ list: protectedProcedure .input(z.object({ page: z.number().default(1), pageSize: z.number().default(20) })) .query(({ input }) => db.user.findMany({ skip: (input.page - 1) * input.pageSize, take: input.pageSize, })), create: protectedProcedure .input(UserCreateInput) .mutation(({ input }) => db.user.create({ data: input })), // 其余 get / update / delete 类似,不再展开 });这里强烈建议你不要直接改生成文件。如果需要加鉴权之外的业务逻辑,比如创建用户后要发一封邮件,正确做法是把它写成独立 service,在路由外面包一层,或者用后面会讲到的"扩展点"机制。保持生成文件"可随时重新生成"的状态,是这套工作流能跑下去的前提。
3.4 前端 hooks 与工程化接入
生成器还会输出前端用的 React Query hooks,比如src/web/hooks/useUser.ts里会有useUserList、useUserCreate、useUserUpdate等方法,每个 hook 的入参类型直接来自 Zod schema 推导出的 TypeScript 类型。前端调用时不需要再手写类型,也不需要管 query key 字符串拼写,因为这些都是同一个 schema 生成出来的。
工程化方面我比较推荐三件事:
- 把
npx t3code generate挂到package.json的prepare或predev钩子上,保证本地开发时产物不会过期。 - 把生成文件提交进 Git。很多人纠结"生成物要不要入库",我的答案是入库,理由很简单:真实项目里你会改模板、会调输出路径,入库后 diff 能让你看清"这次调整到底影响了哪些文件",排查问题会快得多。
- 给 CI 加一条
npx t3code check,它只做哈希比对,不写文件,作为"生成物是否过期"的守门员。
到这里,一个完整的 user 模块——包含后端校验、路由、前端 hooks——就生成完了。整个过程不涉及任何手写接口代码,而 schema 文件本身就是最好的文档。
4. 踩坑记录:这些坑我已经替你交过学费了
4.1 EJS 模板转义:<%和 TypeScript 模板字符串打架
这是 t3code 开发过程中我花时间最长的一个 bug。EJS 的标签是<%和%>,而 TypeScript 的模板字符串是`${...}`,两者看似井水不犯河水,但一旦你的模板里要生成一段"包含模板字符串的 TS 代码",冲突就来了。
我举个例子:生成 Zod schema 时,我想在产物里输出${'email'}这样的模板字符串写法,但这段内容一旦放进 EJS 模板,EJS 会把${当普通文本处理,转义顺序稍有差池,就会生成错乱代码。我当时的解决办法有两个,你可以直接拿去用:
- 在 EJS 模板中需要输出
${字面量的地方,用<%= '%' %>拼出来。例如${name}写成<%= '%' %>{name},生成时就是${name}。 - 更彻底的办法:把"需要字面输出模板语法"的内容抽到辅助函数里,在函数内部用普通字符串拼好,模板里只调
<%= helper.emailRule() %>,从根上避开嵌套冲突。
第二个办法更推荐,因为它把"生成复杂字符串"的逻辑和"模板布局"分开了,测试起来也更好写。后来 t3code 内置的模板几乎全部改用辅助函数输出这类内容,转义问题基本绝迹。
4.2 文件覆盖:合作者手动改了生成文件怎么办
项目做到中期,最容易出现的烂事是:同事在生成的 router 文件里直接加了业务代码,然后某天他跑了一次 generate,代码被覆盖,半天的工作白干,群里一顿吵。这个问题的根源不是覆盖时机,而是"生成文件和手写代码没有隔离"。
我的解法是两层。第一层是严格的保护模式:默认情况下 t3code 检测到"文件有生成标记但内容哈希不匹配",会拒绝覆盖并列出 diff;只有--force才覆盖。第二层更重要——提供显式的扩展点,也就是在生成 router 时,模板里留出一个"用户可编辑区块":
// @t3code-extension:start user.router.custom // 这个区间内的代码不会被生成器覆盖 // @t3code-extension:end user.router.custom生成器每次写入前扫描文件,如果文件里有这个标记对,就把区间内容原样搬回来,再写回文件。这样同事的定制逻辑能安全地跨越多次生成,同时生成部分保持可复现。
当然,这个方案不是万能的,嵌套的扩展点、扩展点内容里包含结束标记这类情况都要兜底处理。我的建议是:扩展点只用于"少量业务插桩",超过 50 行的定制就该抽成独立 service 模块,生成代码只负责调用它。
4.3 跨平台路径:macOS 上好好的,Windows 上就崩
t3code 在 macOS 和 Linux 上跑了好一阵都没事,直到一个同事在 Windows 上跑generate,生成的 import 路径里出现了反斜杠,模块解析直接失败。原因很简单:代码里用了path.relative拼接 import 路径,在 Windows 上返回的是..\src\entities\user.zod,而 TypeScript 处理这种混合分隔符的路径时,行为和你预期的不完全一样。
修复方式是统一用path.posix来生成所有写进文件的路径,只在真正访问磁盘时用平台相关的path。这也是一个通用建议:凡是"会写进源代码里的路径",一律用 posix 风格;只有"给文件系统用的路径",才允许平台差异。
另一个跨平台问题是 watch 模式下的文件监听。chokidar在 Windows 上对符号链接和大小写不敏感路径的处理和 macOS 不太一样,我在本地目录使用followSymlinks: false并显式指定 ignored 规则,才把误触发和重复触发的现象压下去。这种问题没有捷径,只能靠在不同平台上多跑几遍真实场景来磨合。
5. 经验沉淀:生成器的边界与后续计划
5.1 什么情况我不推荐你用生成器
t3code 用了大半年之后,我对"代码生成器能干什么、不能干什么"有了更清醒的认识。它最舒服的场景是:数据模型稳定、接口形态规整、前后端类型联动频繁。反过来,如果项目里充满了不规则的状态机、细碎到无法归纳的权限分支、或者上下游系统的字段映射,那强行抽象出 schema 的成本反而比手写还高。
我现在选型时有个很朴素的标准:一份业务代码,如果我在第三个类似模块里重复写它,我就开始考虑抽模板;如果只是偶尔写一次,哪怕它很繁琐,手写也未必更慢。生成器不是银弹,它是"重复次数足够多之后"才划算的杠杆工具。
5.2 后续计划和一个实用小技巧
t3code 目前还在演进,接下来我最想加的是插件机制,让不同框架的用户(比如 NestJS、Fastify 的用户)可以自己写模板包发布出去。另外,把check模式做成一个 pre-commit 的 Git hook 也是低成本高收益的方向。
最后分享一个实战里非常好用的小习惯:每次改完模板或 schema 后,先执行生成,再用git diff --stat快速浏览这次改动涉及的文件范围,最后针对重点文件看 diff。因为生成的代码都是同一个模板套路,diff 里出现的大面积增删通常意味着"模板改了"或者"schema 结构变了",这两种情况对应的问题排查方向完全不同。这个小习惯让我在改模板时特别有安全感,基本不会出现"改完模板后发现线上业务被悄悄影响"的情况。
如果你也想做一个类似的代码生成工具,我的建议是:先圈定一个非常窄的场景——比如就只做"Zod schema 到 tRPC 路由"这一段——把它打磨到"重新生成不心惊"的程度,再谈扩展。生成器这东西,越小越锋利。