☰
t3code:以类型安全为核心的全栈代码生成器实战
2026/10/26 8:43:33 网站建设 项目流程

t3code 这个项目,起因其实特别朴素:我实在受不了在每次新起一个全栈项目时,都重复一遍“搭脚手架、配数据库、写接口类型、再手动对齐前后端参数”这种体力活。那段时间正好在复盘自己经手的几个中型项目,发现大量时间都浪费在“类型没对齐”引起的低级 bug 上。所以我开始动手写一个以类型安全为核心的代码生成器,也就是 t3code,它专门负责把一套包括前端请求层、后端路由层、数据库模型层在内的全栈骨架,通过命令行工具自动生成出来。这篇文章不打算做成产品说明书,而是从一个实际开发者角度,把 t3code 的核心设计、技术选型、实现细节、踩坑记录都摊开讲一讲,给想做类似脚手架、代码生成器,或者想理解全栈类型安全实践的人一点参考。

1. 项目背景与目标拆解

1.1 t3code 解决的到底是什么问题

先描述一个非常常见的开发场景:你和团队新起一个 Next.js 项目,后端用 NestJS 或者 Next.js API Routes,数据库用 Prisma,校验逻辑打算上 Zod。项目初始化本身很快,真正的心智负担在于三层之间的对齐。

前端调用接口时,请求参数是否和后端 DTO 一致?后端返回数据是否真的和前端声明的 interface 一致?数据库表结构变更之后,之前的接口类型是不是已经悄悄失效了?很多团队靠“约定”约束这个问题,靠 code review 检查,偶尔靠运行时的报错提醒。但这些都属于事后补救,真正能做到“写代码的时候就不容易错”的方案,还是依赖把类型定义作为唯一事实来源,再通过工具自动生成其它层的代码。

t3code 做的工作就是:你提供一份集中式 schema 描述(我用了 Zod schema 作为唯一事实来源),它自动生成 Prisma model、tRPC router、前端 API 调用函数和类型定义。生成的代码保证类型一致,并且带有完整的 TS 类型,确保你在编译期就能发现结构不匹配的问题。

1.2 为什么选择“类型安全优先”作为核心路线

我在做技术选型的时候,其实没有第一时间就想做全栈代码生成,中间也对比过其它思路。一种做法是手动维护多个目录,比如 postman 集合、openapi.yaml、前端 types.ts、后端 DTO 文件。这种做法的问题很直观:新增一个字段,可能要同时改五个地方,少改一处就会留下隐患。另一种做法是在运行时做数据校验,比如后端入口处统一校验,前端统一处理错误提示。这样可以兜底,但前端 UI 上的类型推导仍然是空的。

t3code 的中心思想是“用编译期工具代替运行时排查”。代码生成之后,前端直接拿到了精确到字段级别的响应类型,根本不存在“手动声明 interface 但实际数据不匹配”的问题。再加上 tRPC 天然的端到端类型推导,前端调用一个服务端方法,参数类型和返回类型都是自动推导出来的。这一套对我来说是收益最大的,因为运行时校验只能防住坏数据,而类型对齐能从源头防住“错误的调用方式”。

1.3 适合什么人参考这个项目

我最初设想 t3code 的服务对象是自己,后来发现身边不少同事也受这个问题困扰。它最适配的人群有三类。

第一类是自己维护多个全栈小项目的人,这类人不希望为每个项目重新配置一遍数据库模型和请求层,希望开箱即用。第二类是团队里正在推行“类型驱动开发”的工程师,他们需要一个可见、可控的生成流程,而不是靠自觉遵守约定。第三类是对代码生成器本身感兴趣的开发者,t3code 的实现思路、模板渲染方式、交互式命令行设计,都可以直接借鉴。

怎么判断你的场景适不适合用代码生成?我自己的经验是看“模板化程度”和“类型关联密度”。如果项目里实体多、层级深、字段多,而且前端后端都围绕同一批数据结构转,那代码生成几乎就是最优解。如果项目只是个小接口孤岛,两个文件之间没有强关联,那手工维护反而更简单。t3code 不是银弹,它是为了解决“中等复杂度以上的全栈项目”这种具体问题存在的。

2. 技术选型与整体架构

2.1 核心依赖与选型理由

