t3code 是我花了几周业余时间写的一个命令行代码生成器,起因特别朴素:给项目加一个业务模块,我通常要把同一套代码写六遍——Prisma 模型一遍,Zod 校验规则一遍,tRPC 路由一遍,React Query hooks 一遍,表单类型一遍,有时候还要补一遍权限判断。create-t3-app 帮我把项目骨架一分钟搭起来了,但真正磨人的从来不是初始化,而是第一个实体出现之后无穷无尽的高度重复。你只需要维护一份描述业务实体的配置,t3code 会在后端生成 Prisma 模型与 tRPC 路由,在前端生成校验规则、hooks 和表单类型,整条链路保持类型安全。如果你在维护 T3 Stack(Next.js + tRPC + Prisma + Tailwind)项目,或者对"配置驱动生成类型安全代码"这种思路感兴趣,这篇文章从立项、设计到踩坑的完整过程都记录了,希望能给同类工具一点参考。
1. t3code 诞生的场景:脚手架之后的"手工劳动"才最烦人
先说背景。去年我接手一个内容社区项目,技术栈正好是 T3 Stack 那一套:Next.js 做页面和 API,tRPC 兜底类型安全的接口层,Prisma 管数据库。项目跑起来之后,需求开始按模块划分:先是文章,然后是文章下面的评论,再然后是标签和收藏夹。每个模块看起来不难,可真动手时我发现,一个普通的 Article 模块,少的也要手写十几个文件:
- 在 schema.prisma 里加模型和关系,跑一次迁移;
- 写 Zod schema 做入参校验,字段规则要和 Prisma 模型保持同步;
- 写 tRPC router,list/detail/create/update/delete 五个 procedure 长得几乎一样;
- 写 React Query hooks 和对应的类型定义;
- 最后还得检查 tsconfig 的路径别名有没有被正确使用。
最烦的不是写,而是改。比如我后来给 Article 加了一个 status 字段,Prisma 加一行,Zod 加一行,表单选项加一行,接口返回类型全部跟着变。我试过直接复制上一个模块的代码再全局替换,结果漏改了一处校验,某个接口短时间里接受了一个本来不该允许的枚举值,问题在线上才暴露出来。这种靠复制粘贴维持的"手工一致性",迟早会出错。
我当时的总结是:项目初始化工具已经解决得足够好,缺的是一个"模块生成器"——你告诉它要一个什么实体、有哪些字段和关系,它把后端到前端那一整套配套代码全部生成出来,并且保证类型一致。市面上确实有 Prisma 自带的 generator,但它只能生成数据访问层的代码,管不到 Zod 和 tRPC 这一层;也有人直接用 OpenAPI 或 protobuf 走代码生成,但对一个本来就用 TypeScript 全栈的团队来说,额外引入一套 IDL 和构建链,学习成本和心智负担都太大。t3code 的定位因此很明确:面向 TypeScript 全栈项目的、以实体配置为唯一输入的应用程序代码生成器。它不碰数据库迁移,不碰 UI 组件库,只负责把"从一个实体到一套可用代码"这段路自动化。
2. 核心设计取舍:为什么把"一份 feature spec"作为唯一输入
t3code 的第一个问题是输入长什么样。当时我有两个选项:YAML 配置,或者 TypeScript 模块。
YAML 看起来对非开发者友好,但项目里真正用 t3code 的人基本都是前端工程师,他们更习惯在 .ts 文件里获得自动补全和类型校验。选 TypeScript 还有一个实际好处:配置本身就能引用项目里已有的枚举常量、权限定义,不需要在 YAML 里再复制一份。所以我最终把配置设计成一个 TS 模块,用 defineFeature 提供接口和链式写法,底层其实就是一个带类型的普通对象。
一个文章模块的配置大概长这样:
// features/article.ts import { defineFeature, s } from 't3code'; export const article = defineFeature({ name: 'Article', fields: { title: s.string().min(1).max(120), slug: s.string().unique(), content: s.text(), published: s.boolean().default(false), }, relations: [ { type: 'hasMany', target: 'Comment', via: 'articleId' }, ], endpoints: ['list', 'get', 'create', 'update', 'delete'], });这里 s 是 t3code 暴露的字段描述器,每个方法都返回带类型的元数据对象。为什么我要单独做 s.string()、s.text() 这一层,而不是直接让用户写{ type: 'string', min: 1, max: 120 }?理由有两个:一是链式调用在 IDE 里有完整的补全提示,写错字段名会在运行 t3code 之前就被类型检查拦下来;二是字符串字面量形式的 type 容易被复制粘贴出错,而方法调用天然不允许随意拼凑。
选"一份 spec 作为唯一输入"这个模式,最主要的考虑是消除多端信息不一致的根源。手写代码时,Prisma、Zod、tRPC 各有一份对实体的描述,哪怕同一个字段名,三处的规则也可能悄悄分叉。spec 把字段类型、校验规则、关联关系放到一个地方,生成结果由同一份数据推导出来,就不存在"改了一处忘改另一处"的问题。这个思路和 Zod 的 z.infer 有点像:Zod 用一个运行时对象同时推导出静态类型,spec 则用一个描述对象同时推导出数据库模型、接口校验和前端类型。
也有人问过,为什么不干脆让用户直接写一个 Zod schema 作为输入,再由生成器逆推出 Prisma 模型。Zod 到 Prisma 的字段级映射可行,但关系(hasMany/belongsTo)、索引、unique 约束这些数据库概念在 Zod 里根本没有对应物。所以 spec 里必须同时容纳数据层概念和校验层概念,它本质上定位成"两端之间的公共语言",而不是某一端模型的简单镜像。
3. 生成管线逐个拆解:从 Prisma 模型到 tRPC 路由再到前端 hooks
确定了输入之后,剩下的问题是怎么从一份 spec 变出一堆文件。t3code 的管线分三层:输入层把 TS 配置加载并规范成"生成上下文",模板层负责渲染具体文件内容,输出层决定文件写到哪里、怎么处理覆盖。
3.1 输入层:从配置对象到生成上下文
t3code 在用户项目里通过npx t3code generate运行,第一步是用 jiti 动态加载 features 目录下的 TS 文件,得到包含所有实体的配置数组,然后做两件事:类型归并和关系补全。
类型归并是把 s.string()、s.text() 这种描述器翻译成三个不同体系下的具体类型。我做了这样一张映射表:
| DSL 类型 | Prisma 类型 | Zod 类型 | TS 类型 |
|---|---|---|---|
| s.string() | String | z.string() | string |
| s.text() | Text | z.string() | string |
| s.int() | Int | z.number().int() | number |
| s.float() | Float | z.number() | number |
| s.boolean() | Boolean | z.boolean() | boolean |
| s.datetime() | DateTime | z.coerce.date() | Date |
| s.json() | Json | z.unknown() | unknown |
这张表是整个生成器的"词表",后面 Prisma 模板、Zod 模板、类型模板都从这一份映射里取各自的表示,保证同一个字段在三个体系里语义一致。
关系补全则要处理反向字段。比如 Article 的 relations 里写了 hasMany Comment,via 是 articleId,那么 Comment 的 spec 里不需要再写 belongsTo Article,生成器会自动在 Comment 的 Prisma 模型里补上 articleId 字段和关系外键,同时在 Article 的模型里补 comments 数组关系。这个设计让用户只在一侧声明关系,另一侧由程序推导,避免了两侧声明不一致的坑。
3.2 模板层:为什么选 ETA 而不是手拼字符串
生成文件本质上就是"模板 + 数据"。最粗暴的方案是手写字符串拼接,但生成的内容里有大量 TSX 和泛型,直接拼字符串很容易在各种引号、反引号、尖括号里翻车,代码一多根本没法维护。我一开始用的是 Handlebars,理由很简单:生态大、语法熟。
结果第一个坑就踩在 Handlebars 上。生成 React 表单组件时,JSX 里的 style 对象长这样:style={{ width: '100%' }}。Handlebars 也把{{ }}当作模板语法,看到这段直接当变量名去解析,生成的文件整个坏掉。我试着用转义字符去处理,但转义规则和 JSX 混在一起,模板可读性急剧下降。最后换成了 ETA,并把定界符从默认的<% %>改成[[ ]]:
const eta = new Eta({ views: path.join(__dirname, 'templates'), tags: ['[[', ']]'], });这样生成内容里的 JSX、泛型、TS 模板字符串都成了普通文本,只有[[ ... ]]是模板逻辑。比如生成 tRPC router 的模板片段:
// templates/router.eta import { createTRPCRouter, publicProcedure } from '~/server/api/trpc'; import { z } from 'zod'; import { [[it.name]]Schema } from '../schemas/[[it.name]]'; export const [[it.name]]Router = createTRPCRouter({ [[for (const ep of it.endpoints) { ]][[include `procedure.${ep}` ]][[ } ]] });模板里只放结构骨架和循环、条件,字段级的差异全部由输入层算好,放进上下文对象里,模板本身保持很薄。这样既方便调试,又方便团队里其他人看懂模板在干什么。
3.3 输出层:文件落盘、覆盖策略与收尾命令
输出层要回答三个问题:文件放哪、旧文件怎么办、生成完还要做什么。
路径规划用一套约定:tRPC router 放到src/server/api/routers/<name>.ts,Zod schema 放到src/server/schemas/<name>.ts,React Query hooks 放到src/hooks/<name>.ts,表单类型和基础组件放到src/components/<name>/。约定写死在生成器的 route 配置里,用户可以整体改前缀,但不建议单独改某一个文件的路径,否则跨文件的相对导入会乱。
Prisma 模型不是生成新文件,而是要追加到用户已有的 schema.prisma 里。为了不覆盖用户手写的内容,我用标记注释圈定生成区域:
// --- @t3code:article-start --- model Article { // 由 t3code 生成的所有字段 } // --- @t3code:article-end ---每次生成时,只重写两个标记之间的部分,标记之外的内容原样保留。这样用户即使手动调整过其他模型,也不会被误伤。
覆盖策略我再加了一道保险:如果目标文件存在但文件头没有// generated by t3code标记,生成器拒绝覆盖并报错,防止把用户手写的文件整个冲掉。做完文件写入后,t3code 会自动依次执行prisma format、prisma generate和针对生成目录的 Prettier,相当于把收尾动作也封装进管线。这一步看起来琐碎,但正是它让生成结果能直接通过编译,而不是留下一堆需要手工格式化才能看的代码。
4. 实战走一遍:用 t3code 给博客项目生成带评论的文章模块
光讲设计有点虚,我拿一个真实场景完整跑一遍。假设项目已经用 create-t3-app 初始化好了,技术栈是 Next.js + tRPC + Prisma,我们要添加 Article 和 Comment 两个模块,评论挂在文章下。
第一步,安装并初始化:
npm install -D t3code npx t3code initinit 会在项目根目录建 features 目录和 t3code.config.ts,config 里只需要声明 features 目录位置、默认输出路径和是否启用 prettier。
第二步,写两个 feature 文件。Article 的配置我在第 2 节已经贴过,Comment 的配置如下:
// features/comment.ts import { defineFeature, s } from 't3code'; export const comment = defineFeature({ name: 'Comment', fields: { body: s.text().min(1).max(1000), authorName: s.string().max(80), articleId: s.string(), }, relations: [ { type: 'belongsTo', target: 'Article' }, ], endpoints: ['list', 'create', 'delete'], });注意 Comment 这里我主动写了 articleId 字段,同时声明 belongsTo Article。生成器会检查这种冗余声明是否一致:如果字段名、类型和反向关系能对上,就正常合并;对不上则直接报错。这个检查帮我避免了好几次字段类型前后不一的问题。
第三步,运行生成:
npx t3code generate输出大致是这样:
✓ features/article.ts → prisma model Article (+2 relations) ✓ features/comment.ts → prisma model Comment (+1 relation) ✓ generate Article router → src/server/api/routers/article.ts ✓ generate Comment router → src/server/api/routers/comment.ts ✓ generate schemas → src/server/schemas/article.ts, comment.ts ✓ generate hooks → src/hooks/article.ts, comment.ts ✓ prisma format & generate ✓ prettier生成的 Comment router 大致长这样:
import { createTRPCRouter, publicProcedure } from '~/server/api/trpc'; import { z } from 'zod'; import { commentSchema, commentCreateSchema } from '../schemas/comment'; export const commentRouter = createTRPCRouter({ listByArticle: publicProcedure .input(z.object({ articleId: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.comment.findMany({ where: { articleId: input.articleId }, orderBy: { createdAt: 'desc' }, }); }), create: publicProcedure .input(commentCreateSchema) .mutation(async ({ ctx, input }) => { return ctx.db.comment.create({ data: input }); }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.comment.delete({ where: { id: input.id } }); }), });前端 hooks 也一并生成:
import { api } from '~/utils/api'; export function useCommentsByArticle(articleId: string) { return api.comment.listByArticle.useQuery({ articleId }); } export const useCreateComment = api.comment.create.useMutation;最后一步是把生成的 router 挂到根 router 上。t3code 保留了用户手动挂载的代码,这一步需要手工完成:
// src/server/api/root.ts import { commentRouter } from '~/server/api/routers/comment'; export const appRouter = createTRPCRouter({ article: articleRouter, comment: commentRouter, });然后页面里就能直接这样用:
const { data: comments } = useCommentsByArticle(article.id); const createComment = useCreateComment();整个过程跑下来不到两分钟。最让我满意的不是速度快,而是修改流程:后来我想给 Comment 加一个 isApproved 字段,只改了 feature 文件的一行,重新 generate 之后,Prisma 模型、Zod 校验、创建接口的类型、前端 hooks 的返回类型一次性全部更新,没有再出现"数据库加了字段但接口校验还没加"这种半同步状态。
5. 三个印象最深的坑:模板定界符、相对导入与跨版本漂移
t3code 本身是个代码生成工具,但它最典型的三个坑,恰好都来自"生成的代码要活在用户的真实项目里"这一事实。每一个我都实打实排过,分开写一下。
5.1 Handlebars 与 JSX 双花括号的正面冲突
这就是第 3 节提到的定界符问题,但值得单独展开。用 Handlebars 生成 React 组件时,JSX 对象字面量的{{ }}是必现语法,style={{ display: 'flex' }}这种写法几乎每个组件都有。Handlebars 会把第一个{{当作变量标签的开始,模板解析阶段直接抛错,连渲染都到不了。
我当时的排查过程:先怀疑是模板写错了,逐行缩小范围后发现在一个只包含 style 属性的最小模板里依然报错,这才意识到是定界符冲突。中间尝试过用\{{转义,代码确实能跑,但模板里遍布转义符,生成器维护者会疯掉。最终方案是换用支持自定义定界符的 ETA,把逻辑标签改成[[ ]]。如果你也想自己写这类生成器,选模板引擎的第一条标准应该是"定界符和你要生成的代码语法没有交集",而不是"哪个引擎功能多"。
5.2 生成文件里的路径别名:在应用里能用,在测试里立刻崩
第二个坑出现在单元测试环节。我最初让生成的 router 一律用~/server/...这种 tsconfig paths 别名引用公共模块,应用运行时没问题,但项目里用 vitest 跑单测时,vitest 默认不认识~别名,生成的 router 文件一被 import 就报 module resolution 失败。
一开始我以为配一下 vitest 的 alias 就行,但后来发现团队成员各自项目里 vitest、jest、ts-node 的配置千差万别,指望生成代码依赖某一种测试环境是不现实的。最后定下的规则是:生成器在同一个模块内一律使用相对路径,比如 schemas 和 router 之间的引用写成../schemas/comment;只有引用 tRPC 的 createTRPCRouter 这类框架级入口时才用项目别名。这个改动之后,生成的代码在任何环境下都能直接 import,没有再出现环境相关的问题。这是一个值得所有生成器开发者记住的原则:生成代码要尽量"环境无关",不要假设用户项目的工具链和你的开发环境一致。
5.3 依赖版本漂移:生成器写死了写法,升级就把代码变过期
第三个坑是长期维护才会遇到的。t3code 最初按 Prisma 4 和 tRPC 10 的 API 写死了模板:Prisma 客户端挂在 ctx.prisma 上,tRPC 用 createTRPCProxyClient 创建调用端。半年后项目升级到 Prisma 5 和 tRPC 11,旧模板生成的代码要么需要手动改 API 名,要么类型对不上,等于生成器产出了"过期代码"。
我给的解决方案分两层。第一层,生成器启动时读取用户项目里实际安装的依赖版本,在生成上下文里标注 apiVersion,模板里用条件分支适配不同写法;第二层,也是更关键的一层,生成器提供t3code verify命令,跑一次类型检查和一个最小冒烟用例,确保生成结果在当前依赖版本下能通过编译。生成代码的维护成本和手写代码一样,会随时间增长,只有版本探测加持续验证,才能让生成结果不与项目脱节。
6. 扩展思路:把 t3code 变成团队自己的代码生成底座
t3code 目前是单机运行的个人工具,但它的结构天然适合往团队底座方向扩展。我觉得最值得做的三个方向:
第一个是字段类型注册表。现在 s.string()、s.text() 的映射是写死在生成器里的,团队如果有自己的枚举类型,比如业务里的订单状态、用户角色,可以注册一种"语义字段类型",把它映射到 Prisma 的枚举、Zod 的枚举校验、前端的下拉组件类型。这样 spec 里一个字段,整条链路的业务语义都是统一的。
第二个是模板包。模板目录本身是普通文件,完全可以打成 npm 包发布,团队内部维护一套"统一模板包",这样每个项目生成的代码风格都是同一套规范。新项目 clone 下来跑一次 generate,就自动落在团队的代码规范上。这对多人团队价值很大,等于把 code review 里最重复的一部分提前消灭了。
第三个是 CI 校验。在 CI 里跑t3code verify,比对生成结果和仓库里的实际文件是否一致,不一致就 fail。有了这道防线,任何人都不能绕过 spec 直接手改生成文件,所有改动都回到 feature 配置这个唯一入口上。
最后说一点个人体会。写 t3code 之前我以为难点在"生成"本身,写完才发现难点在"生成物要可靠地活在各种真实项目环境里":模板定界符、相对导入、依赖版本、覆盖保护,每一个都是和现实环境妥协的结果。与其追求生成器的功能丰富,不如把它做小、做薄,让模板可读、输出可预期,剩下的交给规范去约束。如果你也有类似的全栈项目重复劳动,比起抄一份模板到处改,花点时间把生成逻辑固定下来,长期看反而是更省力的一条路。