
t3code 是我花了几周业余时间写的一个命令行代码生成器起因特别朴素给项目加一个业务模块我通常要把同一套代码写六遍——Prisma 模型一遍Zod 校验规则一遍tRPC 路由一遍React Query hooks 一遍表单类型一遍有时候还要补一遍权限判断。create-t3-app 帮我把项目骨架一分钟搭起来了但真正磨人的从来不是初始化而是第一个实体出现之后无穷无尽的高度重复。你只需要维护一份描述业务实体的配置t3code 会在后端生成 Prisma 模型与 tRPC 路由在前端生成校验规则、hooks 和表单类型整条链路保持类型安全。如果你在维护 T3 StackNext.js tRPC Prisma Tailwind项目或者对配置驱动生成类型安全代码这种思路感兴趣这篇文章从立项、设计到踩坑的完整过程都记录了希望能给同类工具一点参考。1. t3code 诞生的场景脚手架之后的手工劳动才最烦人先说背景。去年我接手一个内容社区项目技术栈正好是 T3 Stack 那一套Next.js 做页面和 APItRPC 兜底类型安全的接口层Prisma 管数据库。项目跑起来之后需求开始按模块划分先是文章然后是文章下面的评论再然后是标签和收藏夹。每个模块看起来不难可真动手时我发现一个普通的 Article 模块少的也要手写十几个文件在 schema.prisma 里加模型和关系跑一次迁移写 Zod schema 做入参校验字段规则要和 Prisma 模型保持同步写 tRPC routerlist/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()Stringz.string()strings.text()Textz.string()strings.int()Intz.number().int()numbers.float()Floatz.number()numbers.boolean()Booleanz.boolean()booleans.datetime()DateTimez.coerce.date()Dates.json()Jsonz.unknown()unknown这张表是整个生成器的词表后面 Prisma 模板、Zod 模板、类型模板都从这一份映射里取各自的表示保证同一个字段在三个体系里语义一致。关系补全则要处理反向字段。比如 Article 的 relations 里写了 hasMany Commentvia 是 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.tsZod schema 放到src/server/schemas/name.tsReact 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.tsconfig 里只需要声明 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 之前我以为难点在生成本身写完才发现难点在生成物要可靠地活在各种真实项目环境里模板定界符、相对导入、依赖版本、覆盖保护每一个都是和现实环境妥协的结果。与其追求生成器的功能丰富不如把它做小、做薄让模板可读、输出可预期剩下的交给规范去约束。如果你也有类似的全栈项目重复劳动比起抄一份模板到处改花点时间把生成逻辑固定下来长期看反而是更省力的一条路。