引言
TDD(测试驱动开发)主张「先写失败测试,再写实现」;Type-First 主张「先写类型,再写实现」——让 TypeScript 编译器在动手写逻辑前就把接口契约焊死。这不是「多用类型」的口号,而是一套可执行的流程:定义输入/输出类型 → 用 satisfies/泛型约束锁住形状 → 实现逻辑直到「编译通过 + 类型收窄正确」→ 用测试验证行为而非形状。类型与测试从此各司其职:类型管形状,测试管行为。本文将把这条路径拆成可落地的工程实践。
前置:/typescript-type-level-programming/(类型计算)、/typescript-runtime-validation-typesafe/(类型与运行时边界)、/typescript-testing-type-safe/(测试类型安全)。
目录
- 1. Type-First 是什么
- 2. 类型作为契约
- 3. 先定义输入输出
- 4. 实现与编译反馈
- 5. 类型与测试的分工
- 6. 测试的「类型优先」写法
- 7. 类型驱动的重构
- 8. 团队协作与文档
- 9. 局限与取舍
- 10. 速查表与一句话记忆
- 延伸阅读
1. Type-First 是什么
Type-First 是一种开发顺序的转变:把「写类型」当作实现的第一步,而不是最后的注释。
传统流程:想功能 → 写逻辑 → 补类型(经常变成 any 或 afterthought)
Type-First:定契约 → 写类型 → 实现逻辑 → 编译通过 → 测试行为
与 TDD 的关系:
| 维度 | TDD | Type-First |
|---|---|---|
| 先行物 | 失败测试 | 类型定义 |
| 保障什么 | 行为正确 | 形状正确、契约成立 |
| 反馈速度 | 秒级(跑测试) | 毫秒级(编译) |
| 最佳组合 | 行为变化用测试 | 形状变化用类型 |
核心理念:TypeScript 编译器是你「永远在跑、反馈最快」的测试框架。TDD 管「行为不该错」,Type-First 管「形状不许错」——两者互补,不是替代。
2. 类型作为契约
契约(Contract)是「调用方必须满足的前提 + 实现方必须承诺的结果」。类型就是 TypeScript 世界的契约语言:
// 一段「契约」:parseUserId 承诺输入 string,输出 UserId 或抛错
type UserId = string & { readonly __brand: unique symbol };
function parseUserId(input: string): UserId {
if (!/^[0-9a-f]{24}$/.test(input)) throw new Error("invalid id");
return input as UserId;
}
类型即契约的三个特征:
- 可检查:编译期验证双方约定(
UserId不能与普通string混用——品牌类型); - 可组合:
Partial<Contract> | null等类型运算表达变体契约; - 可演进:改契约 = 改类型 = 全站编译检查点。
// 用 satisfy 让「对象字面量契约」本地生效
const config = {
api: "/v1",
retries: 3,
} satisfies Readonly<Record<string, string | number>>;
// 多写 / 写错类型都会编译报错
3. 先定义输入输出
Type-First 流程的第一步,是在写任何逻辑前定义函数的输入/输出类型:
// 业务接口:订单拆单
type Order = {
id: string;
items: { sku: string; qty: number }[];
discount?: number;
};
type SplitResult = {
orders: Order[];
remainder: Order | null; // 无法整除的余单
};
// 先只写签名,类型锁死契约
declare function splitOrder(order: Order, limit: number): SplitResult;
实践技巧:
- 先
declare后实现:用declare function占位签名,让「编译过」成为「契约完成」的判据; - 输入不可变:
Readonly<T>/as const表达「不修改入参」; - 输出穷举:用判别联合表达所有可能结果(成功/失败/空);
- 错误显式化:用 Result 模式(/typescript-error-handling-result/)替代隐式
null。
4. 实现与编译反馈
定义好签名后,实现是「填空」,而编译器是「自动判卷机」:
function splitOrder(order: Order, limit: number): SplitResult {
const orders: Order[] = [];
let current: Order["items"] = [];
for (const item of order.items) {
current.push(item);
if (sumQty(current) > limit) {
orders.push({ id: genId(), items: current, discount: order.discount });
current = [];
}
}
const remainder = current.length ? { id: genId(), items: current } : null;
return { orders, remainder }; // 类型不匹配 → 编译立即报错
}
反馈循环:改类型 → 编译 → 全站检查点(调用方、测试、文档一并校验)。这个循环让「契约变更」的成本可视化——改一个参数类型,所有使用处立刻告诉你哪些要跟着改。
5. 类型与测试的分工
类型与测试不是重复劳动,而是互补的「两道防线」:
| 防线 | 抓什么 | 抓不住什么 |
|---|---|---|
| 类型 | 形状错误、缺字段、类型不兼容 | 逻辑错误、边界行为 |
| 测试 | 行为错误、边界值、状态迁移 | 编译期契约(已被类型抓) |
// 类型已保证「不会把 string 传给 number」,测试不必重复测这个
function double(x: number): number { return x * 2; }
// 测试聚焦「行为」:0、负数、大数
it.each([2, 0, -3])("double(%i)", (n) => {
expect(double(n)).toBe(n * 2);
});
工程原则:如果测试里充满了「断言类型正确」的代码(如 expect(fn).toHaveType<...>),说明类型没写好;如果类型里塞满逻辑(花式体操),说明逻辑该移到实现。两者职责分清,各自简单。
6. 测试的「类型优先」写法
在测试里,Type-First 意味着「先让测试的类型成立,再写断言」:
// 1. 先声明「这个测试涉及哪些类型」
type TestCase = { input: Order; limit: number; expectedCount: number };
// 2. 用类型化数据驱动
const cases: TestCase[] = [
{ input: makeOrder(5), limit: 2, expectedCount: 3 },
{ input: makeOrder(3), limit: 10, expectedCount: 1 },
];
// 3. 断言的是行为,但数据形状已被类型锁死
describe("splitOrder", () => {
it.each(cases)("拆分 $input 得 $expectedCount 单", ({ input, limit, expectedCount }) => {
const result = splitOrder(input, limit);
expect(result.orders.length).toBe(expectedCount);
});
});
好处:
- 测试数据即文档:
TestCase[]让「输入形状」一目了然; - 改契约 = 测试编译失败:接口一变,测试先行暴露,形成「改类型→改测试」的正循环;
- 减少无效测试:形状错误交给编译器,测试专注真行为。
7. 类型驱动的重构
类型是重构的「安全网」:它让「改错了能立刻发现」成为常态。
// 重构前:命名模糊、耦合实现细节
type Order = { id: string; items: { sku: string; qty: number }[] };
// 重构目标:提取领域类型、收敛依赖
type Sku = string;
type Quantity = number;
type LineItem = { sku: Sku; qty: Quantity };
type Order = { id: OrderId; items: LineItem[] };
类型驱动的重构流程:
- 提取类型:把魔法字符串/重复结构提取成命名类型(
Sku、OrderId); - 推着实现走:类型引用处就是「要改的地方」,编译器列出清单;
- 逐步替换:每替换一处,编译通过再继续——「类型绿 = 这一步没破坏契约」;
- 收窄依赖:类型暴露面越小,重构自由度越大。
反直觉点:类型写得好,重构反而更大胆——因为编译器把「改坏」的可能性提前拦截了。类型是「让你敢改」的底气,不是「限制你改」的枷锁。
8. 团队协作与文档
Type-First 让「类型即文档」从口号变成协作机制:
| 场景 | 类型的作用 |
|---|---|
| Code Review | 类型先于实现被 review,契约争议在写逻辑前解决 |
| 新人上手 | 读类型比读实现快得多,declare 签名就是 API 文档 |
| 跨团队对接 | 共享类型包(/typescript-api-type-generation/)是双端契约 |
| 交接与维护 | 类型揭示「能传什么、会得到什么」,实现细节退居其次 |
工程实践:
- 提交顺序:一个 PR 先提交「类型层」再提交「实现层」,reviewer 先看契约;
@deprecated标注:类型层标记废弃 API,编译器给出提示;- 类型命名规范:
XxxState、XxxResult、XxxError后缀让意图自明; explainFiles排查:类型引用关系可视化,看「类型从哪来」。
9. 局限与取舍
Type-First 不是银弹,理解其边界才不会教条:
- 类型无法表达「运行时约束」:
number不保证「非负」——需要运行时校验(/typescript-runtime-validation-typesafe/)补位; - 类型系统图灵完备也非万能:过度体操(HKT 模拟、深递归)增加理解成本,团队要设「类型复杂度预算」;
- 外部世界无法类型化:API 响应、用户输入、第三方库的
any——边界净化是另一层工作; - 性能:极度复杂的条件类型拖慢类型检查(/typescript-generic-api-design-performance/),要在表达力与速度间取舍。
何时值得 Type-First:
✓ 接口稳定、多端复用(API 层、领域模型)
✓ 团队契约意识强、Code Review 前置类型
△ 快速原型、一次性脚本——先写通,再补类型
✗ 强运行时逻辑(如日期运算)——类型帮不上太多
10. 速查表与一句话记忆
| 步骤 | 一句话 |
|---|---|
| 定契约 | 先写输入/输出类型(declare 占位) |
| 锁形状 | 泛型约束 + satisfies + 判别联合 |
| 填空实现 | 实现到编译通过 = 契约达成 |
| 分工 | 类型管形状,测试管行为 |
| 重构 | 类型是安全网,改类型→编译器列清单 |
| 协作 | 类型即文档,契约先 review |
| 边界 | 运行时约束与外部世界靠校验补位 |
一句话记忆:Type-First = 先类型后实现 + 编译器当测试 + 类型管形状/测试管行为 + 类型安全网支持大胆重构——它是「让编译器替团队守着契约」的工程文化。
延伸阅读
- /typescript-testing-type-safe/ — 测试的类型安全与 tsd/expect-type
- /typescript-runtime-validation-typesafe/ — 运行时校验与类型边界
- /typescript-error-handling-result/ — Result 模式与显式错误
- /typescript-generic-api-design-performance/ — 类型设计的复杂度预算
- /typescript-api-type-generation/ — 契约生成与共享类型
- 测试专题 — TDD 与测试金字塔
- /typescript-design-patterns-practice/ — 类型的工程化表达
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。