news 2026/10/28 5:17:10

t3code:以数据模型为锚点的 T3 栈 CRUD 代码生成实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
t3code:以数据模型为锚点的 T3 栈 CRUD 代码生成实践

1. 我为什么自己折腾了一套 t3code

先说背景。干我们这行的,最近两年接的 TypeScript 项目,跑不掉一个组合:Next.js + tRPC + Prisma + Tailwind。这个组合网上叫 T3 Stack,社区模板 create-t3-app 一个命令拉下来,项目能跑,认证有,数据库有,路由有,看起来万事俱备。但一旦开始写真实业务,问题就全来了。

最烦的是什么?CRUD。真的,就那么点事:一个 Post 模型,要在 Prisma schema 里定义一张表,要在 zod 里写一套输入校验,要在 service 层写一堆 CRUD 函数,要在 tRPC router 里把每个函数包成 procedure,如果用了 React Query,前端还得再写一层 hook 封装。一个模型,六七个文件来回改。改完一两个模型还行,三四个模型开始头疼,到十几个模型的时候,你会发现一整周啥也没干,全在复制粘贴改字段名。

我也试过找现成的代码生成工具。但问题在于:要么太重,必须按它的目录规范来,和团队现有的代码风格对不上;要么太轻,只能生成接口骨架,zod 校验、service 层、router 层之间的类型引用还是要手写。更关键的是,很多工具生成完之后就没有后续了——你改了一次模型,它不会帮你把关联的 service、router 一起同步更新,你得全删了重新生成,或者自己再手动改一遍。

所以我才自己折腾了 t3code。一句话描述:它不是一个框架,而是一个以数据模型为唯一锚点的代码生成辅助工具,专门解决 T3 技术栈里 CRUD 代码的生成和同步问题。

这篇文章想分享的,不是那种"看我做得多牛"的自夸,而是记录我实际使用和踩坑的过程。如果你也是整天被重复 CRUD 折磨的 T3 栈用户,或者正在考虑给自己的团队搞一套类似的内部工具,应该能从里面找到点可复用的东西。

2. 核心思路:让"数据模型"成为唯一的锚点

很多脚手架工具的思路是"生成一个能跑的模板",用完一次就拜拜。t3code 反过来,它要求你先把数据模型定义清楚,然后所有分层代码都从模型推导出来。

2.1 单一事实来源是怎么起作用的

一个 Post 模型的完整业务链路,其实可以从模型本身推导出几乎所有信息:字段类型决定了 zod 校验的写法,是否可选决定了校验规则,是否主键决定了 updateById 这类方法的入参,字段名直接变成 tRPC procedure 的输入参数名。只要数据模型定义准确,service 层和 router 层的 80% 代码是可以机械生成的。

这里面有个关键认知:代码生成不是"拼字符串",而是"结构推导"。我在最初版本想简单了,以为搞个模板引擎,把模型字段填进字符串里就能用。结果生成出来的代码,类型是散装的,字段引用到处对不上,根本没法编译。后来改成以 zod schema 作为输入,先解析成结构化字段列表,再基于这份字段元数据去套模板,代码质量才真正可用。

以 Post 模型为例,下面是它的 zod schema 定义:

import { z } from 'zod' export const PostSchema = z.object({ id: z.string().uuid(), title: z.string().min(1).max(200), content: z.string().min(1), published: z.boolean().default(false), createdAt: z.date().optional(), }) export type Post = z.infer<typeof PostSchema> export const PostCreateInput = PostSchema.omit({ id: true, createdAt: true }) export type PostCreateInput = z.infer<typeof PostCreateInput>

这份 schema 里,每个字段都被 t3code 解析成结构化的元数据:

字段类型约束推导出的代码行为
idstringuuid主键,service 层会按 id 查询
titlestringmin(1), max(200)创建入参必填,生成 zod 校验直接透传
contentstringmin(1)同上
publishedbooleandefault(false)创建入参可选,编辑时保留默认值
createdAtz.date()optional不参与 create/update 入参,但参与返回类型

看一眼这张表你就明白:只要模型定义没有歧义,service 层方法签名、tRPC procedure 的 input/output 类型、前端的 hook 类型,全都能严丝合缝地对起来。

