TypeScript 类型测试 — tsd 与 expectTypeOf,类型也需要测试
你的类型是"产品"。typed-utils 这个库卖的就是类型——如果一次重构让
Flatten<T>的返回从T[]悄悄变成readonly T[],几十个下游项目的代码可能突然报错,而没有任何运行时测试能发现它(类型的回归是编译期的)。今天学习给类型写测试:tsd 的expectType/expectError、vitest 的expectTypeOf、type-coverage 的覆盖率度量,把"类型正确"变成 CI 里一道真实的关卡——就像单测守护运行时行为一样,守护类型的形状。
目录
- 一、为什么类型需要测试
- 二、类型测试的原理:让错误变成期待
- 三、tsd:专为类型库设计的测试框架
- 四、vitest expectTypeOf:测试运行时顺便测类型
- 五、类型覆盖率:type-coverage
- 六、把类型测试接进 CI
- 七、实战:给 typed-utils 写类型测试套件
- 八、类比记忆:质检流水线
- 九、常见坑点与最佳实践
- 十、自测挑战
- 十一、总结与知识图谱
一、为什么类型需要测试
1.1 一个真实的回归场景
// v1.0.0 的 Flatten(第 2 周你写过):
type Flatten<T> = T extends readonly unknown[] ? T[number] : T;
// Flatten<number[]> → number ✅
// 两个月后的一次"顺手优化"(v1.1.0):
type Flatten<T> = T extends unknown[] ? T[number] : T;
// ↑ 删掉了 readonly
// Flatten<number[]> → 依然是 number(测试全绿 ✅)
// 但下游用户:
type R = Flatten<readonly [1, 2]>;
// v1.0.0: 1 | 2(符合文档)
// v1.1.0: readonly [1, 2](原样返回!)—— 下游几十处代码编译报错 💥
// 运行时测试?零发现。运行时行为根本没变。
// 这就是【类型回归】——只有类型测试能拦住。
1.2 类型测试守护的三类形状契约
1. 输出形状 —— Flatten<number[]> 必须等于 number(不能宽也不能窄)
2. 输入约束 —— Flatten<string> 该编译报错?还是原样返回?(错误也是 API)
3. 语义稳定 —— MyPick<T, K> 与内置 Pick 行为一致(回归对照)
二、类型测试的原理:让错误变成期待
// 类型测试的核心思想:反向利用编译错误
// 普通开发:编译错误 = 坏事,修掉它
// 类型测试:编译错误 = 断言的信号,【期待】它在正确的位置出现
// 第 3 周你天天在用的判题工具,其实就是最原始的类型测试:
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
type _ = Expect<Equal<Flatten<number[]>, number>>;
// Flatten 变形了?Equal 结果为 false → T 不满足 true → 编译错误 → 测试失败 ✅
// tsd / expectTypeOf 做的事情,本质就是:
// 把这套 Equal/Expect 工程化 + 漂亮的报错信息 + 跑在 CI 里
三、tsd:专为类型库设计的测试框架
3.1 安装与目录约定
npm i -D tsd
typed-utils/
├── src/index.ts # 被测的库
├── index.d.ts # 类型入口(dist 产物或指向 src)
└── index.test-d.ts # ⚠ tsd 约定:*.test-d.ts 是类型测试文件
// package.json
{
"scripts": {
"test:types": "tsd"
},
"tsd": {
"directory": "." // 测试文件所在目录(默认找 *.test-d.ts)
}
}
3.2 核心 API:expectType / expectAssignable / expectError
// index.test-d.ts
import { expectType, expectAssignable, expectError } from "tsd";
import { Flatten, DeepReadonly, Get } from "./src/index";
// ===== 1. expectType:严格形状断言(含 readonly 等修饰符)=====
type Flattened = Flatten<readonly [1, 2, 3]>;
expectType<1 | 2 | 3>(null as unknown as Flattened);
// ⚠ 技巧:expectType<T>(value) 要求 value 的类型【严格等于】T
// 测试类型时传 null as unknown as X —— 值无所谓,只看类型
// ===== 2. expectAssignable:宽松断言(能赋值即可)=====
expectAssignable<{ readonly temp: number }>(null as unknown as DeepReadonly<{ temp: number }>);
// 适合"只关心必须有哪些成员"的场景(比 expectType 宽松)
// ===== 3. expectError:断言【必须报错】=====
expectError<Get<{ a: 1 }, "b">>(null as unknown as never);
// 或者更常用的值形式:
// expectError(getValue(null as never)); // 传错参数必须被类型系统拦截
// expectError 是类型测试的灵魂:
// "错误也是 API 的一部分" —— 防止有人把约束改松了(any 泄漏)
3.3 tsd 的默认严格性
tsd 默认开启 strict 检查测试文件本身:
- expectType 是【严格相等】(readonly、可选修饰符的差异都算不相等)
- 官方文档列出每个断言的精确语义 —— 写之前扫一眼,避免"以为相等"
经验法则:
- 库的公开返回类型 → expectType(精确契约)
- 用户提供的宽松输入 → expectAssignable(兼容即可)
- 防御性约束 → expectError(必须拒绝)
四、vitest expectTypeOf:测试运行时顺便测类型
4.1 安装与文件
npm i -D vitest
// tests/utils.test.ts —— 运行时测试 + 类型测试写在同一个文件
import { describe, it, expect } from "vitest";
import { expectTypeOf } from "vitest";
import { Flatten, Get } from "../src/index";
describe("Flatten", () => {
// ===== 运行时测试(普通单测)=====
it("运行时行为正确", () => {
// 若有运行时函数则测之;纯类型库这层可以省
});
// ===== 类型测试(expectTypeOf)=====
it("拉平元组为联合", () => {
type R = Flatten<readonly [1, 2, 3]>;
expectTypeOf<R>().toEqualTypeOf<1 | 2 | 3>(); // 严格相等
});
it("非数组原样返回", () => {
type R = Flatten<string>;
expectTypeOf<R>().toEqualTypeOf<string>();
});
it("返回类型是联合而非元组", () => {
type R = Flatten<number[]>;
expectTypeOf<R>().not.toEqualTypeOf<[number]>(); // .not 反向断言
expectTypeOf<R>().toEqualTypeOf<number>();
});
});
4.2 expectTypeOf 的常用断言全家福
// 假设被测类型:type Device = { id: string; readonly temp: number }
expectTypeOf<Device>().toEqualTypeOf<{ id: string; readonly temp: number }>();
// 严格相等(含 readonly/可选修饰符)
expectTypeOf<Device>().toMatchTypeOf<{ id: string }>();
// 结构兼容(Device 至少长得像 { id: string })
expectTypeOf<Device>().toHaveProperty("temp").toEqualTypeOf<number>();
// 检查单个成员的类型
expectTypeOf<Device>().toExtend<{ id: string }>();
// 继承关系(alias of toMatchTypeOf)
expectTypeOf<never>().toEqualTypeOf<Get<{ a: 1 }, "b">>();
// Get 的错误路径必须产出 never
// 函数签名断言:
declare function getProperty<T, K extends keyof T>(obj: T, key: K): T[K];
expectTypeOf(getProperty).parameter(0).toEqualTypeOf<object>();
expectTypeOf(getProperty).returns.toBeNumber();
// 链式、可读性拉满 —— 团队协作首选
4.3 tsd vs expectTypeOf 选型
| 维度 | tsd | vitest expectTypeOf |
|---|---|---|
| 定位 | 纯类型库(.d.ts 即产品) | 应用/混合项目 |
| 文件 | 独立 *.test-d.ts | 与运行时测试同文件 |
| 依赖 | 独立运行,轻 | 需要 vitest 生态 |
| 报错体验 | 专业(diff 详细) | 测试报告集成 |
| 建议 | typed-utils 用它 | 业务项目用它 |
本项目决策:双轨制
- tsd 守"类型库契约"(index.test-d.ts)—— Day 28 发布前的质量闸门
- vitest 守"运行时工具函数"(*.test.ts)—— 有运行时代码的部分
五、类型覆盖率:type-coverage
5.1 度量"any 的浓度"
npm i -D type-coverage
npx type-coverage
# 输出示例:
# 1523 / 1610 = 94.59% 覆盖率(87 处 any)
// package.json
{
"scripts": {
"type-coverage": "type-coverage --strict --detail"
// --strict 把 any/unknown 的隐式使用都算不覆盖
// --detail 列出每一处未覆盖的文件与行号
}
}
5.2 覆盖率的正确用法
- 不是追 100%(第三方边界、catch 的 unknown 合理存在"未标注")
- 是【防止倒退】:CI 里设阈值,低于阈值即失败
- 工业实践:老项目从当前值起步,每周提升 1%,写入 README 的质量徽章
六、把类型测试接进 CI
# .github/workflows/ci.yml(Day 27 会扩展成完整流水线)
name: CI
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
# 三道关卡(顺序有讲究:快的先跑)
- run: npm run typecheck # ① tsc --noEmit:语法层
- run: npm run test:types # ② tsd:类型契约层
- run: npm test # ③ vitest:运行时层
- run: npm run type-coverage # ④ 覆盖率阈值(可选)
类型回归的完整防线:
tsc --noEmit(本项目能不能编译)
→ tsd(公开类型的形状对不对)
→ vitest expectTypeOf(使用处的类型期不期待)
→ type-coverage(any 有没有变多)
七、实战:给 typed-utils 写类型测试套件
今天的实操(Day 28 BOSS 战的前置作业):
1. 安装:npm i -D tsd vitest
2. 写 index.test-d.ts,为第 2 周的工具类型各写 2-3 条断言:
- Flatten:元组拉平 / 非数组透传 / readonly 兼容(本文 1.1 的回归场景!)
- DeepReadonly:嵌套加 readonly / 函数成员不映射(Day 19 的边界)
- Get<T, Path>:合法路径取值 / 非法路径 never
- MyPick/MyOmit:与内置版行为对照(expectType<...>)
- TupleToUnion、Last 等元组工具
3. 写一条 expectError 防御(防止约束被改松):
expectError(getProperty(null as never, "x"))
4. 运行 npm run test:types → 全绿
5. 破坏性实验(最重要的一步):
故意改坏 Flatten(删掉 readonly)→ 跑测试 → 看它变红
→ 恢复 → 变绿
"测试会红" 才证明测试有效(和运行时单测的杀虫剂悖论同理)
6. type-coverage 跑一次,记录基线值
八、类比记忆:质检流水线
类型测试 = 工厂的质检流水线
- tsc --noEmit = 目检:零件能装上吗(能不能编译)
- tsd = 卡尺:尺寸是否严格符合图纸(expectType 精确到修饰符)
- expectAssignable = 试装:能塞进标准接口就行(宽松兼容)
- expectError = 负向测试:故意塞错误零件,卡扣必须弹开(该拒的必须拒)
- type-coverage = 材料成分分析:any 的浓度不得超标
- CI = 每件出厂前必过全检,低于标准不放行
杀虫剂悖论在质检上的对应:
从来没检出过不良品的流水线,多半是量具坏了 ——
今天实操第 5 步"故意改坏看变红"就是在校准量具。
九、常见坑点与最佳实践
坑点 1:expectType 对象字面量被拓宽
// ❌ 字面量参数会拓宽类型,断言悄悄通过/失败得很怪:
expectType<{ temp: number }>(null as unknown as { temp: 65 });
// 65 ⊑ number —— 严格断言失败(这其实是对的!但常常不是你想要的)
// ✅ 先 type 别名固定形状,再断言:
type Actual = Flatten<readonly [1, 2]>;
expectType<1 | 2>(null as unknown as Actual);
坑点 2:expectError 太宽(断了个寂寞)
// ❌ 这行"通过了",但可能是【任何原因】报的错(比如拼写错误):
expectError(someFunction(wrongArgumentTypo));
// ✅ expectError 只在你明确知道"唯一报错原因"时使用,
// 并在注释里写明期待的错误信息
坑点 3:类型测试跑在宽松 tsconfig 下
症状:测试文件里断言全部"绿",但用户用 strict 一开就报错
修复:tsd/测试 tsconfig 必须与用户最严格场景对齐
(strict + noUncheckedIndexedAccess —— Day 22 的配置在这里兑现价值)
坑点 4:把类型测试写成类型体操表演
// ❌ 测试里炫技(嵌套 Equal 三层),失败时没人看得懂
// ✅ 测试的第一读者是三个月后的你:
// 每条断言一行注释:契约是什么、防的是哪个回归
// (本文 1.1 的 readonly 回归就是最好的注释素材)
十、自测挑战
挑战 1(基础):API 对号入座
以下场景分别用哪个断言?
a. Flatten<readonly [1,2]> 必须严格等于 1 | 2
b. DeepReadonly<T> 的结果必须能赋给 Readonly<T>
c. getProperty(obj, "不存在的键") 必须编译报错
d. 函数第二参数必须是 string 或 number(不能 boolean)
挑战 2(进阶):给 Includes 写类型测试
// 用 expectTypeOf 为第 3 周的 Includes 写 4 条类型断言:
// ① 命中返回 true ② 未命中返回 false
// ③ Includes<[boolean], false> 是 false(严格相等的语义)
// ④ Includes<[any], unknown> 是 false(Equal 的功劳)
挑战 3(实验):校准你的量具
在 typed-utils 里:
1. 故意把 DeepReadonly 的 Function 分支删掉 → 测试必须变红
2. 故意把 Get 的 never 分支改成 any → 测试必须变红
3. 两个"变红"都成功 → 截图写进博客《我的测试真的在守门》
挑战 4(论文级):给团队定类型测试规范
写 5 条团队规范(想象你是 Tech Lead):
- 什么时候用 expectType / expectAssignable / expectError?
- 覆盖率阈值定多少?为什么不是 100%?
- 类型测试放哪个文件?命名约定?
- CI 里它排在运行时测试前还是后?为什么?
- Code Review 时类型测试看什么?
十一、总结与知识图谱
类型测试(Day 26)
│
├── 动机:类型回归是编译期事故
│ └── 运行时测试零发现(readonly 丢失案例)
│
├── 原理:反向利用编译错误
│ └── Equal/Expect(第 3 周)的工程化
│
├── tsd(类型库专用)
│ ├── expectType —— 严格相等契约
│ ├── expectAssignable —— 结构兼容
│ └── expectError —— 错误也是 API ⭐
│
├── vitest expectTypeOf(应用项目)
│ ├── toEqualTypeOf / toMatchTypeOf
│ ├── .not / .toHaveProperty / 参数与返回值链式断言
│ └── 与运行时测试同文件
│
├── type-coverage
│ └── any 浓度度量 → 防倒退阈值
│
└── CI 四道防线
tsc --noEmit → tsd → vitest → coverage
一句话总结:类型库的产品是类型本身,没有类型测试的类型库等于没有单测的业务代码——tsd 用 expectType 锁死形状契约、expectError 守住约束底线、type-coverage 盯住 any 浓度,三件套让"重构"从赌运气变成有安全网的高空作业。
明日预告:Day 27 工程工具链——typescript-eslint 的类型感知规则(让 ESLint 看懂类型后战斗力翻倍)、Prettier 分工、Husky + lint-staged 提交门禁,把本周所有质量关卡串成一条自动化流水线。