上个季度我接手了一个基于 Next.js 13 + TypeScript + tRPC + Prisma + Tailwind 的新项目,也就是大家常说的 T3 全家桶。项目业务本身不算复杂,但有个事特别磨人:每新增一个业务模块,都得手动从头到尾敲一大串样板代码——Prisma model、Zod schema、tRPC router、React Query hooks、表单、表格,一个都不能少,错一个类型整条链路全崩。前两个模块我还能忍,到第三个模块时我实在受不了了,于是开始折腾 t3code。这篇就把我这几个月的真实使用经验写出来,包括它到底解决什么问题、怎么接入现有工程、核心的工作流长什么样、团队协作该怎么约定,以及我踩过的几个坑的完整排错过程。如果你是 T3 技术栈的重度用户,或者团队正在为“照抄上一个模块再改类型”这种低效做法发愁,这篇文章应该能帮你省下不少时间。
1. t3code 是什么定位:不等同于脚手架,而是增量代码生成器
1.1 先对齐一下 T3 这个词
说到 T3,很多人的第一反应是 create-t3-app——那个一键生成 Next.js + TypeScript + Tailwind + tRPC + Prisma 项目的脚手架。它解决的问题是“项目从零到一”,跑一条命令就能得到一个结构完整、类型安全的种子工程。
但项目跑起来之后,真正的日常工作是“从一到 N”:今天加一个订单,明天加一个商品分类,后天再来个用户积分流水。这个阶段没有现成命令可敲,大家的常规操作是:打开上一个类似模块的代码,全选复制,然后全局替换字段名、类型名、注释里的业务词。听起来挺快,实际上破绽百出——漏改一个字段,类型对不上;复制过来的校验规则忘了改,表单屡屡验证失败;更别说代码风格在几个模块之间逐渐漂移。
t3code 在命名上很容易被误认为是 T3 的另一个脚手架,但用下来你会发现,它走的完全是另一条路:不负责项目初始化,只负责功能模块的增量生成。你给它一份领域模型定义,它负责把从数据库层到 UI 层一整条链路的代码铺好。
1.2 样板代码的真实成本:手工复制到底贵在哪
我用数据说明一下为什么手工复制不划算。以我们公司一个典型业务模块为例,它包含:
- Prisma schema 里的 model 定义与枚举
- 对应的 migration 文件
- Zod input / output schema(至少两套,创建和更新还不一样)
- tRPC router,包含 CRUD 五个方法加上分页、模糊搜索
- React Query 的一套 hooks(useQuery、useMutation,还有缓存的 key)
- 前端表单组件、校验错误的展示、表格列定义
把这些全写完,熟练的人也要 30 到 60 分钟,而且大部分时间是花在“把上一份代码的字段 A 改成字段 B”这种毫无技术含量的机械替换上。机械替换的问题在于:它恰好是人最容易犯错的地方。我统计过团队里代码评审中被点名的修改意见,有接近四成都是“类型没同步”“字段少了一处”“枚举值拼错了”。
t3code 正是冲着这部分成本去的。它把“定义一个领域模型”和“生成整套代码”这两件事拆开:模型定义是你写的、需要人审的;后续所有层的代码,全部由生成器依据同一份定义推导出来。只要定义没错,出来的代码各层之间天然一致。
1.3 它的工作方式:一份定义,多处产出
用一句话概括 t3code 的工作方式:读取领域模型文件,按模板批量产出代码文件。领域模型文件可以是一个符合 Zod 语法的 TypeScript 文件,也可以是一个 YAML 描述,团队可以按习惯选。生成器解析出字段、类型、校验规则、关联关系,然后套用内置的 tRPC 模板、React Query 模板、Prisma 模板、表单模板,把文件写到项目对应目录里。
这个“一份定义,多处产出”的模式,带来的好处不只是省时间,还顺手解决了一个更隐蔽的问题:各层之间的不一致。手工开发时,Prisma 里字段名叫createdAt,Zod 里写create_time,tRPC 返回又改成createTime,前端接的时候小心翼翼,但总有漏网之鱼。而生成器的所有产出都来源于同一份定义,名字、类型、校验规则天然统一,这一类问题基本被扼杀在源头。
2. 把 t3code 接进现有项目:安装、初始化与配置逐项说明
2.1 环境准备与版本选型
先说环境。t3code 是一个 Node.js CLI 工具,要求 Node 18 以上的运行环境,因为解析 Zod schema 和 TS 类型依赖新版 TypeScript 编译器 API。包管理器我用的是 pnpm,项目锁文件是 pnpm-lock.yml,如果你的团队用 npm 或 yarn,命令上大同小异,后面我会提到对应写法。
版本选型上有一点值得注意:t3code 的版本号跟 TypeScript 深度绑定,0.x系列会标明它支持的 TS 版本区间。项目里用的是 TS 5.4,我装的就对应t3code@0.3.x。不建议直接装最新版,最好先看一眼 peerDependencies 和你的项目 TS 版本是否匹配,不然生成出来的类型文件可能出现@types不兼容的情况。这个坑我后面会详细说。
安装命令很简单:
# pnpm pnpm add -D t3code # npm npm install -D t3code # yarn yarn add -D t3code装完之后运行npx t3code --version,能正常输出版本号就说明装好了。
2.2 首次初始化:t3code init 做了什么
接下来是接入现有工程的关键一步,运行:
npx t3code init这个命令会在项目根目录生成一个t3code.config.ts配置文件,同时创建一个默认的模板目录.t3code/templates。它不会像某些脚手架那样强行改动你的项目结构,也不会生成一堆你根本不需要的文件——这算是我对它初始好感度较高的原因。
init 过程中它会自动探测你项目里已有的目录约定。比如你的 Prisma schema 在prisma/schema.prisma,它就会把这个路径写进配置;你的 tRPC router 在src/server/api/routers,它也能识别出来。探测不到的目录会使用默认值,后续可以手改。也就是说,接入一个已有的 T3 项目不会破坏现有结构,生成文件的位置由配置决定。
2.3 配置文件逐项解读
初始化生成的t3code.config.ts大致长这样:
import { defineConfig } from "t3code"; export default defineConfig({ // 领域模型文件所在目录 modelsDir: "./src/models", // 生成物输出到哪个目录 generatedDir: "./src/server/generated", // 使用文件头标记,标记过的文件不要手工改 managedFileHeader: "@generated by t3code", // 各个层级的输出路径,按需覆盖 outputs: { prisma: { schemaPath: "./prisma/schema.prisma", append: true }, tRPC: { routerDir: "./src/server/api/routers", indexPath: "./src/server/api/root.ts" }, reactQuery: { hooksDir: "./src/hooks" }, forms: { componentDir: "./src/components" }, }, // 自定义模板目录,覆盖内置模板 templatesDir: "./.t3code/templates", // 生成文件后缀 fileExtensions: ["ts", "tsx"], });逐个说下重点:
modelsDir:你的领域模型放哪。我习惯把模型文件按业务域拆开,比如user.model.ts、order.model.ts,每个文件可以定义多个 model。generatedDir:t3code 默认把所有生成物放一块儿的兜底目录。如果你不想逐层指定输出位置,它会按层级子目录往里写。managedFileHeader:这个字段很关键。它会往每个生成文件的开头注入一行标记,类似// @generated by t3code。配合编辑器插件或 CI 检查,就能识别“这个文件是机器生成的,别手工改”。我见过团队因为这个没配置,导致生成文件被手工改出分叉,最后 diff 都不敢看的惨状,所以强烈建议保留。outputs:各层的覆盖路径。T3 工程里目录约定各家不同,有的用src/server,有的用server/,这里按实际路径填就行。templatesDir:自定义模板目录,后面第 6 节细讲。
配置完成后,跑一遍npx t3code check --config可以验证配置里填的路径是否存在、有没有权限写入。这一步别跳过,我见过有人路径写错,生成时文件散落到了src和server两个地方,排查了半天。
3. 从模型定义到全链路代码:一条业务功能的生成实战
3.1 定义领域模型:以“商品标签”为例
说再多不如跑一遍。我拿项目里一个真实的“商品标签”(ProductTag)模块当例子,完整演示 t3code 的工作流。
首先在src/models下新建一个product-tag.model.ts文件:
import { z } from "zod"; export const ProductTag = z.object({ id: z.string().uuid().describe("主键 ID"), name: z.string().min(1).max(20).describe("标签名称"), color: z.enum(["blue", "green", "orange", "red"]).default("blue"), sortOrder: z.number().int().min(0).default(0), isActive: z.boolean().default(true), createdAt: z.date().nullable(), updatedAt: z.date().nullable(), }); export const ProductTagCreateInput = ProductTag.omit({ id: true, createdAt: true, updatedAt: true, }); export const ProductTagUpdateInput = ProductTagCreateInput.partial();这里有个设计上的细节:t3code 直接复用 Zod 语法,不搞一套新的 DSL。好处很明显——团队不需要额外学习成本,Zod 的min、max、enum、default这些都是现有技能栈里的东西。describe()里的中文描述会被一并带到生成的注释、表单 label 和表格列头里,这一招在生成前端代码时特别省事。
3.2 生成 tRPC Router 与 React Query Hooks
模型文件写好后,运行:
npx t3code generate product-tag生成器会解析ProductTag相关的 Zod schema,然后批量产出文件。以 tRPC 层为例,它会在outputs.tRPC.routerDir下生成一个productTag.ts,大致长这样:
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { ProductTagInput } from "@/models/product-tag"; import { prisma } from "@/server/db"; export const productTagRouter = createTRPCRouter({ list: protectedProcedure .input(ProductTagListInput) .query(async ({ ctx }) => { return ctx.prisma.productTag.findMany({ orderBy: [{ sortOrder: "asc" }], }); }), create: protectedProcedure .input(ProductTagCreateInput) .mutation(async ({ ctx, input }) => { return ctx.prisma.productTag.create({ data: input }); }), update: protectedProcedure .input(ProductTagUpdateInput.extend({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const { id, ...data } = input; return ctx.prisma.productTag.update({ where: { id }, data }); }), delete: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { return ctx.prisma.productTag.delete({ where: { id } }); }), });同时它会把 router 自动注册进root.ts,新增一行productTag: productTagRouter。如果你用的是 NextAuth session 保护,生成器默认按protectedProcedure生成,未登录用户没有调用权限;某些公开接口可以改模型配置为publicProcedure。
React Query hooks 的生成也在这一步完成。它会为每个 tRPC 方法生成对应的 hook,比如:
export function useProductTagList() { const utils = api.useUtils(); return api.productTag.list.useQuery(undefined, { staleTime: 30_000, }); } export function useCreateProductTag() { const utils = api.useUtils(); return api.productTag.create.useMutation({ onSuccess: () => utils.productTag.list.invalidate(), }); }注意它带了一个默认的缓存失效策略:创建、更新、删除成功后会 invalidate 列表缓存。这个细节比我预想的贴心,因为实际开发中十个人有九个会漏写invalidate,导致新增数据不刷新。
3.3 生成 Prisma 模型、表单与表格
接下来是数据层和 UI 层。t3code 会在prisma/schema.prisma里追加对应的 model:
model ProductTag { id String @id @default(uuid()) name String @db.VarChar(20) color String @default("blue") sortOrder Int @default(0) isActive Boolean @default(true) createdAt DateTime? updatedAt DateTime? }注意@db.VarChar(20)是由 Zod 的max(20)推导出来的,Zod 层的约束和数据库层的约束保持一致,这是手工开发时最容易被忽略的一环。模型追加后你需要自己跑npx prisma migrate dev生成 migration,t3code 只改 schema 不替你执行迁移命令,这点设计合理,迁移这种操作理应保留在人的控制范围内。
表单和表格组件也会被生成到componentDir。生成的表单组件会集成 react-hook-form 和 zodResolver,校验规则直接来自模型定义;表格组件会渲染所有字段的列定义。之后你可以在组件基础上微调样式和布局,核心的校验逻辑、数据绑定、缓存刷新不用重写。
跑完这一步,一个模块的 CRUD 链路就齐了。我用计时器比过一次:手工模式从查旧代码到改完整个模块大约 45 分钟,用 t3code 从写模型到全部生成完大约是 8 分钟,其中 6 分钟是在纠结color枚举要不要加一个purple。
4. 生成物管理与团队协作:入库、模板与漂移检查
4.1 生成物要不要提交到 Git:我的结论是“要”
用代码生成器最常见的争论就是:生成的文件要不要提交进仓库。反对的人会说,生成物是冗余,攒个干净仓库直接忽略掉;支持的人会说,生成物进库之后,代码评审和 diff 才有抓手。
我在几个项目里尝试下来,结论是:T3 场景下生成物应该进 Git。原因有三:
第一,tRPC router、React Query hooks 这些文件是运行时直接导入的,它们和手写代码无缝混在一起,强行 ignore 会让 IDE 跳转失效,团队其他成员 checkout 后还得先跑一遍生成命令才能跑起来,门槛抬高了不少。
第二,生成物进库后,代码评审时能直观看到“模型改动对应哪些代码变化”,这对新人理解项目结构特别有帮助——他们看着生成的代码就能倒推出模型定义的含义。
第三,为实现“生成物可评审”,我要求生成器产出的文件风格与手写代码完全一致:统一的缩进、单引号、尾分号、import 排序。t3code 生成完会自动跑 Prettier,这算是最低限度的体面。
当然,提交的前提是约定好绝不手工修改生成文件。文件头有@generated by t3code标记,评审时看到指定范围外的改动会立刻打回。
4.2 团队模板约定:把特殊需求沉淀成标准
t3code 内置模板覆盖了标准的 CRUD,但真实业务里总有“不走寻常路”的场景。比如我们项目里的所有实体都必须有createdBy字段,用于记录创建人,并且删除一律用软删除(deletedAt置时间戳),不做物理删除。这些统一约定不该靠团队成员自觉遵守,而是应该写进模板里,成为生成的默认行为。
我们在.t3code/templates里自定义了 tRPC router 模板,把createdBy的赋值、软删除的 filter、以及findMany里默认排除deletedAt不为空的记录都内建进去。从那以后,新成员即使完全不熟悉这些约定,生成出来的代码也天然符合团队规范。模板目录结构类似这样:
.t3code/templates/ ├── router.hbs ├── react-query.hbs ├── form.hbs └── helpers/ └── format-label.ts模板的写法留到第 6 节细讲,这里先强调团队层面的两个约定:
- 模板变更必须走 Pull Request 评审,因为模板影响的是所有后续生成模块,一个变量写错就是大面积事故。
- 模板里禁止写死业务关键词,比如“订单”“用户”这类字眼,模板要保持领域无关性,业务语义全部来自模型定义和
describe()。
4.3 CI 里的漂移检查:t3code check
团队协作最大的隐患不是生成的时候出错,而是有人绕过生成器手工改了生成文件,或者改了模型定义但忘了重新生成。这两种情况都会造成“模型定义”和“实际代码”的漂移。
对付漂移,我在 CI 里加了一个检查步骤:
npx t3code check --drift它会对所有带@generated by t3code标记的文件做一次“先比对再生成”的校验:把模型定义重新生成一遍代码,和当前工作区文件做 diff,不一致就返回非零退出码,导致 CI 失败。这样“改模型忘生成”的提交会在流水线里被拦住,而不是上线前夜被队友发现。
实际配置到 GitHub Actions 里大概是这样的:
- name: Check generated code drift run: | pnpm install npx t3code check --drift跑完这个检查,我会再单独跑一次npx t3code generate --all并让 CI 自动提交回生成结果,这样即使有人忘了跑生成,合并后代码仓库也会自动收敛。用了这套机制之后,团队里“生成文件和模型不一致”的 bug 基本绝迹了。
5. 排错实录:我在使用 t3code 时踩过的四个坑
工具再好,用起来总得踩几个坑才踏实。下面四个问题是我实际遇到并且完整排查过的,每个都说说现象、根因和最终解法,希望能帮你跳过。
5.1 坑一:tRPC Router 重复注册导致类型爆炸
现象:第一次用t3code generate生成两个模块后,npm run dev报了一长串类型错误,错误信息指向 tRPC 的根路由文件,说某个 router 的泛型类型不匹配。
排查过程:我先是以为是 Prisma 模型没迁移,跑了一遍prisma generate,无效。又怀疑是 Zod schema 版本问题,折腾了半天。最后打开root.ts逐个对比,才发现生成器往root.ts里追加注册语句时,把productTagRouter注册了两遍——第一次运行时注册了一次,第二次生成另一个模块时它重新扫描了目录,把已有的注册行当成“新输出”又追加了一次。
根因:当时项目改造前手写过一部分 router,文件头没有@generated by t3code标记,导致生成器的注册器识别不到“这段是我生成的”,理所当然地再次追加。
解法:把root.ts恢复干净,然后给文件里 t3code 管理的注册区域加上标记注释。这类区域型管理是 t3code 内置的一种机制的标配,标记形如// start-managed和// end-managed。从那以后,每次生成前如果改动过root.ts,我会先跑一次npx t3code check --drift确认区域标记还在。教训总结一句话:手动改过生成文件又没保留区域标记,是生成器出各种诡异问题的头号来源。
5.2 坑二:Zod 枚举和 Prisma 枚举的顺序不一致
现象:生成的表单下拉框选项顺序和我们设计稿里要求的顺序不一致。模型里color枚举我是有意按“蓝、绿、橙、红”排列的,但生成的下拉框里“红”跑到了第一位。
排查过程:我给模型加了console.log想看看生成器解析出的枚举顺序,发现解析结果确实是我定义的顺序。那问题很可能出在 Prisma 层。打开schema.prisma一看,enum Color的定义顺序被生成器排成了按字母序,而表单组件是直接读取 tRPC 的枚举 schema 来渲染选项的——tRPC 的运行时会从 Prisma 客户端读取枚举,顺序自然变成了 Prisma 侧的字母序。
根因:t3code 内部有两套枚举表示,Zod 侧按模型定义顺序,Prisma 侧按字母序优化(因为数据库 migrate 文件更稳定),而生成的表单组件在运行时调度的枚举顺序是从 Prisma 侧来的,两者没对上。
解法:一是在模型定义里明确枚举值顺序,并在生成的组件里禁用“自动按字母序排序”,通过模型配置enumOrder: "preserve"解决;二是如果枚举顺序只影响展示,可以直接在表单组件的生成模板里写死一份选项顺序映射。两个方案里我选了第一个,因为它的约束在源头,不会因为后续手改组件而丢失。
5.3 坑三:Windows 环境下 glob 匹配路径分隔符
现象:团队里一位同事在 Windows 上跑t3code generate --all,生成器报“找不到匹配模型文件”,但同样的路径在 macOS 上一切正常。
排查过程:远程连到他的机器上看,模型文件明明在src/models下,文件权限也没问题。对比了两边跑的命令日志,发现路径打印出来不一样:macOS 打印的是src/models/product-tag.model.ts,Windows 打印的是src\models\product-tag.model.ts。
根因:t3code 内部扫模型文件用的 glob 库在 Windows 上返回反斜杠路径,而后续解析器按正斜杠匹配规则做处理,正反斜杠混在一起后匹配失败。这个问题的排查难点在于错误信息里的路径看起来“没什么不对”,但实际就是匹配不上。
解法:当时有两个选择,一是改代码把反斜杠统一转成斜杠,这也是我给上游提的修复建议;二是在配置文件里给 modelsDir 传相对路径,绕开绝对路径的反斜杠问题。我们团队先在配置里统一了路径写法做临时缓解,等新版本发布后升级解决。这里给个实用建议:如果团队里有 Windows 开发者,生成类工具的路径配置尽量统一写相对路径,能少踩很多坑。
5.4 坑四:React Query 的缓存 key 类型收窄问题
现象:生成的useProductTagListhook 在用data.map()遍历列表时,TypeScript 报错说data可能是undefined。我确认staleTime和路由都配置正确,请求也确实有返回,但类型就是过不了。
排查过程:我把生成的 hook 文件摊开看,发现useQuery的返回类型没问题,问题出在 t3code 生成 react-query 模板时,默认加了initialData相关的条件类型判断,而项目里 React Query 版本是 5。React Query 5 的useQuery返回类型里data默认是undefined联合类型,和 React Query 4 的行为不一致。
根因:内置模板是针对 React Query 4 写的,升级到 5 后泛型签名变了,生成代码里的条件类型推断失效,导致data类型收窄失败。这本质是版本兼容问题,不是业务代码问题。
解法:直接在自定义模板里更新 react-query 的 hook 写法,改成显式接收 tRPC 返回类型而非依赖条件类型推断。改完后重新生成一次,类型立刻正常。这件事给我的教训:使用生成工具时,工具版本的升级不是孤立事件,它依赖链上的 React Query、Next.js、TS 版本都要整体对齐,最好把版本矩阵写进团队的 README。
这四个坑的根因归结下来其实就四类:区域管理标记被破坏、模型与数据库枚举的语义不一致、跨平台路径差异、依赖版本升级导致模板失效。后两类属于环境的坑,前两类则和工具的使用规范强相关。我从第一个坑之后就给团队补了一份简短的规范:生成文件不过手改、模型枚举顺序要显式声明、CI 必跑漂移检查。
6. 自己写模板扩展 t3code:从一个审计日志需求说起
6.1 模板语法核心:别学新语言,记住四个元素
内置模板不够用时就得自己写,这也是 t3code 这类工具真正的分水岭——能不能沉淀出团队自己的生成体系。它的模板语法不是重新发明的语言,而是类似 Eta/Handlebars 的标签式写法,上手成本很低。核心只要掌握四样东西:
- 变量插值:
<%= it.modelName %>输出模型名,<%= it.fieldList %>输出字段列表 - 表达式:
<% if (it.useSoftDelete) { %>条件分支,<% for (const f of it.fields) { %>循环遍历 - 帮助函数:
t3code.helpers.camelCase(...)、t3code.helpers.pluralize(...),处理命名、单复数、首字母大小写 - 数据上下文:
it对象里装着解析后的模型信息,包括 modelName、fields、relations、configs 等
我们用一个实际需求来说明模板怎么用。项目要求:所有删除操作不真正删数据,而是置deletedAt时间戳,同时在audit_log表里记录操作人和操作类型。
6.2 一个自定义模板的完整示例
我们在.t3code/templates/router.hbs里维护自己的 tRPC router 模板,关键的remove方法是这样写的:
remove: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const now = new Date(); await ctx.prisma.<%= it.modelLower %>.update({ where: { id: input.id }, data: { deletedAt: now }, }); await ctx.prisma.auditLog.create({ data: { entity: "<%= it.modelName %>", entityId: input.id, action: "DELETE", operatorId: ctx.session.user.id, occurredAt: now, }, }); return { success: true, deletedAt: now }; }),同时所有列表查询的模板里统一追加一行where: { deletedAt: null },并且select字段列表自动排除deletedAt之外的软删除相关字段(展示层不需要)。这样一来,团队里所有模块的软删除行为完全一致,不会出现“这个模块删了但列表还有旧数据”的怪现象。
表单模板我们也改了。项目里所有模型都要求字段分组展示,如“基础信息”“高级设置”。我定义模型时用describe("group:基础信息")这样的约定标记分组,模板里读取字段描述的分组前缀,把字段按组渲染成多个表单 section。这个约定实现后,设计稿里的分组要求从“评审时叮嘱”变成了“生成自带”。
6.3 生命周期钩子与生成后的自动处理
模板之外,t3code 还支持在生成流程里挂两个钩子:beforeGenerate和afterGenerate。
beforeGenerate我用来做模型文件预检。比如检查模型里是否包含名为id的主键字段、是否包含createdAt/updatedAt,没有就直接报错终止生成,把问题暴露在生成之前。这比生成一个残缺模块然后运行时报错要便宜得多。
afterGenerate我用来做格式化与索引更新。生成完后自动执行 Prettier、ESLint 修复,另外把新增的 router 在根文件里的 import 顺序按字典序排好,保证提交出来的 diff 干净好看。我们甚至把 Prisma 的 migration 生成提示也挂在钩子里:生成完跑一次prisma format,输出变更摘要,引导开发者自己执行prisma migrate dev。
写模板的这一个月里,我的体会是:**模板不是一次性投资,它是团队规范的“可执行版本”。**手写规范文档贴 wiki 上没人细看,但模板写在代码里,每次生成都会强制执行。这段时间我们把团队里原先零散在 PR 评论里的各种要求逐条沉淀进了模板,新成员上手的速度明显加快,因为他们能看到一套前后一致、自带约束的代码生成体系。
最后再分享一个我个人的习惯:每次生成完新模块,我会先跑git diff看一遍生成物,再对照模型定义走一遍核心链路。这既是给生成器“验货”,也是让自己清楚工具生成的边界在哪里。生成器能替你省掉重复劳动,但对业务语义的理解和校验,该自己把关的还是不能省。如果你也在 T3 技术栈里被 CRUD 样板代码折磨过,不妨试着把 t3code 这类增量生成器接进团队的工作流里,从一个小模块开始验证,跑顺之后你会明显感觉到,那些曾经耗在机械替换上的时间,终于能花在真正有挑战性的业务问题上了。