1. 从“t3code”这个标题说起:它到底是什么
第一次看到“t3code”这个词,很多人会一头雾水。它不像“Vue”“React”那样一眼能看出是前端框架,也不像“Docker”“K8s”那样自带运维属性。我最初接触到这个词,是在一个技术社群里,有人发了一句“t3code 这套东西跑起来是真舒服”,底下跟了一堆“+1”。当时我的第一反应是:这又是什么新出的脚手架?还是某个内部工具流出的代号?
后来花了两天时间,把能找到的资料翻了个遍,又自己动手搭了一套环境跑通,才算把它的轮廓摸清楚。简单来说,t3code 是一套围绕“类型安全”构建的全栈开发范式与工具链组合,它的核心思路是把 TypeScript 的类型系统从“写完代码再补类型”变成“类型先行、贯穿始终”。它不是一个单一的库,也不是一个官方发布的标准,而是社区在实践中沉淀出来的一套约定俗成的技术选型方案,通常包含类型安全的 API 层、端到端类型推导、以及一套约定优于配置的项目结构。
它能解决什么问题?最直接的痛点是:前后端联调时,接口字段对不上、类型定义各写各的、改了一个字段忘了同步另一边,导致运行时才报错。t3code 的思路是让这些错误在编辑器里就暴露出来,而不是等到部署上线才发现。适合谁来参考?如果你已经写过一段时间的 TypeScript,对前后端分离的开发模式比较熟悉,但总觉得“类型”这件事做得不够彻底,那这套东西值得花时间研究。如果你是刚入门的新手,也不用急着上手,可以先理解它背后的设计哲学,等基础扎实了再回来啃。
我写这篇东西的目的,不是给你一份官方文档式的说明,而是把我自己踩过的坑、试过的方案、以及那些“文档里不会写但实际很重要”的细节整理出来。你可以把它当成一个过来人的笔记,能抄的地方直接抄,该避的坑提前避开。
2. 整体设计思路拆解:为什么是“类型先行”
2.1 核心矛盾:类型定义的两套账
在传统的全栈项目里,类型定义往往是“两套账”。后端用 TypeScript 写业务逻辑,定义了一套接口类型;前端用 TypeScript 写页面,又定义了一套几乎一样的接口类型。两边各自维护,靠文档或者口头约定来同步。这种模式在项目初期没什么问题,一旦接口字段多了、参与的人多了,就会出现各种离谱的情况:后端把userId改成了user_id,前端不知道,上线后列表页直接白屏;前端以为某个字段是可选,后端其实必传,提交表单时直接 500。
t3code 要解决的就是这个“两套账”的问题。它的核心主张是:类型定义只写一次,前后端共享。具体怎么共享,不同团队有不同的做法,但底层逻辑是一致的——把类型定义抽到一个公共位置,让前后端都从这里引用,而不是各自复制一份。
2.2 方案选型:为什么不是“生成代码”而是“共享类型”
有人可能会问:那用代码生成工具,根据后端接口自动生成前端类型不就行了?这个思路确实能解决一部分问题,但它有两个硬伤。第一,生成是单向的,后端改了接口,前端需要重新跑一遍生成命令,如果忘了跑,类型还是旧的。第二,生成出来的类型往往是“死”的,缺少业务语义,比如一个status字段,生成出来就是string,但实际它只可能是pending、active、closed三个值之一,这种信息在生成过程中很容易丢失。
t3code 选择的路线是“共享类型”而不是“生成类型”。具体来说,就是把类型定义放在一个前后端都能访问到的地方,比如一个独立的types包,或者利用 monorepo 的结构让前后端直接引用同一份文件。这样做的好处是:类型是“活”的,改一处,所有引用它的地方都会立刻感知到;类型可以携带业务语义,比如用联合类型'pending' | 'active' | 'closed'来约束状态字段,而不是宽泛的string。
注意:共享类型的前提是前后端在同一个代码仓库里,或者至少能通过包管理工具引用到同一个类型包。如果前后端是完全隔离的两个仓库,这套方案落地起来会比较别扭,需要额外搭建私有包仓库或者用 git submodule 来同步。
2.3 端到端类型推导的价值
t3code 另一个核心点是“端到端类型推导”。什么意思?就是从前端的调用代码,一路推导到后端的处理函数,中间不需要手动标注类型。比如前端调用api.user.getById(123),编辑器能自动提示这个调用返回的是一个User对象,而User对象的定义来自后端。如果后端把User的某个字段删了,前端调用处会立刻标红。
这种体验的实现,通常依赖于一套类型安全的 RPC(远程过程调用)层。传统的 REST API 是“字符串路由 + 手动类型标注”,而 t3code 倾向的方案是“函数调用 + 自动类型推导”。前端调用的看起来是一个普通函数,实际上底层被序列化成了 HTTP 请求,后端的处理函数接收到的参数和返回的值,类型都是自动对齐的。
我实测下来,这种模式在中小型项目里效率提升非常明显。以前改一个接口字段,要同时改后端类型、前端类型、前端调用处、后端返回处,至少四个地方。现在只需要改后端类型定义,其他三个地方编辑器会直接告诉你哪里需要调整。
3. 核心细节解析与实操要点
3.1 项目结构:monorepo 是标配
t3code 这套东西,用单仓库(monorepo)来组织是最顺手的。典型的目录结构大概长这样:
project-root/ packages/ api/ # 后端服务 web/ # 前端应用 shared/ # 共享类型与工具函数 package.json tsconfig.jsonshared包是核心,里面放的是前后端都要用的类型定义、常量、以及一些纯函数工具。api和web都通过 workspace 的方式引用shared,这样类型就能天然共享。
为什么不用多仓库?因为多仓库下,shared包需要发布到私有 npm 仓库或者用 git 引用,每次改动都要走一遍发布流程,开发体验很差。monorepo 下,改shared里的类型,api和web立刻就能感知到,不需要任何额外的同步操作。
实操心得:如果团队之前没用过 monorepo,建议先用 pnpm workspace 起步,配置简单,对现有项目侵入性小。npm workspace 也可以用,但在依赖提升和幽灵依赖的处理上不如 pnpm 干净。
3.2 类型定义的组织方式
在shared包里,类型定义建议按“领域”来组织,而不是按“技术层次”来组织。比如:
shared/ src/ types/ user.ts order.ts product.ts constants/ order-status.ts utils/ format.ts每个领域文件里,把相关的类型、枚举、常量放在一起。比如order.ts里既有Order接口,也有OrderStatus联合类型,还有OrderCreateInput这种用于创建订单的输入类型。这样前端要用订单相关的东西,只需要从一个文件里导入,不需要翻好几个目录。
一个常见的坑是:把类型定义写得太“技术化”。比如定义一个ApiResponse<T>泛型,然后所有接口返回都套一层。这种做法在类型层面没问题,但在实际使用时,前端每次都要处理response.data这一层,很啰嗦。t3code 的倾向是让类型尽量“扁平”,接口返回什么就直接是什么,不要额外包一层。
3.3 类型安全的路由层设计
这是 t3code 里技术含量最高的部分。传统的 Express 或 Koa 路由,req.body的类型是any,你需要手动断言或者用 zod 之类的库做运行时校验。t3code 的思路是:让路由处理函数的参数类型自动从请求体中推导出来。
实现方式通常有两种。一种是基于装饰器的方案,比如用tsoa或routing-controllers,在类方法上标注装饰器,框架自动生成路由和类型。另一种是基于函数式组合的方案,比如用trpc或zod+ 自定义封装,把路由定义成一个对象,每个键对应一个处理函数,类型自动串联。
我两种都试过,装饰器方案上手快,但和 TypeScript 的严格模式配合时偶尔会有类型推断不准确的情况。函数式方案写起来稍微啰嗦一点,但类型推导更可靠,而且不依赖反射元数据,打包后体积更小。
注意:无论选哪种方案,运行时校验都不能省。TypeScript 的类型只在编译期有效,运行时如果收到一个不符合类型的请求体,照样会出问题。所以类型定义和运行时校验(比如 zod schema)要成对出现,类型从 schema 推导出来,而不是手写。
3.4 前端调用层的封装
前端这边,核心是“调用后端函数像调用本地函数一样”。比如后端定义了一个getUserById的处理函数,前端通过一套客户端封装,可以直接写const user = await api.getUserById(123),编辑器能提示user的类型是User。
这套封装的关键在于:客户端的类型定义要从服务端的路由定义中自动推导出来,而不是手写。如果手写,就又回到了“两套账”的老路。实现上,通常是把服务端的路由定义导出为一个类型,客户端用typeof来提取这个类型,然后基于它生成调用方法。
我踩过的一个坑是:服务端路由定义里用了很多高阶函数和泛型,导致typeof提取出来的类型过于复杂,编辑器提示卡顿。后来把路由定义简化了一层,把复杂的泛型逻辑收进内部实现,对外暴露的类型保持扁平,问题就解决了。
4. 实操过程与核心环节实现
4.1 环境搭建:从零开始跑通最小闭环
假设你现在要从零搭一套 t3code 风格的项目,我建议按下面的步骤来,不要一上来就追求大而全。
第一步,初始化 monorepo。用 pnpm 的话,根目录建一个pnpm-workspace.yaml,内容写上packages: - 'packages/*'。然后在packages下建三个目录:api、web、shared。
第二步,配置 TypeScript。根目录放一个tsconfig.base.json,把严格模式打开,strict: true、noUncheckedIndexedAccess: true、exactOptionalPropertyTypes: true这几个都加上。子包的tsconfig.json继承这个基础配置。严格模式一开始会报很多错,但这是值得的,类型安全的前提就是类型足够严格。
第三步,在shared里定义第一个类型。比如一个简单的User:
// packages/shared/src/types/user.ts export interface User { id: number; name: string; email: string; role: 'admin' | 'member'; }第四步,在api里写一个返回User的处理函数,并暴露为路由。这里以函数式方案为例:
// packages/api/src/routes/user.ts import { z } from 'zod'; import type { User } from '@project/shared'; const getUserSchema = z.object({ id: z.number(), }); export const userRoutes = { getUser: { input: getUserSchema, resolve: async ({ input }): Promise<User> => { // 实际业务逻辑 return { id: input.id, name: '测试用户', email: 'test@example.com', role: 'member', }; }, }, };第五步,在web里创建客户端,从userRoutes的类型推导出调用方法:
// packages/web/src/api-client.ts import type { userRoutes } from '@project/api'; type UserRoutes = typeof userRoutes; async function callApi<T>(path: string, input: unknown): Promise<T> { const res = await fetch(`/api/${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); return res.json() as Promise<T>; } export const api = { getUser: (input: { id: number }) => callApi<Awaited<ReturnType<UserRoutes['getUser']['resolve']>>>( 'user.getUser', input ), };这样,前端调用api.getUser({ id: 1 })时,返回值的类型就是User,如果后端把User的email字段删了,前端这里会立刻报错。
4.2 参数校验与类型推导的联动
上面代码里用到了 zod,这是 t3code 体系里很关键的一环。zod 的作用是“运行时校验 + 类型推导”。你定义一个 schema,zod 会自动推导出对应的 TypeScript 类型,不需要手写两遍。
比如:
const createOrderSchema = z.object({ productId: z.number().positive(), quantity: z.number().int().min(1), note: z.string().optional(), }); type CreateOrderInput = z.infer<typeof createOrderSchema>;CreateOrderInput的类型是自动推导出来的,你不需要手动写interface。这样做的好处是:类型定义和校验逻辑永远一致,不会出现“类型说是可选,校验却要求必填”这种矛盾。
实操心得:zod schema 建议放在
shared包里,前后端共用。前端可以用它做表单校验,后端用它做请求体校验,一份定义两处使用。如果 schema 只放在后端,前端做表单校验时又要重新写一遍规则,容易不一致。
4.3 错误处理的类型化
t3code 风格的项目里,错误处理也应该是类型安全的。传统的做法是抛一个Error,然后前端 catch 住,但Error的类型信息很弱,前端不知道具体是什么错误。
更好的做法是定义一套错误类型:
export type ApiError = | { code: 'NOT_FOUND'; message: string } | { code: 'VALIDATION_FAILED'; message: string; fields: string[] } | { code: 'UNAUTHORIZED'; message: string };后端抛出这些错误时,前端 catch 到的错误对象类型就是ApiError,可以根据code做不同的处理。这样比笼统的Error要清晰得多。
实现上,可以在 API 层统一封装一个错误转换函数,把业务错误转成ApiError格式,再序列化返回。前端客户端在收到非 2xx 响应时,解析出ApiError并抛出。
4.4 构建与部署的注意事项
t3code 风格的项目在构建时有一个容易忽略的点:shared包需要被编译成前后端都能用的格式。如果shared里只有类型定义,那没问题,TypeScript 编译时会自动擦除。但如果shared里还有常量、工具函数,就需要考虑模块格式。
我的做法是:shared包同时输出 ESM 和 CJS 两种格式,package.json里配置好exports字段。前端打包工具(Vite、Webpack)用 ESM,后端 Node 环境用 CJS,各取所需。
另一个坑是路径别名。monorepo 里经常用@project/shared这种别名,但 TypeScript 的paths配置和打包工具的alias配置需要保持一致,否则会出现“编辑器不报错但打包失败”的情况。建议在根目录统一维护一份别名配置,子包继承。
5. 常见问题与排查技巧实录
5.1 类型推导失效的几种典型情况
用 t3code 这套东西,最常遇到的问题就是“类型推导突然失效了”。表现是:编辑器里某个变量变成了any,或者提示“无法推断类型”。我总结了几种常见原因:
第一种,循环引用。shared里的类型 A 引用了api里的类型 B,api又引用了shared里的类型 A,TypeScript 在处理循环引用时有时会放弃推导。解决办法是:类型定义尽量集中在shared里,api和web只引用不定义。
第二种,泛型嵌套过深。比如一个函数返回Promise<Array<Record<string, Partial<User>>>>,这种类型编辑器处理起来很吃力,有时会退化成any。解决办法是:把复杂类型抽成具名类型,分步推导。
第三种,as断言用多了。as会强制覆盖类型推导,用多了之后 TypeScript 就“放弃思考”了。尽量用类型守卫(type guard)代替as。
5.2 前后端类型不同步的排查
如果发现前端拿到的类型和后端实际返回的不一致,按下面的顺序排查:
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| shared 包是否重新构建 | 查看shared/dist目录时间戳 | 改了源码没重新 build |
| 路径别名是否一致 | 对比 tsconfig 和打包工具配置 | 别名指向了旧版本 |
| 缓存是否过期 | 清除node_modules/.cache | Vite/Webpack 缓存了旧类型 |
| 版本是否对齐 | pnpm list @project/shared | 不同子包引用了不同版本 |
我遇到最多的是第一种:改了shared里的类型,但shared包没有重新构建,api和web引用的还是旧的dist文件。解决办法是在根目录加一个dev脚本,用tsc --watch监听shared的变化,自动重新构建。
5.3 运行时校验与编译期类型不一致
这是一个比较隐蔽的问题:TypeScript 编译通过了,但运行时却报错。原因通常是类型定义和实际数据不匹配。比如类型定义说某个字段是number,但数据库里存的是字符串,查询出来直接返回,运行时才暴露。
解决办法是在数据进入系统的边界处做校验。比如数据库查询结果、外部 API 响应、用户输入,这些地方都要用 zod schema 过一遍。校验通过后再进入业务逻辑,这样类型和运行时数据就是一致的。
注意:不要为了省事跳过边界校验。我见过太多项目因为“信任数据库返回”而导致线上事故,排查起来非常痛苦,因为类型系统帮不上忙。
5.4 编辑器性能问题
t3code 风格的项目类型比较复杂,编辑器(尤其是 VS Code)有时会变卡。表现是:输入延迟、提示弹出慢、保存时格式化卡顿。我试过几个优化手段:
一是限制tsconfig的include范围,不要把node_modules和dist包含进来。二是关闭不必要的类型检查,比如skipLibCheck: true。三是把大文件拆小,单个文件超过 500 行后类型检查会明显变慢。四是升级 TypeScript 版本,新版本在类型推导性能上有持续优化。
如果项目特别大,可以考虑用项目引用(project references),把shared、api、web拆成独立的 TS 项目,各自增量编译,编辑器只加载当前打开文件所属的项目。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 前端提示类型是 any | 循环引用或泛型过深 | 抽具名类型,集中定义 |
| 改了类型但前端没反应 | shared 未重新构建 | 加 watch 脚本 |
| 编译通过但运行报错 | 边界未做运行时校验 | 加 zod schema |
| 编辑器卡顿 | 类型过于复杂 | 拆分文件,开 skipLibCheck |
| 打包后类型丢失 | shared 输出格式不对 | 配置 exports 双格式 |
| 别名在打包时失效 | tsconfig 与打包配置不一致 | 统一维护别名 |
6. 我个人的一些实操体会
这套东西我前前后后折腾了大概三个月,从最开始的一头雾水,到后来能比较顺手地搭起一套完整项目,中间踩的坑不算少。最大的体会是:类型安全不是目的,减少沟通成本才是。以前前后端联调,光是对接口字段就要来回好几轮,现在类型定义一写,前端直接照着提示写调用代码,后端改了什么前端立刻知道,省下来的时间非常可观。
另一个体会是:不要追求“一步到位”。t3code 这套东西涉及的面比较广,monorepo、类型共享、运行时校验、端到端推导,每一项都有学习成本。建议先从“共享类型”这一步开始,把前后端的类型定义统一到shared包里,这一步的收益最直接,改动也最小。等这一步跑顺了,再逐步引入运行时校验和端到端推导。
最后分享一个小技巧:在shared包里加一个type-tests目录,专门放类型测试文件。用tsd或expect-type这类工具,对关键类型做断言测试。比如断言User['role']只能是'admin' | 'member',如果哪天有人不小心把类型改宽了,测试会直接失败。这个习惯在多人协作的项目里特别有用,能防止类型定义被无意中“放松”。