t3code 是一个 Node.js 命令行工具,采用 TypeScript 编写。技术栈的核心是这几项:

组件选型理由
运行时Node.js 20+生态成熟,CLI 工具开发效率高
语言TypeScript与生成目标一致,便于复用类型逻辑
schema 定义Zod v3既有运行时校验,又能提取静态类型
模板引擎EJS语法简单,支持 include 和逻辑分支
代码生成自研 renderer + prettier保证生成内容统一格式化
CLI 交互commander + prompts参数解析和交互式问答都够用
数据库 ORMPrisma(生成目标)对 zod schema 的 model 映射很直接
API 层tRPC(生成目标)端到端类型推导效果好,返回值天然类型安全

我特别要解释一下为什么用 EJS 而不是更现代化的模板方案。代码生成器和普通页面模板不太一样,它需要处理大量“这个字段有默认值就渲染 default,没有就省略”的逻辑,EJS 的直接<% %>语法在这种场景下反而最直观。调试起来也容易,模板渲染报错可以直接定位到具体行号。相比之下,JSX 模板或者 AST 转换的方案,学习成本高,维护成本也不低。

2.2 t3code 顶层目录结构和数据流

t3code 项目本身的结构,我按“解析层 / 模型层 / 生成层 / 渲染层”四层来拆:

t3code ├── bin │ └── index.ts # CLI 入口 ├── src │ ├── commands │ │ ├── init.ts # 初始化项目 │ │ ├── generate.ts # 核心生成逻辑 │ │ └── add.ts # 追加新的 model │ ├── parser │ │ └── schema.parser.ts # 将 zod schema 解析为中间模型 │ ├── models │ │ └── entity.model.ts # Entity、Field、Relation 等结构体 │ ├── renderers │ │ ├── prisma.renderer.ts │ │ ├── trpc.renderer.ts │ │ └── client.renderer.ts │ └── templates │ ├── prisma │ ├── trpc │ └── client └── tests

数据流在设计上保持单向:用户定义 Zod schema → parser 读取并转换为中间模型 EntityModel → renderer 基于模板和中间模型生成字符串代码 → prettier 格式化后写入目标目录。这样设计的好处是:模板只管渲染,parser 只管解析,中间模型成为稳定契约。以后如果要支持另一个 schema 来源(比如 JSON Schema 或者手写 YAML),只需要新写一个 parser,renderer 完全不动。

2.3 中间模型为什么是 EntityModel

代码生成最怕的是“边解析边渲染”的耦合写法,那样做看起来省事,实际一旦模板复杂度上来,几乎没法测试和复用。我花了不少精力设计中间模型。

一个核心的 EntityModel 结构简化下来长这样:

interface EntityModel { name: string; tableName: string; fields: FieldModel[]; relations: RelationModel[]; } interface FieldModel { name: string; zodType: string; // z.string() / z.number() 等原始类型 isOptional: boolean; isNullable: boolean; hasDefault: boolean; defaultValue?: string; isUnique: boolean; isId: boolean; } interface RelationModel { type: 'one-to-one' | 'one-to-many' | 'many-to-many'; targetEntity: string; foreignKey: string; backRelationName: string; }

很多刚写代码生成器的人会犯一个错误:直接把用户输入的 schema 当成模型用。比如解析 zod 时,发现z.string().optional()就直接往模板里塞“string optional”这种描述。但等到要生成 Prisma model 时,你会发现 Prisma 的 optional 和 nullable 是不同概念,Zod 的 optional 也不完全等价于数据库字段的 optional。如果没有中间层做语义转换,模板里就会堆满各种条件判断,甚至出现“这个模板只适用于某个特定结构”的硬编码情况。

t3code 的 parser 做的就是把 zod 类型“翻译”成 EntityModel,这一步把z.string().email()提取成{ zodType: 'z.string().email()', constraints: { email: true } },再由 renderer 决定该约束在 Prisma 里怎么表达、在 tRPC 输入类型里怎么表达。语义统一之后,每种 renderer 只需要关注自身领域的映射规则。

3. 核心功能拆解与实操记录

3.1 init 命令:从零生成一个全栈项目

t3code 的init命令承担着“项目初始化”的重任。执行后它会做几件事:检查当前目录是否为空;生成基础配置文件(package.json、tsconfig、prisma schema 入口、env 示例);创建 src 目录结构;安装依赖。

我在设计交互流程时最关注的是减少决策成本。init不会问太多问题,只问三个:项目名、包管理器偏好、是否包含示例代码。其它全部采用默认值,等用户通过后续命令修改。这符合脚手架工具的使用预期,毕竟用户最想要的是“先把项目跑起来”,而不是一上来就被十几个配置问题淹没。

初始化完成后生成的核心配置文件大概是这样的:

t3code init my-app --pm pnpm
my-app ├── package.json ├── tsconfig.json ├── .env.example ├── prisma │ └── schema.prisma └── src ├── server │ └── trpc.ts └── client └── api.ts

跑完这一步,项目已经可以pnpm dev启动,这时候只有一个示例的 ping 路由,用来验证链路通不通。真正有用的东西还在后面。

3.2 用 Zod schema 定义实体并生成三端代码

这是 t3code 的核心使用流程。我在示例项目里定义了一个非常典型的场景:用户和文章。

import { z } from 'zod'; export const userSchema = z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(1), createdAt: z.date(), }); export const postSchema = z.object({ id: z.string().uuid(), title: z.string().min(1), content: z.string(), authorId: z.string().uuid(), published: z.boolean().default(false), tags: z.array(z.string()).default([]), });

