“t3code”这个名字,最初是我某个周末随手给个人项目起的代号,当时完全没有深意。T3就是敲着顺眼的三个字符,看起来比“my-project-v3”清爽,仅此而已。可项目迭代到第三轮的时候,我突然发现这个代号居然能自洽地概括我想做的事:T3,即Three T,分别对应Type、Test、Template。翻译成人话就是——用类型契约、边界校验、模板化结构,把散乱生长的代码收进同一条流水线。
这不是一个框架,也不是一个库,更不是什么新潮架构。它就是一套我自己反复打磨出来的代码组织规范,外加一整套能自动运行的命令。如果你也经常遇到这样的情况:个人项目写一半不敢重构、测试只在线上挂了才想起来补、每个新项目都要花半天搭建基础设施——那这套思路大概率对你有用。
下面写的东西,全部来自我真实跑过的项目,包括被证明是过度的设计,和后来改掉的方案。直接开聊。
1. 代号背后的三个痛点:类型、测试、模板各自解决什么
先坦白一点:t3code 这个命名并非一开始就有深刻含义,它只是我在终端里随手敲的目录名。项目推进到第三轮迭代时,我才意识到真正困扰我的恰好是三件事,而且每件事都能用T开头的英文词概括。
1.1 Type:类型缺失带来的重构恐惧
个人项目最常见的情况:数据从接口、配置文件、事件回调里进来之后,很快就变成了 any 或者裸对象。你当然知道它大概长什么样,但编译器不知道,三个月后的你也不太确定。
我踩过最经典的坑是一个价格计算模块。所有商品、折扣、优惠券在运行时被打包成一个 object 传来传去,第一版只有两个月历史,但因为促销逻辑越来越复杂,我每次加字段都要全局搜索,生怕漏掉某个使用点。后来我加了一个新的优惠类型,有一处调用点没找到,线上直接出现了“折扣算错但没人发现”的静默故障。这其实就是典型的“结构不明确导致修改不可控”。
后来我把核心数据边界定义成类型,效果立竿见影:加字段时,编译器会直接标出所有需要同步修改的地方;重构函数签名时,调用点如果没跟上,立刻红波浪线。用一个生活化类比:你厨房里每个杯子都长得不一样,洗碗机就没法设计;类型相当于统一了杯口直径,机器才能放心接手后续流程。
需要提醒的是,我并不是主张所有变量都写满类型。实际做法是只给“会跨越模块边界的数据”建立契约,模块内部临时变量保持宽松即可。这个边界该怎么划,我在第二章会展开。
1.2 Test:边界问题总是在线上才爆发
个人项目容易有一种侥幸心理:主流程跑通了就算完成。但现实是,真正让项目翻车的往往不是正常路径,而是边界路径:空数组、undefined字段、并发请求、超长输入。
举一个印象深刻的教训。我之前写过一个文件上传组件,正常上传、进度条、错误提示都测过,自我感觉良好。结果上线后,用户上传了一个0字节的空文件,解析器直接抛异常。逻辑上空文件是一个极其常见但又最容易被忽略的输入。
t3code 里的 Test 层,要做的事情不是堆测试数量,而是把“会出错的分支”变成可回归的断言。说得直白一点:每次你修完一个 bug,都先把导致 bug 的那个输入写进测试,再修代码。这样同样的雷不会踩第二次。个人项目最容易欠下的债,不是没写测试,而是测试只覆盖了“我希望发生的路径”,没有覆盖“可能发生的路径”。
1.3 Template:每次新项目都在重新发明轮子
还有一个隐蔽的浪费:每开一个新项目,先花半天搭结构。目录建好、lint 配置好、测试框架接好、构建脚本写好,时间就没了。我用 t3code 之前,几乎每个项目都是这样开头的。更讽刺的是,因为每次都急着写业务,搭建过程往往粗糙,导致不同项目的基建风格完全不统一——这个项目有测试,那个裸奔;这个用 ESLint,那个靠自觉。
Template 层解决的就是这个问题:把 Type、Test 的骨架固化成一个模板仓库,新项目从这里初始化,而不是从空目录开始。打个比方:厨房里刀具固定挂在同一个位置,你不需要每次做饭前都去工具箱翻一遍。
这三个痛点各自独立,又互相放大:没有类型,测试很难写;没有测试,重构不敢做;没有模板,规范无法传承。这也是 t3code 不是单一工具,而是一整套工作流的原因。
2. 三层结构的咬合关系:为什么Type必须走在Test前面
很多人听到“类型、测试、模板”这套组合,可能会觉得无非是把业界常见做法拼在一起。但如果只是把三样东西并排摆着,那它就是一锅大杂烩,不叫工作流。真正让 t3code 发挥作用的,是这三层之间的顺序和依赖关系。
2.1 Type先行:先让数据边界变得可见
我做任何模块的第一步,不是建目录,不是写函数,而是把“进出的数据结构”先写下来。就这么简单。
比如要做用户导入功能,先定义:
interface ImportRow { name: string; email: string; age?: number; } type ImportResult = | { success: true; imported: number } | { success: false; error: string };注意这里用了一个可辨识联合类型,ImportResult 要么成功带数量,要么失败带原因。这样的类型写下之后,后续所有逻辑分支都会被编译器约束住,不会出现“成功了但忘了填数据”这种鬼情况。
这个步骤看起来简单,但它带来了一个连锁效益:因为数据边界是先画出来的,写实现的人(哪怕就是未来的自己)不需要反复猜测“这个字段到底有没有”。所有跨模块传参都像一份签好的合同,下面层级的实现只是在履行合同。
2.2 Test跟随类型:测试写的是行为,不是实现
Type 层把结构定好之后,Test 层就变得非常明确:对每个核心类型,都对应一组“输入—期望输出”的断言。这里有个重要原则:测试尽量描述行为,不锁定实现。我见过很多人把内部函数调用顺序都测进去了,结果一重构测试全崩,逼得人不敢改代码。正确的做法是只看输入输出,以及副作用是否按预期发生。
import { describe, expect, it } from "vitest"; import { normalizeImportRows } from "./normalize"; describe("normalizeImportRows", () => { it("应跳过空行并保留错误行信息", () => { const rows = [ null, { name: "张三", email: "zhang@example.com" }, { name: "", email: "invalid" }, ]; const result = normalizeImportRows(rows); expect(result.valid.length).toBe(1); expect(result.invalid.length).toBe(1); }); });这类测试的价值在于:当类型定义变化时,测试同步变化的代价很小;当实现被重写时,只要外部行为不变,测试一行都不用改。
那么哪些边界情况最值得写?我根据自己的踩坑史整理了一个可直接套用的清单:
| 边界类型 | 测试方式 | 真实案例 |
|---|---|---|
| 空输入 | 传入 null、[]、{} 等空值 | 空订单导致 for...of 崩溃 |
| 非法数值 | 负数、超过范围、NaN | 折扣率大于1导致金额反涨 |
| 缺失字段 | 构造 undefined 字段 | 用户未填年龄导致格式化失败 |
| 超长数据 | 超大文本、超长数组 | 长评论截断逻辑出错 |
| 重复触发 | 同一操作连续调用 | 按钮双击导致订单重复提交 |
这张表我直接放在模板仓库的 tests 目录里,每次写新模块就照着它过一遍。比起从零开始想边界,列出来逐个打勾会靠谱得多。
2.3 Template兜底:把默认值从“自觉”变成“默认”
Type 和 Test 最终要落到模板上。如果每次新建项目都需要手工配置它们,规范的执行成本就太高了。
我的模板仓库里预设好了这几样东西:
- 核心类型目录 src/domain
- 测试目录与 Vitest 配置
- ESLint 与 Prettier 配置
- tsconfig 的 strict 模式
- 一个最小 CI 脚本:类型检查 → lint → 测试
这样任何人(包括未来的自己)执行一条t3 init,拿到的就是一个已经“戴好安全帽”的项目,而不是一张空白画布。规范从来不能被空文件夹里的自觉叫进来,只能被默认模板带进来。
2.4 顺序为什么不能倒
如果把顺序反过来,先搭模板、再写测试、最后补类型,会发生什么?大概率是:模板搭得太宽,塞了一堆用不上的东西;测试写得太晚,对着没有类型定义的接口无从下手;类型最后补,内部逻辑已经绕成一团,想加类型都不敢加。
这个顺序的本质其实很朴素:先确定“事物是什么”(Type),再验证“事物行为对不对”(Test),最后让“下次建新事物时自动具备前两步”(Template)。三者环环相扣,后面每一层都是前面那一层的执行器和放大器。
3. 落地配置:目录骨架、核心命令与命名约束
讲完理念,说点可以直接抄的配置。这章给出一套具体到文件和命令的实现,命令基于 Node 生态,但思路同样适用于其他语言。
3.1 一个t3code项目的基本骨架
我目前使用的模板目录结构是长这样的:
project/ ├── src/ │ ├── domain/ # 类型定义、纯逻辑、领域规则 │ ├── adapters/ # 外部依赖适配:数据库、HTTP、文件系统 │ └── app/ # 组装层:把domain和adapter连接起来 ├── tests/ │ ├── unit/ # 与domain一一对应的单元测试 │ └── integration/ # 跨模块行为测试 ├── scripts/ # 构建、发布等脚本 ├── .eslintrc.cjs ├── vitest.config.ts └── tsconfig.json为什么要分成这三块,而不是按业务、组件、工具来切?因为这样划分能让依赖方向保持单一:domain 不依赖其他两层,adapters 依赖 domain,app 把前两者组装起来。内核保持纯净,外部世界被隔离在适配层。这就是我能保持重构底气的核心原因——内层稳定,外层随便换。
3.2 三条核心命令
t3code 并不是什么独立 CLI,我用的是 npm scripts 聚合,再加一个很轻薄的初始化脚本。核心就三条命令:
t3 init:从模板仓库复制骨架到当前目录,做一次项目名、包名的变量替换,然后安装依赖。底层工具用的 degit,它不会把 .git 历史一起拷过来,很适合从模板开新项目。t3 check:依次执行类型检查、ESLint、单元测试。这是每次提交前必须跑的命令,相当于流水线的自检站。t3 ship:构建产物、跑完整集成测试、生成变更日志。只有 check 全部通过才会走到这一步,否则直接中断。
关键设计点是:check 要足够快,让人愿意频繁跑;ship 要足够稳,让发布不再靠运气。
// package.json scripts 示例 "scripts": { "type-check": "tsc --noEmit", "lint": "eslint . --max-warnings=0", "test": "vitest run", "check": "npm run type-check && npm run lint && npm run test", "ship": "npm run check && npm run build && semantic-release" }这里有个很容易被人忽略的细节:lint 里我设置了--max-warnings=0。因为一旦允许 warning 存在,它就会越攒越多,最后等于没有。宁可一开始严格,也不给“下次再改”留口子。
3.3 命名约束:只有五条硬规则
模板能管住目录结构,但管不住命名。命名一旦混乱,三层边界就会被慢慢腐蚀。我给自己定了五条硬规则,其他都随意:
- domain 中的文件名就是类型名,比如 Order.ts 里只放 Order 及相关纯函数。
- 函数名必须体现“动作+对象”,比如 normalizeImportRows,而不是 handleData 这种万金油。
- 只有 adapters 层允许直接引用第三方依赖,domain 保持零外部依赖。
- 测试文件与被测文件同名加 .test 后缀,放在 tests/unit 的镜像路径下。
- 任何跨模块共用的状态都必须在 domain 里先声明类型,禁止在 app 层凭空造共享状态。
这五条规则的共同导向是“让结构自解释”。不需要额外文档,看目录和文件名就知道哪层在哪、职责是什么。这也是整个 t3code 最省心的地方——规范不需要背,跟着结构走就行。
4. 实操中废掉的三个设计:从过度追求规范到回归实用
讲到这里,你可能会觉得这套流程挺理想。但真实情况是,我在反复使用中废掉了至少三个设计。它们都是听起来很对、执行起来很痛的东西。把这些写出来,比展示一个完美方案更有参考价值。
4.1 被废掉的第一个设计:给一切建类型
刚开始实践 t3code 时,我最容易犯的错就是把“类型先行”理解成“万物皆类型”。一个只有十几行的辅助函数,我也要定义入参接口、返回接口,还喜欢套泛型。结果代码被大段注解淹没,改一个函数像改一份协议。
后来复盘发现,问题出在我把“类型安全”当成了目的本身,而不是手段。于是定了一条更务实的原则:只有跨模块边界传递的数据才必须建类型;纯粹在函数内部流转的临时数据,保持简洁即可。判断标准很简单——如果这个数据不会从文件A流到文件B,那它就不配拥有一等公民般的类型定义。
4.2 被废掉的第二个设计:测试里过度追求覆盖率数字
第二个废掉的设计,是把“测试覆盖率”作为 check 的硬性指标,设了至少90%的红线。结果测试开始为了凑数而存在,连 getter 函数都要写断言,项目里出现了一堆毫无意义的测试。更糟糕的是,高覆盖率的假象让人放松了对关键分支的投入。
现在我的目标只有一个:核心路径和所有已知边界分支必须有测试,覆盖率数字不设门槛。用更直接的话说,好测试的标准不是数量,而是“出过一次 bug 的地方,一定有对应的回归测试”。这个原则下面,测试数量和项目健康度的相关性反而更强了。
4.3 被废掉的第三个设计:模板自带“全家桶”
最开始设计模板时,我往里塞了很多东西:日志框架、缓存抽象、配置中心、监控探针……心想反正模板只用一次,多给点总比少给好。
现实很快打脸。新项目带着这一堆预设启动,很多组件根本用不上,反而因为“既然模板里有了,那就用吧”的心理,引入了一堆过度设计。后来我把模板削减到了最少:目录骨架、lint、vitest、tsconfig、CI 脚本。其他能力全部做成可选扩展包,缺什么装什么,比“都给你”更负责任。
4.4 留下来的三条底线
废掉上面三个设计之后,t3code 沉淀下来的核心实际上只剩三条底线:
- 核心数据边界必须有明确类型。
- 出过事的分支必须有回归测试。
- 项目骨架永远从模板初始化,不手工搭建。
这三条底线都很朴素,但恰恰因为它们朴素,才可能长期执行下去。规范这种东西,一旦让执行者感到“我在为规范服务”,它就会在某个周末被悄悄放弃;只有当规范让人觉得“这玩意儿在帮我兜底”,它才会真正活下来。
5. 一次真实重构的对比:重构前与重构后的差别
说得再多,不如看一次实际对比。我挑一个发生过真实问题的模块——折扣计算。这个例子很小,但足以说明 t3code 三层结构带来的差异。
5.1 重构前:一个典型的“能跑就行”模块
旧版本代码长这样(简化版):
function calcDiscount(order: any, coupon: any) { let total = 0; for (const item of order.items) { total += item.price; } if (coupon && coupon.rate) { return total * coupon.rate; } return total; }这个问题一目了然:order 和 coupon 都是 any,没人知道 items 里有没有 price,coupon.rate 可能是个负数,可能传 null,可能一条订单里既有参与折扣的商品又有不参与折扣的商品。上线后第一次出 bug,就是因为一个订单里没有 items,for...of 直接抛异常。
更麻烦的是,这个函数被三个页面共用,其中两个页面还额外依赖了它内部“只要 coupon 有 rate 就打折”的隐含规则。改成别的折扣策略时,所有调用方都得人肉检查。
5.2 重构后:同一功能的三层写法
先用类型定义清楚输入输出:
type Order = { id: string; items: OrderItem[]; }; type OrderItem = { sku: string; price: number; discountable: boolean; }; type Coupon = { code: string; rate: number; // 0~1之间 }; type DiscountResult = { subtotal: number; discount: number; final: number; };然后写纯逻辑函数:
function calcDiscount(order: Order, coupon?: Coupon): DiscountResult { const subtotal = order.items.reduce((sum, item) => sum + item.price, 0); let discount = 0; if (coupon && isValidRate(coupon.rate)) { const discountableTotal = order.items .filter((item) => item.discountable) .reduce((sum, item) => sum + item.price, 0); discount = discountableTotal * coupon.rate; } return { subtotal, discount, final: subtotal - discount }; }接着是测试:
describe("calcDiscount", () => { it("空订单返回零金额", () => { expect(calcDiscount({ id: "o1", items: [] })).toEqual({ subtotal: 0, discount: 0, final: 0, }); }); it("优惠券只作用于可打折商品", () => { const order = { id: "o2", items: [ { sku: "a", price: 100, discountable: true }, { sku: "b", price: 200, discountable: false }, ], }; expect(calcDiscount(order, { code: "SALE", rate: 0.5 })).toEqual({ subtotal: 300, discount: 50, final: 250, }); }); it("非法折扣率直接忽略优惠券", () => { const order = { id: "o3", items: [{ sku: "a", price: 100, discountable: true }], }; expect(calcDiscount(order, { code: "BAD", rate: 1.5 })).toEqual({ subtotal: 100, discount: 0, final: 100, }); }); });5.3 这个对比说明了什么
重构前,这段逻辑依赖调用方自觉遵守一堆不成文规则;重构后,规则变成了类型约束和测试断言。调用方如果传错结构,编译器会在编译阶段就报错;边界行为是否成立,跑一次测试就知道。
这个模块后来被我改了三次(从满减改成折扣,又改成限制品类的折扣),每次改动都只动 domain 里的纯函数和对应测试,adapters 和 app 层几乎不受影响。这就是 t3code 最直接的收益:当变化发生时,爆炸半径被控制在一个很小的范围内。
6. 这套规范适合谁用:边界条件与推广建议
最后聊聊边界。任何工作流都有适用场景,t3code 也不例外。把它吹成万能药,就和一开始的过度设计一样,会在实际项目中翻车。
6.1 适合的场景
根据我的经验,这套规范在下面几类项目里收益最大:
- 个人维护的中长期项目:生命周期超过三个月,需要持续加功能、修 bug。
- 两三个人的小团队内部工具:没有专职架构师,需要低成本形成共识。
- 需要频繁交付新项目的场景:模板能显著压缩冷启动时间。
在这些场景下,哪怕团队里只有一个人熟悉这套规范,也能用“模板默认值”来约束其他人的产出,不用反复宣导。
6.2 不适合的场景
有几类相反的场景,我不建议直接套用:
- 一次性脚本或原型验证:为了验证一个想法写的几十行脚本,上这套流程是杀鸡用牛刀。
- 已有成熟架构的大型项目:强行引入新的目录划分会破坏既有约定,迁移成本远大于收益。
- 竞品原型需要极速上线时:先把商业模式跑通比代码整洁更重要,这个优先级要诚实面对。
6.3 如果要推广,建议从“命令”开始而不是从“文档”开始
我在实际推广中得出的经验:不要先发一份几十页的规范文档,那样没有人看。更好的做法是先铺好模板和命令,让所有人t3 init出来的项目天然长成符合规范的样子。人只会随大流,而默认值就是最大的大流。
如果非要给新人讲点什么,也只讲三句话:
- 跨模块的数据先定类型。
- 修过的 bug 马上补测试。
- 新项目一律从模板起。
只要这三句话被贯彻执行,剩下的细节都可以在代码评审中慢慢对齐。规范是在项目里长出来的,不是文档里预定义好的。
最后分享一个小细节。现在我把 t3code 的模板仓库放在自己的 GitHub 下,任何新项目的第一件事永远是t3 init。三个月前我因为一次重构差点把整个项目改崩,最后全靠类型检查和回归测试兜住。我的体会是:个人项目的技术债,多数不是来自代码写得不好,而是来自“下次再统一改”这个念头。t3code 并没有做什么高深的事,它只是把“这次就做对”的成本压到了最低,让正确的选择成为最省力的选择,仅此而已。