2.2 三层结构的分工逻辑

t3code 生成代码时会分开三个层面,目的是让每一层都能独立修改而不互相污染:

  • 校验层:直接复用你的 zod schema,生成PostCreateInputSchema这类类型,不写死规则,schema 变了它自然变。
  • 逻辑层:service 文件里每个函数对应一种业务操作,比如createPostService(input)、getPostById(id)、updatePost(id, input)。这一层会引用 repository 层,但 repository 的具体实现可以自己替换。
  • 路由层:tRPC router 文件,把 service 函数包成 procedure,加上输入校验。这里做的是"搬运工"工作,不带任何业务判断。

这种分层的直接好处是:如果你今天要把 Prisma 换成 Drizzle,或者想给某个操作加上缓存逻辑,不用动所有文件。路由层和校验层是纯类型依赖,逻辑层是你唯一需要手动改业务代码的地方。

2.3 手写和生成的差距到底在哪

我拿同一个 Post 模型做了个对照。手写一套标准的 service+router,大约需要 150 行代码,打字快也要七八分钟,而且这还不算来回翻文件的时间。用 t3code 生成,一个命令 2 秒内出来,代码风格统一,类型完整,不用人肉检查漏没漏字段。

但要说清楚,t3code 的目标从来不是"替代思考"。它生成的是机械部分,真正业务逻辑像"发布文章时同时发送通知"这种,放在 service 层里手动补充就好。生成代码只是骨架,它给了你一个可靠的基础,让你能把精力花在写真正的业务上,而不是操心字段映射写错了没。

3. 安装、初始化和最容易踩的配置细节

3.1 装起来很快,但有个环境要求先提一下

t3code 是一个命令行工具,要求 Node.js 版本 18 以上,推荐直接用 pnpm 管理。

pnpm add -D t3code pnpm t3code init

init命令会在项目根目录生成一个t3code.config.ts文件。它的初衷是让配置跟着项目走,所以用 TypeScript 格式写比较合理。如果你比较传统,它其实也支持t3code.config.js,差别只是有没有类型提示。

生成出来的配置大概长这样:

import { defineConfig } from 't3code' export default defineConfig({ schemaDir: './src/server/schema', outputDir: './src/server/generated', layers: ['service', 'router', 'api'], projectType: 'next-app', routerPrefix: 'post', watchMode: false, })

几个字段的含义直观:schemaDir说明 zod schema 文件放哪;outputDir是生成代码的输出目录;layers控制生成哪几层;routerPrefix决定 router 命名;watchMode决定要不要开启文件监听。

3.2 容易被忽略的两个配置坑

这个工具我用了好几周,大部分问题都出在配置而非生成逻辑上。

第一个坑:schemaDir 的路径粒度。如果你只有一个 schema 目录,那没问题。但真实项目里通常有多个业务域,比如user.schema.ts和post.schema.ts混在一起。t3code 默认按文件名为单位生成代码,所以一个 schema 文件对应一个 service、一个 router。建议一开始就按业务域拆文件,别把所有模型堆在一个 schema 里。堆在一起不是不能跑,但生成出来的文件会非常臃肿,而且只要改其中一个模型,整个大文件的产物都会变动,代码评审的时候看 diff 看得想哭。

第二个坑:outputDir 和项目的 tsconfig 关系。生成目录最好放在src/server/generated下,同时把它加进.gitignore?这里我犹豫了很久。最终我选择不 ignore,而是提交进仓库。原因后面第五章会详细说,这里先给结论:生成代码如果不上库,新同事拉完代码必须手动跑一次生成命令才能编译,而出于各种原因(比如 CI 环境没装全依赖),这一步经常漏。

另一个容易踩的位置是:配置里的watchMode。它不是默认开启的,有人以为 init 之后它会自动监听,结果改了 schema 半天没生成,还以为是工具坏了。实际上这个模式需要显式开启,因为它会占用一个终端进程。

3.3 生成器的幂等性:这决定了你敢不敢放心用