在 schema 目录里记录下这两个对象之后,执行:

t3code generate

t3code 会自动完成以下操作:

首先,在 Prisma schema 中生成 model User 和 model Post,并且根据authorId识别出 user 到 post 的一对多关系;其次,生成 tRPC router,包含按 id 查询、列表查询、创建三种基础操作;最后,在前端生成api.ts的调用函数,直接导出getUserById、getPosts、createPost这些类型安全函数。

生成的 tRPC 路由简化后大致是这个样子:

import { router, publicProcedure } from '../trpc'; import { z } from 'zod'; import { prisma } from '../prisma'; export const userRouter = router({ getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(({ input }) => { return prisma.user.findUnique({ where: { id: input.id } }); }), });

对应前端生成的调用函数:

import { trpc } from '../client'; import type { User } from '../types'; export const getUserById = (id: string) => { return trpc.user.getById.query({ id }); }; export type { User };

这里需要强调一个细节:t3code 不会把 Prisma 的类型直接暴露给前端,而是通过 tRPC 的输出类型天然推导。你在前端 IDE 里输入getUserById(...),返回值类型就自动是 User 完整对象。这比“生成一堆 export interface”的方案更优雅,因为 tRPC 已经替你维护了类型的一致性。

3.3 add 命令:增量追加实体

项目进入中期后,通常会频繁添加新实体。t3code 的add命令专门处理这种增量场景,它的流程是:先扫描现有 schema 文件列表,询问需要关联哪些已有实体;再生成单独的 model 文件;最后更新 router 聚合入口。

增量生成最怕的是“重复跑生成,把已有代码覆盖掉”。我的设计原则是:t3code 只处理自己生成的那部分内容,凡是检测到目标文件不是由 t3code 生成的(没有对应的标记注释),就跳过写入并给出警告。这样既保证了生成可靠性,又不会破坏用户手动扩展的代码。

标记注释长这样:

// @t3code-generated export const userRouter = router({ ... });

简单但实用。你永远不会担心跑一次生成命令,把辛辛苦苦手写的自定义查询覆盖了。

4. 开发过程中的关键细节与踩坑

4.1 模板转义与 EJS 语法冲突

使用 EJS 作为代码生成模板会遇到一个很隐蔽的问题:生成的代码本身包含 JavaScript 模板字符串、泛型尖括号和正则表达式。EJS 的默认标签和模板字符串的${}混在一起时,非常容易被错误解析。

我在早期就被Type '${string}' is not assignable to type这种报错坑过好几回。查到最后,发现是模板里写着一个前端的模板字符串:

const url = `/user/${id}`;

EJS 解析器会尝试把${id}当作自己的插值语法处理,然后报错。解决方案有两种:第一种是把模板字符串拆开写,避免和 EJS 冲突:

const url = '/user/' + id;

第二种是使用 EJS 的escape函数,或者在模板里临时切换自定义标签。实际上最稳妥的做法是在模板里写一个辅助函数,统一处理字符串拼接。因为我生成的是 TypeScript 代码,完全可以把 URL 拼接转换成函数调用,从源头避开冲突。

