【TS】day26-type-testing

作者:mario 发布时间: 2026-09-01 阅读量:6 评论数:0

TypeScript 类型测试 — tsd 与 expectTypeOf,类型也需要测试

你的类型是"产品"。typed-utils 这个库卖的就是类型——如果一次重构让 Flatten<T> 的返回从 T[] 悄悄变成 readonly T[],几十个下游项目的代码可能突然报错,而没有任何运行时测试能发现它(类型的回归是编译期的)。今天学习给类型写测试:tsd 的 expectType/expectError、vitest 的 expectTypeOf、type-coverage 的覆盖率度量,把"类型正确"变成 CI 里一道真实的关卡——就像单测守护运行时行为一样,守护类型的形状。


目录


一、为什么类型需要测试

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 提交门禁,把本周所有质量关卡串成一条自动化流水线。

评论