什么叫幂等性?同一份 schema,跑一次和跑一百次,生成结果必须完全一致。这个特性我在早期版本里没有太重视,导致有一次生成完代码 diff 出来七歪八扭。后来我做了两个约束,现在基本不会再出问题:

  • 生成前先清空目标目录中自己上次产出的文件(按约定后缀识别),再全量生成。避免旧的废弃文件留在目录里干扰编译。
  • 输出文件带固定头注释// GENERATED BY T3CODE, DO NOT EDIT。这个标记有两层意义:提醒你生成代码不该手动改;同时给清理逻辑一个识别依据,不至于误删你手写的文件。

如果你只是自己用,幂等性体会不明显。但一旦配合 CI 检查生成结果是否一致,它就是救命的东西。

4. 跑通一次完整生成:从空目录到可运行接口

光说配置没意思,我拿一个小项目走一遍完整流程。目标是:生成一个名为 Post 的业务模型,包含 service、tRPC router 和前端 api hook,然后直接接进 Next.js 应用里跑通。

4.1 先定义模型 schema

在src/server/schema/post.schema.ts里写:

import { z } from 'zod' export const PostSchema = z.object({ id: z.string().uuid(), title: z.string().min(1).max(200), content: z.string().min(1), published: z.boolean().default(false), createdAt: z.date().optional(), }) export const PostCreateInputSchema = PostSchema.omit({ id: true, createdAt: true }) export const PostUpdateInputSchema = PostSchema.omit({ id: true, createdAt: true }).partial() export type Post = z.infer<typeof PostSchema> export type PostCreateInput = z.infer<typeof PostCreateInputSchema> export type PostUpdateInput = z.infer<typeof PostUpdateInputSchema>

注意我显式写出了创建和更新的输入 schema。CreateInput比UpdateInput多一个要求:Create的必填项一个不能少,Update则用.partial()把字段全部设为可选项。这样 t3code 才能正确生成函数签名。

这里有个值得说明的设计选择:为什么我要在 schema 文件里同时定义输入类型和实体类型?因为 tRPC 的 procedure 需要 input 类型,而 service 返回的是 Post 实体。如果不区分,前端调用时就会把id、createdAt也当成入参传进来,运行起来数据会被忽略,类型上却没有任何报错,非常容易埋雷。

4.2 执行生成命令

模型定义完了,跑一下:

pnpm t3code generate

输出大概是这样的日志:

[info] Schema loaded: post.schema.ts [info] Generating service layer... done (2 files) [info] Generating router layer... done (1 file) [info] Generating api hooks... done (1 file) [info] Output written to src/server/generated

生成出来的目录结构大致如下:

src/server/generated/ post.service.ts post.router.ts post.hooks.ts

4.3 生成的代码长什么样

post.service.ts的核心部分长这样:

import { prisma } from '@server/db' import type { Post, PostCreateInput, PostUpdateInput } from '@schema/post.schema' export async function createPostService(input: PostCreateInput): Promise<Post> { return prisma.post.create({ data: input }) } export async function getPostByIdService(id: string): Promise<Post | null> { return prisma.post.findUnique({ where: { id } }) } export async function updatePostService(id: string, input: PostUpdateInput): Promise<Post> { return prisma.post.update({ where: { id }, data: input }) } export async function deletePostService(id: string): Promise<Post> { return prisma.post.delete({ where: { id } }) } export async function listPostsService(params: { page?: number; pageSize?: number }): Promise<Post[]> { return prisma.post.findMany({ skip: (params.page ?? 0) * (params.pageSize ?? 10), take: params.pageSize ?? 10, }) }

每个函数都是标准的 Prisma 调用,没有任何多余业务。这就是我前面说的"机械部分"。如果后续要加业务逻辑,在这个文件里改就行。

post.router.ts长这样:

import { createTRPCRouter, publicProcedure } from '@server/trpc' import { createPostService, getPostByIdService, updatePostService, deletePostService, listPostsService, } from './post.service' import { PostCreateInputSchema, PostUpdateInputSchema } from '@schema/post.schema' import { z } from 'zod' export const postRouter = createTRPCRouter({ create: publicProcedure.input(PostCreateInputSchema).mutation(async ({ input }) => createPostService(input)), getById: publicProcedure.input(z.object({ id: z.string().uuid() })).query(async ({ input }) => getPostByIdService(input.id)), update: publicProcedure.input(z.object({ id: z.string().uuid(), data: PostUpdateInputSchema })).mutation(async ({ input }) => updatePostService(input.id, input.data)), delete: publicProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ input }) => deletePostService(input.id)), list: publicProcedure.input(z.object({ page: z.number().optional(), pageSize: z.number().optional() })).query(async ({ input }) => listPostsService(input)), })