经验之谈:代码生成器的模板,不要贪图“在模板里直接写目标语言里的一切表达”。越复杂的模板,越容易踩转义坑。宁可让辅助函数多一些,也要保证模板的渲染逻辑简单可预期。

4.2 Prisma 与 Zod 的默认值语义差异

这是我在整个开发过程中踩过最深的坑,也是最后悔没有提前做语义分析的地方。Zod 的z.date().default(() => new Date())和 Prisma 的@default(now())看起来语义相近,实际生成的代码不能直接划等号。

比如在 Zod 里,default的作用是“当输入值未定义时,给出一个默认值”。但在 Prisma 里,@default是数据库层面的默认值。如果两者同步生成,生成出的代码就可能出现一个尴尬情况:前端调用createPost时不传createdAt,Zod 会生成一个new Date(),然后这个值被传给数据库,数据库的@default(now())反而永远不会触发。

这个问题本身不会报错,但会让数据真实性和代码一致性产生隐患。t3code 的处理方式是:把 Zod 的 default 明确分为“仅生成端默认值”和“数据库默认值”两种语义,由用户用@dbDefault这种扩展标记显式指定。如果没有显式指定,生成 Prisma model 时就不会为这个字段写@default语句,避免两套默认值机制互相干扰。

4.3 关系识别的心智模型

刚开始实现关系识别时,我试图通过字段命名猜测关系,比如某个字段叫authorId,就默认指向 User 实体的 id。这种做法在小范围内很灵,但一旦遇到多字段关联、联合主键、非规范命名的情况,就漏洞百出。

后来我决定把关系声明也纳入 schema 定义,用 T3 的扩展方法增强 Zod:

userSchema.t3relation({ name: 'posts', target: 'post', type: 'one-to-many', foreignKey: 'authorId', });

这样关系信息不是通过“猜测”得到,而是由用户显式声明。虽然多了一点学习成本,但是可靠性完全不一样,生成的 Prisma relation 和 tRPC 查询函数才不会出现错连、漏连的情况。这也让我再次确信:代码生成器应该尽可能把隐式约定变成显式配置,省心才是长期价值。

5. 常见问题排查与性能优化

5.1 生成失败时的排查顺序表

我整理了一张当时在项目 issue 里反复回答的排查表,对新手尤其有用:

现象优先检查原因
生成后前端类型报错tRPC router 输出是否正常后端路由名或输出类型不匹配
Prisma client 类型缺失是否执行过 prisma generate生成代码依赖 db 类型
关系字段不存在schema 里是否显式声明 t3relationt3code 不自动猜关系
模板渲染报错目标代码里是否存在模板字符串EJS 对${}解析冲突
Zod validation 报错是否有 optional/default 混用运行时生成了 undefined
格式化后文件大改prettier 配置是否缺失生成代码内格式不统一

初学者最容易忽略的是第 5 条。Zod 的.optional()和.default()同时使用时,值可能是合法但不直观的。比如z.string().optional().default('')和z.string().default('')这两个行为不完全相同。t3code 里我统一建议用.default()表示“总是有值”,.optional()表示“允许显式传 undefined”。这个心智模型,比“optional 等于可以缺省”要精确得多。

5.2 生成器性能瓶颈分析

t3code 生成几百个文件的场景并不少见。这时主要瓶颈不在模板渲染上,而在两步:一是 EJS 模板里大量使用字符串拼接和条件判断,二是在完成后全量调用 prettier 格式化。

实际上 prettier 格式化在文件数量超过 300 个以后会明显变慢。我做过一次 Profile,发现格式化耗时占了整个生成流程的 75%。解决方案是只对最终落盘的文件做校验式格式化,并且通过prettier --check跳过内容完全未变的文件。另外,EJS 编译也有优化空间,t3code 在首次加载后会把编译好的模板函数缓存起来,后面的实体只需要复用函数,不用重复编译模板。

一个细节是:模板中对共同片段的提取,也能显著提升性能。例如 Prisma 的 scalar 类型映射表,数百个字段反复渲染String @db.VarChar(255)完全没必要走一遍复杂判断逻辑,预编译成一个常量映射表就好。

