t3code 不是又一个.gitignore生成器也不是那种只帮你搭个空壳就撒手不管的脚手架。我第一次听到这个名字时还以为是某个 CLI 工具的版本号后来真正上手才发现它想做的事情是把 TypeScript 的类型安全从后端一路推到前端的契约层让接口文档、数据库模型、前端请求代码全部长在同一套类型定义上。这篇就聊聊我在实际项目里用它从零搭出一个全栈小应用的全过程包括踩过的坑和最后沉淀下来的一套习惯新手可以直接照抄老手可以拿去对比自己手头的工程化套路。1. 这是干什么的t3code 的核心设计思路1.1 从命名到定位它到底解决什么问题先说结论我把 t3code 理解为一套以类型为契约的全栈项目生成器。它不像 create-react-app 那样只负责前端也不像 Nest CLI 那样只盯着后端而是把两边的公共部分——数据模型、接口出入参、枚举常量、校验规则——集中到一份 schema 里然后通过代码生成器把这份 schema 翻译成前端 TS 类型、后端路由类型、数据库表结构定义和 API 客户端封装。这样做最直接的好处是改动一个字段重新跑一次生成命令前后端所有相关类型同步更新再也不用跑去翻接口文档确认某个字段到底叫createdAt还是create_time。我在自己的项目里试了一周之后最大的感受就是类型定义不再是写的时候顺便标注一下的附属品而是整个项目一开始就要定下来的骨架。它解决的痛点很明确传统前后端分离项目里类型在 API 边界是断的。后端改了字段名前端编译期根本发现不了只有运行的时候接口报错或者页面白屏才意识到出了问题。t3code 给这张容易断的网补上一层从数据模型到前端 UI 全链路可推导的类型约束让前端拿到的不只是某个 JSON而是一份完整的类型说明书。1.2 它和常见脚手架到底差在哪里拿日常的脚手架对比一下就很清楚了。普通的create-xxx-app给你一个能跑的模板里面 src 目录、配置文件、启动命令都齐了但模板和数据模型没有任何关系——你后续写的业务类型还是要自己定义、自己同步、自己维护。t3code 的思路是把模板和数据 schema合并在一起模板负责生成工程结构schema 负责生成业务类型两者缺一不可。我再打一个生活化的比方普通脚手架像给你一套毛坯房户型虽然有了但里面的强弱电点位、水管走向都得你自己重新设计t3code 更像给你一套 BIM 模型你改了一堵墙的位置相关的管线、窗户、承重数据会自动跟着变。对个人项目和中小团队来说这种改一处、全链路跟着改的体验非常值钱因为它把最容易出低级 bug 的环节变成了编译期可见的错误。1.3 适合谁来用不适合谁如果你满足下面任意一条我建议你认真考虑它你是一个人在写前后端全栈项目需要快速把想法落地成可运行的东西你的团队前后端分离但共享一套 schema 文档接口经常变动导致联调成本高你已经受够了手写 API 客户端类型和重复的 zod 校验代码。反过来如果你的项目只是纯前端展示页没有后端交互或者后端是遗留的 Java 系统不方便引入新的 schema 生成链路又或者你极度排斥代码生成这种多一层抽象的思路——那你暂时不需要用它硬上反而增加心智负担。我个人的判断是工具是为了减少重复劳动不是为了让你陷入更复杂的构建流程选择前先掂量一下自己的场景是否匹配。2. 核心模块拆解一次代码生成是怎么串起来的2.1 单一数据源一份 schema 管住所有类型t3code 的整个设计都围绕单一数据源展开。你在schema/index.ts里定义核心模型比如用户、文章、订单然后用类似 zod 的 schema 语法描述字段类型和校验规则。这里有一个非常关键的设计选择它没有发明一套全新的 DSL而是直接用 TypeScript zod理由很简单——新 DSL 需要额外学习成本而且不好做类型推导。我实际写的模型长这样import { z } from zod; export const User z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(2), role: z.enum([admin, editor, reader]), createdAt: z.date(), }); export type User z.infertypeof User;注意到没有这段代码既是运行时校验规则又是静态类型定义还是后续生成数据库迁移和 API 客户端的数据来源。t3code 在t3code.config.ts里读取这些 schema然后走一条完整的生成管线。这里我想强调一个容易被忽略的细节定义 schema 时不要只图省事字段名和约束条件一定要想清楚后再写因为后面所有地方都会引用它改名的成本会随着项目膨胀迅速放大。2.2 生成器链路从 schema 到前后端代码的完整流程代码生成不是魔法本质上是模板 数据的渲染过程。t3code 内部大致分四个阶段解析 schema 文件把所有模型、枚举、输入输出类型读进内存转成一个 AST抽象语法树。生成后端产物数据模型层代码、数据库 migration、路由处理器所需的类型定义、参数校验中间件用的 zod schema。生成前端产物API client封装好的请求函数、React Query 的 hook、页面里直接引用的业务类型。生成文档和联调产物一份 OpenAPI 风格的接口描述文件以及前后端共用的 TypeScript 类型包。整个链条里AST 的稳定性最关键。你写的 schema 千奇百怪但经过解析之后应该变成一棵结构稳定的语法树后面的模板才能可靠地遍历它。如果你自己也想写类似工具一定要把注意力放在这一层的设计上别一上来就忙着写输出模板。2.3 运行时依赖为什么能做到很小很多代码生成工具的通病是产物依赖一堆运行时库生成出来的代码看着挺全实际项目里体积感人。t3code 的做法比较克制生成的前端 client 只依赖zod和fetchReact Query 的 hook 需要你单独安装tanstack/react-query但生成的代码本身不绑定任何重度框架。也就是说你可以把它生成的 client 用在 Vue、Svelte 甚至原生 TS 项目里灵活性很高。我对比过这种做法的实际收益一个只有用户和文章两个模型的小项目生成的前端产物大概 20KB 左右gzip 之后不到 7KB对于页面性能几乎无感。如果你后续不想要 zod 了也可以配置成只生成 TS interface 的轻量模式但这样的话运行时校验就得靠后端二次保证我个人还是建议保留 zod 版本因为前端在拿到数据时多做一次校验往往能在 UI 层提前拦截很多脏数据。3. 实操从零开始用 t3code 搭一个全栈小项目3.1 环境准备与初始化先说环境要求我当前跑通的版本用的是 Node 18包管理器建议直接用 pnpm因为它对 monorepo 场景和依赖安装速度都更友好。如果你机器上还没有 pnpm一行命令装好npm install -g pnpm然后创建一个新项目pnpm create t3codelatest my-first-t3code cd my-first-t3code pnpm install这里你会看到生成出来的目录结构大致长这样apps/ web/ # 前端 React 应用 server/ # 后端 Hono 服务 packages/ api-client/ # 生成的 API 客户端 schema/ # 核心 schema 定义我第一次看到这个结构其实有点惊讶因为它默认就把前后端拆成两个应用而不是全部塞进一个src。这个安排挺合理前端和后端的依赖本质上是隔离的后续分别部署也更方便。初始化过程中它还会问你数据库选型我这边选的是 SQLite因为本地联调不需要额外起服务后面如果上生产再换 PostgreSQL 也基本不费劲。3.2 定义你的第一个模型一个用户加一篇文章进入packages/schema/src/index.ts把我前面提到过的 User 模型贴进去再顺手定义一个文章模型import { z } from zod; export const User z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(2), role: z.enum([admin, editor, reader]), createdAt: z.date(), }); export const Post z.object({ id: z.string().uuid(), title: z.string().min(1), content: z.string().min(10), published: z.boolean().default(false), authorId: z.string().uuid(), createdAt: z.date(), }); export type User z.infertypeof User; export type Post z.infertypeof Post;然后运行pnpm codegen它会自动完成几件事在packages/api-client/src下生成createUser、getPostById、listPosts这类请求函数在apps/server/src下生成数据库表的 seed 文件和路由类型定义在apps/web/src下生成对应的 React Query hook。这里我要特别提醒一个新手容易忽略的点published字段加了.default(false)生成的数据库 migration 也会带默认值但前端类型上这个字段仍然可能为空字符串或 null。也就是说类型层面它依然是可选字段你在渲染时必须处理undefined的情况不能因为它有默认值就在前端当必填用。这种细节不亲自跑一遍真的很难意识到。3.3 把后端 API 跑起来Hono 服务里的真实样子后端默认用的是 Hono一个非常轻量的 Web 框架。生成出来的apps/server/src/index.ts不是空壳它是带了实际 CRUD 路由的import { Hono } from hono; import { prisma } from ./db; import { Post, User } from my-first-t3code/schema; const app new Hono(); app.get(/users/:id, async (c) { const user await prisma.user.findUnique({ where: { id: c.req.param(id) }, }); if (!user) return c.json({ error: not found }, 404); return c.json(User.parse(user)); }); app.post(/posts, async (c) { const body await c.req.json(); const parsed Post.omit({ id: true, createdAt: true }).parse(body); const post await prisma.post.create({ data: parsed }); return c.json(post, 201); }); export default app;注意这里的User.parse(user)它在运行时做了一次校验确保从数据库出来的数据真的符合 schema。如果数据库里混入了脏数据这个 parse 会抛异常而不是让脏数据继续流到前端。实际开发中我遇到过几次因为历史上手写 SQL 导致数据库出现不符合预期的数据靠这一层 parse 拦下来之后排查速度明显变快。启动服务pnpm dev --filter server默认端口是 3001。你可以直接curl一下curl http://localhost:3001/users/test-id应该能拿到 404 的 JSON 响应说明服务已经正常在跑。3.4 前端页面与类型联调生成的 hook 直接能用前端启动pnpm dev --filter web在apps/web/src/App.tsx里可以像下面这样直接用生成的 hookimport { useListPosts, useCreatePost } from my-first-t3code/api-client; function App() { const { data: posts, isLoading } useListPosts(); const createPost useCreatePost(); if (isLoading) return divLoading.../div; return ( div {posts?.map((post) ( article key{post.id} h2{post.title}/h2 p{post.content}/p /article ))} button onClick{() createPost.mutate({ title: Hello, content: t3code works, }) } 创建文章 /button /div ); }从 schema 定义到页面渲染全程没有手写过任何 API 地址、请求函数或返回类型。我当时第一次跑完这个流程最直观的感受就是前端代码好像知道后端长什么样字段名敲错会直接在编辑器里标红。这种感觉会让人上瘾因为省掉的不仅仅是敲代码的时间更是一遍遍对着接口文档校对的精力。4. 常见问题与排查实录4.1 生成的类型和后端实际返回对不上这是我遇到过最多的问题。原因通常是后端在代码里手动修改了 response 结构比如加了分页包装app.get(/posts, async (c) { const posts await prisma.post.findMany(); return c.json({ items: posts, total: posts.length }); });但生成的 client 类型是按照返回 Post 数组推导的于是前端类型显示是数组实际运行拿到的却是一个对象。这种差异在编译期完全发现不了只有在运行时取不到posts.map才会暴露。我的排查思路分两步重新跑一次pnpm codegen确认产物没有因为手动改动而失效在项目和生成器的.json缓存里对比 response schema定位是不是手动修改了路由。重要教训生成出来的代码不要手改要改就改 schema 或路由模板。手改一时爽下次 codegen 一跑直接给你覆盖之前的改动全部白干。如果实在需要自定义返回结构应该在 schema 里用z.object({ items: z.array(Post), total: z.number() })这样的组合类型明确定义。4.2 热更新不生效或者代码被重复生成在 monorepo 模式下热更新偶尔抽风尤其是在修改 schema 之后前端页面不会自动刷新成新类型。这个问题的根源在于代码生成是一次性动作不是持续监听。你需要把 codegen 命令和 dev server 串起来。我试下来最稳的配置是在根目录的package.json里加一条scripts: { dev: concurrently \tsc --watch\ \node --run codegen next dev\ }或者更简单一点的方案写一个watch脚本用chokidar监听 schema 目录文件变化时自动执行pnpm codegen。但要注意别把 codegen 做成循环监听然后又触发 dev server 重启否则 CPU 会狂飙。我自己的习惯还是手动敲命令因为 schema 一般来说不会频繁变动一次改完跑一遍简单直接。4.3 多包仓库里的包互相找不到初始化项目用的是 pnpm workspace如果之前在别处用过 npm 或者 yarn残留的node_modules和 lock 文件可能会干扰依赖解析。现象是 import 一条路径时明明代码写在packages/api-client/src里但别的包就是解析不到。解决方案分四步删掉根目录所有node_modules建议用pnpm -r exec rm -rf node_modules清理干净删除pnpm-lock.yaml重新生成确认根目录pnpm-workspace.yaml里包含packages/*和apps/*两个路径重新pnpm install。另外需要注意如果你在 schema 包里的文件名是.ts而 API client 包引用的是它编译后的产物那需要在生成配置里把tsconfig的paths指准。我也遇到过一种更隐蔽的情况某个包把自己的main字段写成了已删除的dist/index.js导致依赖这个包的应用一直报空引用错误——所以 package.json 里的入口字段一定要核对好。4.4 环境变量的类型推导老是失败因为生成代码会读取.env里的数据库地址和 API 地址如果环境变量没配齐codegen 的时候会直接报错或生成一个奇怪的默认值。我建议在项目根目录用一个env.example维护所有需要的变量t3code 初始化时也会自动生成一份DATABASE_URLfile:./dev.db API_BASE_URLhttp://localhost:3001更进阶一点的做法是给env增加 zod 校验保证项目跑起来之前环境变量就是合法状态。我自己在多个项目里用过zod解析process.env方式是在一个env.ts文件里定义import { z } from zod; const Env z.object({ DATABASE_URL: z.string(), API_BASE_URL: z.string().url(), PORT: z.string().default(3001), }); export const env Env.parse(process.env);这样做的好处是如果你部署到服务器时忘记配某个变量启动瞬间就会得到一个明确的错误信息而不是运行到一半才暴露出怪异的行为。这个问题虽然和相关工具有点远但它恰恰是工程化项目里最常用的保险方法。5. 进阶技巧与我的真实体验5.1 自定义生成模板让代码更贴合团队风格t3code 的模板文件本身是开放的使用基于 Handlebars 的语法存放在node_modules/t3code/templates或者项目根目录的.t3code/templates。我记得它的模板目录大概长这样.t3code/ templates/ client-fetch.hbs hook-react-query.hbs server-route.hbs比如我想让前端生成的请求函数默认带上统一的错误上报逻辑只需要在client-fetch.hbs里加一段 capture 代码export async function {{camelCase name}}(params: {{camelCase name}}Params): Promise{{camelCase name}}Response { try { const res await fetch({{baseUrl}}{{path}}, ...); if (!res.ok) throw new Error(request failed); return await res.json(); } catch (e) { reportError(e); throw e; } }改完模板后重新pnpm codegen所有生成的请求函数都会自动带上错误上报。团队如果用 Sentry 或者其他监控平台这个钩子非常值得加。自定义模板的另一个用途是配置团队内部的代码风格比如函数名用 camelCase 还是 snake_case、文件使用单引号还是双引号、最后一行要不要写分号都能通过模板统一掉省去代码评审口舌之争。5.2 接进 CI/CD 之后的落地效果我在一个真实项目里把 t3code 接进了 CI 流程主要做了两件事第一在pre-commit的 hook 里检查 schema 是否一致如果发现 codegen 之后有文件变更就说明有人改了 schema 但忘了跑生成命令直接拦截提交。第二在 CI 的构建流水线第一步就执行pnpm codegen确保部署的时候用的永远是新鲜的产物。流水线环节大概是这样的build: steps: - run: pnpm install - run: pnpm codegen - run: pnpm build这个流程跑下来之后我发现代码评审里针对前端类型不对的评论几乎消失了。以前每次改接口都要在 PR 描述里附带接口文档链接提醒前端同事看字段变化现在只要 schema 在 PR 里更新了生成的代码变更自然出现在 diff 里评审人一眼就能看出变化影响到了哪些请求函数、哪些页面 hook沟通成本明显降低。5.3 关于是否值得自己造轮子的个人想法最后聊聊我自己的感受。t3code 这类生成式工程化工具网上评价两极分化很严重。反对的人觉得它引入了黑盒生成代码一旦有 bug排查起来非常痛苦支持的人觉得它把重复劳动压缩到了极致能腾出时间写真正的业务逻辑。我站在实操者的角度说句公道话工具的价值取决于团队对抽象层的信任和掌控能力。如果你把 t3code 当成一个不可修改的黑盒遇到问题只能去提 issue那你确实会焦虑但如果你花一个下午把它的模板和生成逻辑读明白把它当作一个可以随意调整的自动化助手那它带来的收益会远远大于学习成本。我第一次尝试改模板时也担心会不会把项目改崩但冷静下来之后发现生成器本质上就是模板和数据的朴素拼接只要 schema 定义正确不管模板怎么改都不会出现无法预期的运行时错误。后面我计划把它用在一个稍微大一点的团队项目里重点验证多模型关联场景下的生成稳定性和团队协作流程。到时候如果发现新的问题或者沉淀出新的经验再单独写一篇记录。