2026/10/27 23:14:02

t3code:基于TypeScript与tRPC的代码生成脚手架实践

t3code:基于TypeScript与tRPC的代码生成脚手架实践 1. 项目缘起与核心定位第一次看到“t3code”这个名字很多人会以为是某个新出的编程语言或者框架缩写。其实不是。t3code 是我在去年年底启动的一个个人项目核心目标只有一个把 T3 技术栈TypeScript tRPC Tailwind CSS的开发体验从“配置地狱”里彻底解放出来。说得再直白一点它是一套面向全栈 TypeScript 项目的代码生成与工程化脚手架方案解决的是“每次开新项目都要花两三天搭环境、配类型、调接口”这个老毛病。你可能会问市面上脚手架还少吗Create T3 App 不就是干这个的没错Create T3 App 确实解决了“从零到一”的问题但它解决的是“标准场景”。一旦你的项目需要接入已有的数据库、需要自定义鉴权逻辑、需要把前后端类型链路打通到极致你还是得手动改一大堆文件。t3code 的切入点就在这里——它不是替代 Create T3 App而是在它之上做了一层“代码资产化”的封装。你可以把它理解成一个“T3 项目的代码片段管理器 类型安全生成器 工程规范检查器”的三合一工具。这个项目适合谁三类人最受益。第一类是全栈 TypeScript 开发者尤其是那些频繁在多个项目之间切换、每次都要重新配 tRPC 路由和 Prisma schema 的人。第二类是中小团队的技术负责人需要统一团队内部的代码风格和接口定义方式但又不想引入太重的基础设施。第三类是对类型安全有执念的独立开发者希望从前端表单到后端数据库查询整条链路都有 TypeScript 编译器帮你兜底。我从 2023 年 11 月开始写第一行代码到 2024 年 2 月基本稳定中间经历了三次大的架构调整。踩过的坑包括但不限于tRPC 的 context 类型推断在 monorepo 下的丢失问题、Prisma 生成的类型和 Zod schema 之间的循环依赖、Tailwind 的 JIT 编译在大型项目中的内存泄漏。这些经验后面会详细展开。现在先说说整体设计思路为什么我选择“代码生成”而不是“运行时封装”这条路。2. 整体架构设计与技术选型逻辑2.1 为什么是代码生成而不是运行时库这是 t3code 最核心的架构决策。我一开始也想过做一个运行时库通过高阶函数和装饰器来简化 tRPC 的调用。但实测下来运行时封装有三个致命问题。第一类型推断会变弱。TypeScript 对高阶函数的类型推断能力有限尤其是涉及泛型和条件类型的时候很容易退化成any。第二调试成本高。运行时库的错误堆栈会多出好几层定位问题很麻烦。第三性能开销。虽然 tRPC 本身很轻但再加一层代理在大型项目里累积起来也不可忽视。代码生成的好处是“一次生成永久受益”。生成的代码就是普通的 TypeScript 文件你可以直接读、直接改、直接调试。类型是编译器直接推断的没有任何中间层。而且生成的代码可以纳入版本控制团队 review 的时候一目了然。坏处是“生成规则”本身需要维护如果项目结构变了生成器也得跟着改。但考虑到 T3 项目的结构相对稳定这个维护成本是值得的。具体来说t3code 的生成器基于Plop.js做模板渲染用ts-morph做 AST 级别的代码分析和修改。Plop 负责“从模板创建新文件”ts-morph 负责“在已有文件里插入新代码”。两者结合既能处理全新模块的生成也能在现有路由或 schema 里追加内容。2.2 目录结构与模块划分t3code 本身的代码库分成四个核心模块每个模块职责单一方便单独测试和替换。generators/存放所有 Plop 模板和生成脚本。每个生成器对应一种代码资产比如router、procedure、model、form、page。analyzers/基于 ts-morph 的代码分析器。负责读取现有的 tRPC 路由树、Prisma schema、Zod schema提取出可复用的类型和结构信息。cli/命令行入口。用Commander.js做参数解析支持交互式问答和直接传参两种模式。config/默认配置和用户配置的合并逻辑。配置文件是t3code.config.ts放在项目根目录。这种模块划分的好处是如果你只想用生成器可以单独引入generators模块如果你想自己写一套生成逻辑也可以复用analyzers里的分析能力。我在实际使用中发现最常用的组合是“先分析 Prisma schema再根据分析结果生成 tRPC 路由和 Zod schema”这条链路能省掉大量重复劳动。2.3 与 Create T3 App 的兼容策略t3code 不是要取代 Create T3 App而是要和它无缝配合。我的做法是t3code 的初始化命令t3code init会先检测当前目录是否是一个 Create T3 App 项目。检测依据是package.json里是否包含trpc/server、prisma/client、tailwindcss这三个依赖以及是否存在src/server/api/trpc.ts这个文件。如果检测通过t3code 会读取现有的trpc.ts和root.ts分析出当前的路由结构然后在此基础上生成新代码。如果检测不通过t3code 会提示你先用 Create T3 App 初始化或者选择“独立模式”从头搭建。这种兼容策略的关键在于不破坏现有代码。t3code 生成的所有代码都放在src/server/api/routers/和src/server/api/schemas/目录下不会覆盖trpc.ts和root.ts里的现有内容只会追加新的 import 和路由注册。实测下来在一个已有的 Create T3 App 项目里运行t3code generate router user它会在routers/下创建user.ts然后在root.ts里自动加上user: userRouter这一行。整个过程不需要手动改任何现有文件。3. 核心生成器实现细节与实操要点3.1 Prisma 模型分析器的工作机制t3code 最核心的能力之一是“从 Prisma schema 自动生成 tRPC 路由和 Zod schema”。这个能力的起点是analyzers/prisma-analyzer.ts。它的工作流程分三步。第一步读取prisma/schema.prisma文件用prisma/internals包里的getDMMF函数把 schema 解析成 DMMFData Model Meta Format对象。DMMF 是 Prisma 内部使用的结构化描述包含了所有模型、字段、类型、关系、枚举的完整信息。相比自己写正则解析 schema 文件用 DMMF 更可靠因为 Prisma 官方保证 DMMF 的结构稳定性。第二步遍历 DMMF 里的每个模型提取出字段名、字段类型、是否可选、是否有默认值、是否是关系字段。对于关系字段还要区分是一对一、一对多还是多对多。这些信息决定了生成的 Zod schema 里哪些字段是必填、哪些是可选、哪些需要嵌套。第三步把提取出的信息转换成 t3code 内部的中间表示IR然后交给生成器去渲染模板。中间表示的设计很关键它需要足够抽象能同时支持 Prisma 和未来的其他 ORM又要足够具体能保留 Prisma 特有的语义比如default(autoincrement())和updatedAt。这里有一个实操中容易忽略的细节Prisma 的DateTime类型在 Zod 里应该映射成z.date()还是z.string().datetime()。我的选择是后者。原因是 tRPC 在序列化 Date 对象时默认会转成 ISO 字符串前端接收到的其实是字符串。如果 Zod schema 用z.date()前端解析时还得手动转换很麻烦。用z.string().datetime()则前后端一致减少心智负担。这个选择在 t3code 的配置文件里可以通过dateTimeFormat选项切换默认是string。3.2 tRPC 路由模板的设计与变量注入t3code 的 tRPC 路由模板放在generators/templates/router.hbs用的是 Handlebars 语法。模板里定义了路由的基本结构import 语句、输入 schema 引用、procedure 定义、以及导出语句。变量注入通过 Plop 的actions配置完成每个变量都来自分析器的输出或用户的交互输入。一个典型的生成路由长这样import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; import { userCreateSchema, userUpdateSchema, userQuerySchema } from ../schemas/user; export const userRouter createTRPCRouter({ create: protectedProcedure .input(userCreateSchema) .mutation(async ({ ctx, input }) { return ctx.db.user.create({ data: input }); }), update: protectedProcedure .input(userUpdateSchema) .mutation(async ({ ctx, input }) { const { id, ...data } input; return ctx.db.user.update({ where: { id }, data }); }), list: protectedProcedure .input(userQuerySchema) .query(async ({ ctx, input }) { return ctx.db.user.findMany({ where: input }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.user.delete({ where: { id: input.id } }); }), });这个模板里userCreateSchema、userUpdateSchema、userQuerySchema都是从 Prisma 模型自动生成的 Zod schema。protectedProcedure和publicProcedure是从现有的trpc.ts里导入的t3code 不会重新定义它们。ctx.db是 Prisma client 的实例也是从现有的 context 里来的。实操中需要注意生成的 procedure 名称和输入 schema 的命名要保持一致。t3code 默认用模型名 操作名的驼峰形式比如userCreateSchema。如果你团队有别的命名规范可以在t3code.config.ts里改namingConvention选项。我试过改成snake_case生成出来的就是user_create_schema但 TypeScript 社区更习惯驼峰所以默认还是驼峰。3.3 Zod Schema 生成器的类型映射规则Zod schema 生成器是 t3code 里最复杂的部分因为 Prisma 类型到 Zod 类型的映射不是一一对应的。下面这张表是我在实际项目中总结出来的映射规则覆盖了 95% 以上的常见场景。Prisma 类型Zod 类型备注Stringz.string()如果字段有db.VarChar(255)可以加.max(255)Intz.number().int()默认加.int()避免浮点数传入Floatz.number()不加.int()Decimalz.number()Prisma 的 Decimal 在 JSON 里是字符串但前端通常当数字用Booleanz.boolean()无特殊处理DateTimez.string().datetime()默认用字符串可配置成z.date()Jsonz.any()或者用z.record(z.any())看具体使用场景Enumz.nativeEnum(EnumName)需要从 Prisma client 导入枚举Bytesz.instanceof(Buffer)仅服务端使用前端场景很少可选字段加.optional()对应 Prisma 的?标记有默认值加.default(value)对应 Prisma 的default关系字段不生成关系字段由单独的关联 schema 处理这张表里的每一行都是踩过坑之后定下来的。比如Decimal类型我一开始映射成z.string()结果前端做数值计算时还得手动parseFloat很别扭。后来改成z.number()虽然精度上有一点点风险但对于大多数业务场景足够了。如果确实需要高精度可以在配置里开启decimalAsString选项。还有一个容易忽略的点Prisma 的updatedAt字段不应该出现在创建 schema 里。因为创建时这个字段由数据库自动填充前端传了也没用。t3code 的分析器会识别updatedAt和default(autoincrement())这类“数据库自动生成”的字段在生成createSchema时自动排除它们但在updateSchema里保留为可选。4. 完整实操流程从零生成一个用户管理模块4.1 环境准备与初始化假设你已经有一个 Create T3 App 项目目录结构是标准的src/server/api/trpc.ts和src/server/api/root.ts。首先安装 t3codenpm install -D t3code # 或者 pnpm add -D t3code然后在package.json的scripts里加一行{ scripts: { t3code: t3code } }接着运行初始化命令pnpm t3code init这个命令会做三件事。第一检测项目类型确认是 Create T3 App 项目。第二在根目录生成t3code.config.ts配置文件里面包含默认的生成路径、命名规范、类型映射选项。第三在src/server/api/下创建schemas/目录用于存放生成的 Zod schema。初始化完成后你会看到终端输出类似这样的信息[✓] 检测到 Create T3 App 项目 [✓] 配置文件已生成: t3code.config.ts [✓] Schema 目录已创建: src/server/api/schemas [!] 下一步: 运行 pnpm t3code generate router 模型名 生成路由这里有一个实操心得如果你的项目用的是 monorepo 结构t3code.config.ts里的schemaPath和routerPath需要指向正确的子包路径。我一开始没注意生成的文件跑到了根目录的src下而不是packages/api/src下。后来在配置里加了basePath: packages/api才解决。4.2 从 Prisma 模型生成 Zod Schema假设你的prisma/schema.prisma里已经有一个User模型model User { id String id default(cuid()) email String unique name String? role Role default(USER) createdAt DateTime default(now()) updatedAt DateTime updatedAt } enum Role { USER ADMIN }运行以下命令生成 Zod schemapnpm t3code generate schema usert3code 会读取 Prisma schema找到User模型然后生成src/server/api/schemas/user.ts文件。生成的内容大致如下import { z } from zod; import { Role } from prisma/client; export const userCreateSchema z.object({ email: z.string().email(), name: z.string().optional(), role: z.nativeEnum(Role).default(Role.USER), }); export const userUpdateSchema z.object({ id: z.string().cuid(), email: z.string().email().optional(), name: z.string().optional(), role: z.nativeEnum(Role).optional(), }); export const userQuerySchema z.object({ email: z.string().email().optional(), name: z.string().optional(), role: z.nativeEnum(Role).optional(), }); export type UserCreateInput z.infertypeof userCreateSchema; export type UserUpdateInput z.infertypeof userUpdateSchema; export type UserQueryInput z.infertypeof userQuerySchema;注意几个细节。第一id字段没有出现在createSchema里因为它是default(cuid())自动生成的。第二createdAt和updatedAt也没有出现同样是自动生成的。第三email字段加了.email()校验因为 Prisma 里是String类型但字段名是emailt3code 的分析器会根据字段名做智能推断。这个推断规则可以在配置里关掉但实测下来大部分时候是准确的。4.3 生成 tRPC 路由并注册到根路由Schema 生成之后接着生成路由pnpm t3code generate router user这个命令会做两件事。第一在src/server/api/routers/user.ts下生成路由文件内容就是前面展示的那个模板。第二用 ts-morph 修改src/server/api/root.ts自动加上 import 和路由注册。修改后的root.ts大致如下import { createTRPCRouter } from ./trpc; import { userRouter } from ./routers/user; export const appRouter createTRPCRouter({ user: userRouter, }); export type AppRouter typeof appRouter;这里的关键是ts-morph 的 AST 修改能力。它不会简单地字符串替换而是解析root.ts的语法树找到createTRPCRouter的调用表达式然后在对象字面量里插入新的属性。这样做的好处是即使root.ts里有注释、有换行、有别的路由插入位置和格式都能保持正确。我试过在root.ts里手动加了一堆注释ts-morph 依然能准确插入不会破坏原有格式。4.4 前端调用与类型安全验证路由生成后前端就可以直接调用了。在src/app/下的任意客户端组件里use client; import { api } from ~/trpc/react; export function UserList() { const { data: users, isLoading } api.user.list.useQuery({ role: USER }); if (isLoading) return div加载中.../div; return ( ul {users?.map((user) ( li key{user.id}{user.email}/li ))} /ul ); }注意api.user.list.useQuery里的参数类型。因为userQuerySchema里定义了role是z.nativeEnum(Role).optional()所以 TypeScript 会自动提示你可以传role而且值必须是USER或ADMIN。如果你传了一个不存在的字段或者传了错误的类型编译器会直接报错。这就是 t3code 追求的“类型安全链路”——从 Prisma schema 到 Zod schema再到 tRPC 路由最后到前端调用整条链路都有 TypeScript 编译器兜底。5. 常见问题排查与避坑经验实录5.1 类型推断丢失的三种典型场景场景一monorepo 下 tRPC 的 AppRouter 类型找不到。这个问题通常是因为root.ts的导出路径和前端引用的路径不一致。t3code 生成的root.ts默认导出AppRouter前端通过~/trpc/react里的api对象访问。如果 monorepo 的tsconfig.json里没有正确配置pathsTypeScript 就找不到AppRouter的类型定义。解决办法是在tsconfig.json里加上{ compilerOptions: { paths: { ~/server/api/root: [packages/api/src/root] } } }场景二Zod schema 里的z.nativeEnum导致循环依赖。如果 Prisma 的枚举定义在prisma/client里而 Zod schema 又引用了这个枚举在某些打包工具比如 esbuild下会出现循环依赖。t3code 的解决办法是在生成 schema 时把枚举值直接内联成字符串字面量联合类型而不是引用prisma/client。比如z.nativeEnum(Role)会变成z.enum([USER, ADMIN])。这样虽然失去了枚举的单一来源但避免了循环依赖而且类型依然安全。场景三tRPC 的useQuery返回类型是any。这通常是因为root.ts里的appRouter没有正确导出类型或者前端引用的api对象不是从createTRPCReactAppRouter()创建的。检查src/trpc/react.tsx里的泛型参数是否正确传入了AppRouter。t3code 在初始化时会检查这个文件如果发现泛型缺失会提示你手动修复。5.2 生成代码与现有代码冲突的处理t3code 的生成策略是“不覆盖只追加”。但如果目标文件已经存在比如routers/user.ts已经手动创建过了t3code 会怎么做默认行为是跳过并提示不会覆盖。你可以加--force参数强制覆盖但我不建议这么做因为手动改过的代码可能包含业务逻辑覆盖了就没了。更好的做法是在生成之前先运行pnpm t3code analyze它会列出当前项目里已有的路由和 schema告诉你哪些模型已经生成过了。如果确实需要重新生成可以先手动备份或者用--dry-run参数预览生成结果确认无误后再实际写入。5.3 性能优化大型项目下的生成速度t3code 在中小型项目里生成速度很快通常一两秒就完成了。但在大型项目里如果 Prisma schema 有几十个模型ts-morph 的 AST 分析会变慢。我实测过一个有 45 个模型的项目完整生成所有路由和 schema 花了大约 18 秒。优化手段有三个。第一按需生成。不要一次性生成所有模型而是用--model参数指定单个模型。比如pnpm t3code generate router user只处理User模型速度会快很多。第二缓存 DMMF 解析结果。t3code 在.t3code/cache/目录下缓存了 DMMF 的解析结果如果schema.prisma没有变化下次生成时会直接读缓存跳过解析步骤。这个缓存默认开启可以通过配置关闭。第三并行生成。如果确实需要批量生成可以用--parallel参数t3code 会启动多个 worker 并行处理不同的模型。但要注意并行写入root.ts时可能会有竞争条件t3code 内部用了文件锁来避免这个问题但实测下来并行写入的稳定性不如串行所以默认还是串行。5.4 常见问题速查表问题现象可能原因解决办法生成的路由没有出现在root.ts里ts-morph 解析失败检查root.ts是否有语法错误确保createTRPCRouter调用存在Zod schema 里缺少某个字段Prisma 字段有updatedAt或default这是预期行为自动生成字段不会出现在 create schema 里前端调用时类型提示不出现AppRouter类型未正确导出检查root.ts的export type AppRouter是否存在生成速度很慢DMMF 缓存未命中检查.t3code/cache/目录是否存在或者手动运行t3code cache clear枚举类型报错循环依赖在配置里开启inlineEnum: true把枚举内联成字符串联合生成的 schema 里DateTime是字符串默认配置如果希望是 Date 对象在配置里设置dateTimeFormat: date6. 扩展思路与个人实践体会t3code 目前只覆盖了 Prisma tRPC Zod 这条链路但它的架构是开放的。analyzers/目录下的分析器接口是通用的你可以自己实现一个drizzle-analyzer.ts来支持 Drizzle ORM或者实现一个graphql-analyzer.ts来从 GraphQL schema 生成 tRPC 路由。生成器模板也是 Handlebars 文件改起来很直观。我在实际项目里还加了一个自定义生成器用来从 Zod schema 生成 React Hook Form 的表单配置。这个生成器不在 t3code 的核心仓库里而是作为一个插件放在项目的t3code/plugins/目录下。t3code 在初始化时会自动加载这个目录下的插件所以扩展起来很方便。踩过几次坑之后我最大的体会是代码生成工具的价值不在于“生成”本身而在于“约束”。当你团队里所有人都用同一套生成器来创建路由和 schema 时代码风格自然就统一了接口定义方式也一致了review 成本大幅下降。这比写一堆 ESLint 规则和文档要有效得多。另一个体会是生成器的模板一定要保持简单。我一开始想把所有可能的业务逻辑都塞进模板里结果模板变得极其复杂改一个地方就牵一发而动全身。后来我把模板拆成“基础模板 可选片段”按需组合维护成本才降下来。如果你也在维护多个 T3 项目或者团队里有人总是忘记写 Zod 校验可以试试 t3code 这套思路。不一定要用我的实现但“把重复的代码结构固化下来用生成器代替手写”这个方向我觉得是值得投入的。