5.3 我用压力测试验证生成结果

为了验证生成的代码在大规模数据模型下仍然可靠,我构造了一个包含 50 个实体、500 个字段、80 个关联关系的测试模型。生成时间稳定在 1.8 秒左右(包含 prettier 格式化),生成的代码能够通过 TypeScript 严格模式编译,没有任何any类型泄漏。

这个压力测试的价值不在于展示跑得有多快,而在于证明生成器在成规模的模型面前没有“手忙脚乱”。小模型下很多设计问题会被掩盖,只有规模上来,模板的缓存设计、语义中间层的稳定性、类型导出的完备性这些细节才会被真正检验到。那次测试之后,我重构了 printer 的调度逻辑,把所有生成任务统一排队执行,并支持并行渲染,文件写入则保持串行避免 IO 竞争。最终生成时间从 4 秒降到了 1.8 秒。这个优化过程一点都不神秘,就是先找出瓶颈,再针对性处理。

6. 从 v1 到 v2 的迭代心得

6.1 用户反馈逼出来的三种能力

t3code 最开始是我个人工具,直到我把它开源并给朋友试用之后,才逐渐往通用方向发展。第一阶段收到最多的反馈是“生成的代码风格和团队不一致”。有人用单引号,有人用双引号;有人习惯末尾不加分号,有人必须加。这个问题的本质是代码生成器只生成了内容,没有适配特定团队的代码风格。v2 版本直接内置 prettier 配置继承机制,从宿主项目的 prettier 配置中读取偏好,生成代码后统一格式化,彻底解决了这个问题。

第二阶段反馈是“我想生成的不只是 CRUD,还有业务逻辑”。这属于我一开始刻意回避的领域。但后来的方案是允许用户在 schema 里写自定义 handler 名称,t3code 会为它生成空的 router 函数体,供用户手动填充。这平衡了机器生成与人工扩展的边界。

第三阶段反馈是“需要支持多数据库”。Prisma 本身已经支持多数据库,但 Zod 的字段映射到不同数据库方言时存在差异,比如z.string()在 PostgreSQL 里是text,在 MySQL 里可能需要取varchar(191)。后来我在 EntityModel 的 FieldModel 里增加了dbType字段,允许用户覆盖默认映射,最终达到了“数据库方言可适配”的能力。

6.2 我自己用 t3code 重写旧项目的真实感受

如果说以上都是理性层面的迭代,那我自己用它重写一个旧项目的经历,则更说明问题。那是一个做了两年的后台管理系统,原来类型分散在各处,改一个实体字段要小心翼翼搜索所有引用。我把核心 schema 提取出来,通过 t3code 重新生成底层服务之后,最大的感受是“改字段不再心虚了”。

以前改一个字段,要同时检查数据库迁移、后端 DTO、前端表单校验、详情页展示、列表列定义。现在只需要改一处,重新跑 generate,更新数据库迁移,其他层的类型和接口会跟着变。如果生成后编译报错,一定是我改漏了业务逻辑,而不是类型结构不一致。这种“错误发生得越早越好”的体验,让我对类型驱动的代码生成越来越有信心。

6.3 给同样想做代码生成器的人三条建议

第一条建议很实际:先定义你的中间模型,再写任何模板。中间模型是代码生成器里最重要的抽象,它决定了你能生成多少种目标框架。没有中间模型,代码生成器就是一个高级的“查找替换工具”,有中间模型,它才是一个产品。第二条建议是模板里减少逻辑,所有复杂逻辑尽量放入 renderer 层。这样模板保持简单,团队其他人理解和维护时不需要推导太多心智模型。第三条建议是设计好“生成标记”和“用户自定义区”的分界。让用户知道哪些文件可以放心让工具覆盖,哪些文件是人工定制区。t3code 通过文件头部注释清晰地划分这条边界,避免了多次生成后文件被覆盖的灾难。

最后想说的是,t3code 这套生成流程未必适合所有项目,但它背后“以类型为核心、以模板为外延、以生成为手段”的思路,我相信对做全栈工程的人都有一点参考价值。如果你正好也在为“类型不一致”“接口层重复劳动”这些问题头疼,不妨从自己的项目里挑一个小模块,用同样的思路做一个最小验证。很多时候,一次小实验比读十篇架构文章更能让你找到答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询