生成代码直接引用了我手写的 schema 文件,而不是复制一份类型,这保证了类型始终来自同一个源头。getById、update、delete三个 procedure 的输入格式略有差别,这也是生成器根据模型主键自动推导出来的。

4.4 把 router 挂进 Next.js 应用

生成产物不自动挂载,这一步还是需要手动做。在src/server/trpc.ts里把你生成的 router 合并进去:

import { postRouter } from './generated/post.router' export const appRouter = createTRPCRouter({ post: postRouter, })

挂载后,前端直接用 tRPC 客户端调用:

const post = trpc.post.getById.useQuery({ id: 'xxx' }) const createMutation = trpc.post.create.useMutation()

到这里,一个 Post 模型的完整链路已经通了:前端调 tRPC procedure,procedure 调用 service,service 操作 Prisma,类型全程闭合。整个过程除了挂 router 需要手动改一下,其他全是自动生成的。

5. monorepo 场景下的连环坑与排查链路

单项目跑通不是终点,我相信真正有追求的技术团队都会上 monorepo。但 monorepo 下 t3code 踩坑的概率成倍增长。下面是我实际遇到的三个问题,每个都折腾了不少时间,写出来让大家少走弯路。

5.1 跨包类型引用导致生成产物无法编译

我的 monorepo 结构用的是 pnpm workspace,包分成了packages/api(trpc router 和 service)、packages/db(prisma client)、packages/schema(共享 zod schema)。t3code 配置在packages/api里,schema 目录指向packages/schema/src,输出目录在packages/api/src/server/generated。

生成本身没问题,代码也正常出来了。但一编译,全是类型报错:

error TS2307: Cannot find module '@schema/post.schema'

排查链路是这样的:

  1. 我先确认生成产物路径下确实有文件,不是生成失败。
  2. 接着怀疑路径别名问题。检查packages/api/tsconfig.json,发现@schema/*的 paths 配置确实存在,而且指向../../schema/src/*,看起来没问题。
  3. 然后我切到packages/schema里,想确认它的 package.json exports 字段。问题就出在这里:我用的是 pnpm,它按模块解析,但packages/schema的 package.json 只写了"main": "./dist/index.js",没写typesVersions,而且根本没构建过。TS 在 monorepo 里解析跨包类型时,会先看 package.json 的 types 字段,找不到就直接报模块不存在,而不会去帮你找.ts源文件。

解决也不复杂。两个办法都行:

  • 在 schema 包的 package.json 里加上"types": "./src/index.ts",让 TS 能直接解析源码类型。
  • 或者给整个 monorepo 根配置一个 tsconfig reference,把 schema 包作为源码引用。

我的建议是走第二个:monorepo 很大时,直接引用源码比每次都要构建 schema 包更爽,因为改动 schema 后不用重跑 build 才能被其他包发现。

这个坑的根因,本质上是我把 t3code 的schema 输入和运行时依赖混在了一起。运行时依赖走 node_modules 解析规则,但编译期类型解析走 TS 的 project reference 规则,两个规则不一致。理解了这一点,排查方向就清晰了。

5.2 监听模式在 monorepo 里丢事件

第二个坑是 watch 模式。我在packages/api目录下跑pnpm t3code watch,监听着packages/schema/src下的文件。第一次改 schema,生成正常。第二次改,没反应。再改一次,又正常。没有任何报错,纯粹是事件丢了。

排查过程:

  1. 先看 t3code 的监听日志,没有记录任何文件变化事件。
  2. 我以为是 chokidar 的 glob 模式写错了,对照配置检查 watch 路径,没毛病。
  3. 后来翻 pnpm workspace 的 node_modules 结构时发现,schema 包是通过 workspace 协议链接到 api 包的。而 chokidar 监听的是物理路径,在 pnpm 的隔离 node_modules 里,packages/schema并不是一个真实目录,而是一个软链接。不同版本的 pnpm 对软链接的监听支持不一致,导致文件事件偶发丢失。

解决办法:在 t3code 配置里加一个选项,让 watch 模式监听的路径解析时先执行realpath,把软链接解析成物理路径再去监听。我最后在 schema 目录放了一个.watch文件,内容只有一行:

../../schema/src/**/*.ts

其实原理一样,核心思路就是别用软链接路径做监听。

这个坑提醒我:monorepo 里的路径解析问题和单项目完全不是一个纬度,凡是涉及文件监听、构建缓存的工具,都要先想清楚node_modules下的链接结构。

5.3 Prisma 与 zod 类型不一致:生成代码运行时才爆雷

第三个坑最隐蔽,运行时报错,类型检查全过。场景是这样的:我在 zod schema 里把published定义成z.boolean().default(false),但 Prisma 创建记录时,默认值是在数据库层面生成的,并不会被 Prisma client 回填到返回值里。所以当 service 层把这个值返回给前端时,published可能是undefined,而不是false。

前端拿到published: undefined后,渲染逻辑里判断if (post.published),判定成了 true,页面表现直接出错。

排查过程:

  1. 首先怀疑前端逻辑写错了,查了一遍没发现问题。
  2. 然后看 API 返回,发现published字段为undefined,而数据库里默认值其实是false。
  3. 最后才想到:zod 的default()只是校验层的默认值,根本不会写进数据库,自然也不可能被查询出来。Prisma 模型的@default(false)是数据库层面的行为,两者语义不同。

这个问题的解法是:在 zod schema 里,凡是 Prisma 模型里有@default的字段,应该标记为optional而不是default(value),并且生成 service 代码时,创建函数里不要给这字段赋值,让数据库自己填默认值。

published: z.boolean().optional().default(false)

注意这里的顺序:先optional()再.default(false),表示数据库中可能没有这个值,但读出来时如果为 undefined,可以默认成 false。这种语义和 Prisma 的@default(false)才对得上。

类型不一致这类问题,最怕的是一时半会看不出根因。我的排查建议是:遇到数据字段返回值和预期不符,先确认这字段是不是在多个层面都有"默认值"设定,然后按 数据库层 Prisma、校验层 zod、逻辑层 service 的顺序逐一排查。每个层面都测一下,基本就能定位。

6. 把这些东西交给团队,需要注意什么

自己一个人用工具,怎么折腾都行。一旦要把 t3code 作为团队规范推广,就必须提前定好几条规矩,否则很容易变成"生成代码一时爽,代码评审火葬场"。

6.1 自定义模板:不同团队有不同口味

t3code 内置的模板适合我这种习惯了 service/repository 分层的写法。但团队未必认这套。比如有人喜欢把业务逻辑直接写在 router 里,不单独抽 service;有人喜欢给每个 procedure 加rateLimit中间件;还有人喜欢用 class 而不是纯函数。

所以 t3code 提供了自定义模板能力:你在templateDir目录下放 .ejs 模板文件,就能覆盖内置模板。把那套模板维护好了,团队的代码风格就真正变成规范了,而不是嘴上说说的约定。

举一个实际例子,我们团队要求在 service 层统一加操作日志。内置模板生成的 service 函数肯定没有这个,于是我在自定义模板里加了一段:

// 生成后手动补充日志的标记位置 // @t3code:log-here

其实更优雅的方式是用 AST 在生成时动态插入,但那样模板会变得很复杂。现实中大多数团队能接受的方案是:生成代码 + 手写扩充分离。生成代码保证骨架和类型正确,手写部分负责业务细节。唯一要求是手写部分要集中在 service 层,别去动 router 和 hooks 层。

6.2 生成代码的评审策略

第一次把生成代码提交进 MR 时,评审同事的态度大概率是怀疑的:这 200 行代码是你手写的还是工具生成的?里面有没有藏坏味道?

我的建议是,在评审模板里写清楚三件事:

  1. 生成文件带固定头注释,评审时可以直接跳过,不用一行行看。
  2. 真正需要评审的只有三类东西:模型 schema 定义本身、自定义模板代码、对生成 service 的手写扩展逻辑。
  3. 如果生成代码有问题,不要生生成输出文件,而是去改模板或者查 bug。

这个流程运行两三个迭代之后,大家会慢慢习惯:看见// GENERATED BY T3CODE, DO NOT EDIT就跳过,把注意力放在真正需要人判断的地方。

6.3 在 CI 里加一道一致性检查

前面说过幂等性是基础,它最有价值的应用场景是 CI。我在 CI 流程里加了一步:

pnpm t3code generate --dry-run

--dry-run只生成到内存,然后和当前磁盘上的文件做对比。如果不一致,CI 直接失败,提示开发者在本地重新跑生成并提交。这样能堵住一个非常实际的漏洞:有人改了 schema,忘了重新生成代码,直接 push 上来,然后别的同事拉代码编译就是错的。

配合这步 CI 检查,团队里再也没出现过"schema 和数据代码不同步"的尴尬。

6.4 团队上手后的几个习惯

  • 新增字段:改 zod schema 后跑一次 generate,再在 service 手写扩展里补充字段相关的业务逻辑。
  • 新增模型:复制一个已有 schema 文件作为参考,改模型名和字段,然后 generate,挂载 router。
  • 删除模型:从 schema 目录删文件,跑一次 generate,再手动清理 router 注册入口。
  • 共有工具:pnpm t3code watch常驻终端,尤其是多人并行改 schema 的时候,一个全局监听比各自手动跑命令要稳得多。

用下来的整体感觉是:工具的价值不在于省了多少打字时间,而在于让团队几百个文件保持统一的风格和稳定的类型关系。CRUD 代码是最不应该出现创意的部分,模板化生成反而让代码质量的下限整体抬高了。

最后再分享一个小细节。watch 模式跑起来后,每次保存 schema 文件,t3code 自动生成完,我习惯顺手看一眼输出日志有没有 warning。早期版本有个 warning 是"字段存在重复命名",不影响生成,但会跳过某个字段的校验生成。有一次就是因为没看日志,前端传了那个字段进来,后端 service 里却拿不到值,排查了半小时。所以记住:生成日志不仅是进度提示,还是工具在告诉你它对你的模型哪里理解不了。看见 warning 别忽略,先搞懂它为什么存在。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 5:16:50

sEMG+触觉反馈融合的神经接口手套技术解析

1. 不是“又一个VR手套”&#xff0c;而是把触觉反馈塞进指尖的物理接口革命最近在几个硬件开发者群里&#xff0c;有人甩出一张图&#xff1a;一只黑色半指手套&#xff0c;掌心嵌着三颗哑光金属圆点&#xff0c;食指根部贴着一枚薄如蝉翼的柔性传感器——配文就一句&#xff…

作者头像 李华
网站建设 2026/10/28 5:16:08

t3code:一个支持增量更新的多语言代码生成框架实践

前一阵在团队内部搭微服务的时候&#xff0c;被一堆重复的 DTO、Mapper、校验规则搞得烦不胜烦。后来我抽空整理了一套内部小工具&#xff0c;代号就叫 t3code。用了一段时间之后&#xff0c;团队里好几个同事都过来问能不能开源、能不能给他们也装一份。所以今天这篇文章&…

作者头像 李华
网站建设 2026/10/28 5:14:26

Excel基础教程:先建表格思维,再做高效数据分析

我带过不少刚入职的同事学Excel&#xff0c;十个人里有九个开口就问“VLOOKUP怎么写”“数据透视表在哪个菜单”。每次我都先让他们把手头的表格发我看看&#xff0c;结果八成是一张横跨几十列、每行记录用合并单元格排版、表头还分了两三层的“大杂烩”。函数背得再熟&#xf…

作者头像 李华
网站建设 2026/10/28 5:14:03

Moto手机应用锁设置指南:入口、分级配置与失效排查

手机递给别人看一张照片&#xff0c;对方手指一滑就点进了聊天列表&#xff1b;孩子在旁边闹着要玩手机&#xff0c;你担心他乱点支付页面&#xff1b;手机送去检修&#xff0c;回来发现相册被人翻过——这些场景我在过去几年被问过太多次&#xff0c;而绝大多数人的第一反应是…

作者头像 李华