本节目标:读完这一节,你能解释「为什么单元测试覆盖不了类型」,用
@ts-expect-error写出零依赖的类型断言,用expectTypeOf与tsd的expectType/expectError建立类型回归测试,并把它们接进tsc --noEmit与 Vitest 的--typecheck流水线;同时能识别「测试写了但其实什么都没验」的假绿。
15.2 类型测试(tsd/expect-type)
上一节我们把运行时行为测了起来。但本书从第 9 章起花了大量篇幅讲类型运算——Exclude、infer、映射类型、工具类型。这些代码有个共同点:它们在运行时几乎什么都不做。
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
DeepPartial 编译后会被完全擦除。你没法用 expect() 断言它——expect 断言的是值,而这里根本没有值。可如果哪天有人「顺手优化」它,把它改成了非递归版本,业务代码的类型精度会悄悄退化,没有任何测试会变红。
这就是类型测试(type testing)要解决的问题。
运行时断言为什么测不到类型
先看一个具体的例子。假设我们写了一个函数,希望它在传入字符串字面量时返回对应的大写类型:
function upper<T extends string>(s: T): Uppercase<T> {
return s.toUpperCase() as Uppercase<T>;
}
const a = upper("hello"); // 类型是 "HELLO"
下面这个「测试」是无效的:
it("upper 返回大写", () => {
expect(upper("hello")).toBe("HELLO"); // ✅ 通过,但它证明不了类型
});
它证明的只是「运行时返回了 HELLO」。把返回类型从 Uppercase<T> 改成 string,这个测试依然全绿——而这恰恰是类型层面的重大退化。
expect(upper("hello")).toBe(...) 里的 expect 拿到的是值,类型信息在调用时就已擦除。要断言类型,必须让编译器来当裁判,而不是运行时。
类型测试的五种流派
| 方案 | 依赖 | 断言方式 | 适用场景 |
|---|---|---|---|
@ts-expect-error | 无 | 编译报错即断言 | 随手验证、少量场景 |
// @ts-expect-error + 类型注解 | 无 | 赋值兼容性 | 不需要新依赖的项目 |
expect-type | 一个包 | expectTypeOf(x).toEqualTypeOf<T>() | Vitest 项目首选 |
tsd | 一个包 | expectType<T>(x) / expectError(x) | 发布 npm 包(DefinitelyTyped 同款) |
attw(are-the-types-wrong) | 一个包 | 检查打包产物的类型入口 | 发布前体检 exports |
前两种不需要任何依赖,后三种各有分工。本节按「零依赖 → expect-type → tsd」的顺序讲。
零依赖方案:@ts-expect-error
TypeScript 3.9 起支持 // @ts-expect-error 指令:它要求下一行必须报错,不报错反而会报一个错。
function fail(message: string): never {
throw new Error(message);
}
// 期望这行报错:fail 需要 string
// @ts-expect-error 参数类型不符
fail(42);
如果哪天有人把 fail 的签名放宽成 (message: unknown) => never,fail(42) 不再报错,编译器就会给出:
Unused '@ts-expect-error' directive. ts(2578)
这正是它比 // @ts-ignore 强的地方——@ts-ignore 会在没有错误时保持沉默,指令悄悄失效你也不会知道。
| 指令 | 有错误时 | 无错误时 | 建议 |
|---|---|---|---|
// @ts-ignore | 忽略 | 静默通过 | 尽量不用 |
// @ts-expect-error | 忽略 | 报 ts(2578) | 类型测试首选 |
配合类型注解,还能做「正向」断言——把值赋给一个期望的类型,赋值失败即测试失败:
// 期望 a 的类型是 "HELLO"
const a: "HELLO" = upper("hello"); // ✅
const b: "hello" = upper("hello"); // ❌ Type '"HELLO"' is not assignable to type '"hello"'.
这招简单有效,但缺点是断言方向单一:它只能验证「可赋值」,分不清 "HELLO" 与更宽的 string。要精确断言「类型完全相等」,就需要专门的库。
expect-type:Vitest 项目的首选
npm i -D expect-type
它的核心是 expectTypeOf,返回一个链式断言对象:
import { expectTypeOf } from "expect-type";
import { describe, it } from "vitest";
describe("类型断言", () => {
it("upper 返回精确的字面量类型", () => {
expectTypeOf(upper("hello")).toEqualTypeOf<"HELLO">();
});
it("不是宽泛的 string", () => {
expectTypeOf(upper("hello")).not.toEqualTypeOf<string>();
});
});
常用 API:
| API | 含义 |
|---|---|
toEqualTypeOf<T>() | 类型完全相等(最严格) |
toMatchTypeOf<T>() | 可赋值给 T(更宽松,旧名 toMatchTypeOf) |
toBeString() / toBeNumber() / toBeBoolean() | 原始类型判定 |
toBeAny() / toBeNever() / toBeUnknown() | 特殊类型判定 |
toBeCallableWith(...args) | 是否能用这些参数调用 |
returns / parameters | 取函数返回类型 / 参数元组再断言 |
.not | 取反 |
expectTypeOf 的断言在运行时是空操作(no-op),它们只在类型检查阶段生效。所以:
- 它必须放在会被类型检查的文件里。放在
*.test.ts里且只跑vitest run,断言根本不会被验证; - 要么让
tsc --noEmit覆盖这些文件,要么用 Vitest 的类型检查模式(见后文)。
函数签名的断言很实用:
declare function fetchUser(id: number): Promise<{ id: number; name: string }>;
expectTypeOf(fetchUser).returns.toEqualTypeOf<Promise<{ id: number; name: string }>>();
expectTypeOf(fetchUser).parameter(0).toBeNumber();
expectTypeOf(fetchUser).toBeCallableWith(1);
expectTypeOf(fetchUser).not.toBeCallableWith("1"); // 字符串参数应当不被接受
returns 与 parameters 返回的是「类型探测代理」,可以继续链式断言,也可以直接 toEqualTypeOf。
tsd:发布 npm 包时的标准做法
tsd 是 DefinitelyTyped 与大量知名库(如 zod、vitest 自身)在用的方案。它的工作方式与 expect-type 不同:它不依赖你的测试运行器,而是直接读类型声明文件(.d.ts)并断言。
npm i -D tsd
先在 package.json 里声明类型入口与测试目录:
{
"types": "./dist/index.d.ts",
"tsd": {
"directory": "test-d"
}
}
注意 "types" 必须指向构建产物。tsd 检查的是用户实际拿到的那份声明,而不是源码——这正是它能发现「声明与实现不一致」的原因。
断言写在 test-d/*.test-d.ts 里:
import { expectType, expectError, expectAssignable } from "tsd";
import { upper } from "../src/index";
// 正向:类型完全相等
expectType<"HELLO">(upper("hello"));
// 反向:这行必须报错
expectError(upper(42));
// 宽松:可赋值即可
expectAssignable<string>(upper("hello"));
// 精确不等:expectNotAssignable<"hello">(upper("hello")) 会失败
常用 API 与 @ts-expect-error 的对应关系:
| tsd API | 等价写法 | 说明 |
|---|---|---|
expectType<T>(x) | const _: T = x 的严格版 | 类型必须完全相等 |
expectAssignable<T>(x) | const _: T = x | 只需可赋值 |
expectNotAssignable<T>(x) | // @ts-expect-error | 必须不能赋值 |
expectError(x) | // @ts-expect-error | 表达式必须报错 |
expectDeprecated(x) | — | 断言标记了 @deprecated |
运行:
npx tsd
成功时输出 0 errors,失败时会给出精确的差异报告。
把类型测试接进流水线
两条路可以并行,成本都很低。
路线一:tsc --noEmit。 让类型测试文件(*.test-d.ts 或 *.test.ts)落在 include 范围内,类型检查本身就完成了断言:
npx tsc --noEmit
路线二:Vitest 类型检查模式。 Vitest 内置了 --typecheck,可以跑专门的类型测试文件:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
typecheck: {
enabled: true,
include: ["**/*.{test,spec}-d.ts"],
},
},
});
npx vitest --typecheck
这样 describe / it 的运行时报告与类型断言就能出现在同一份输出里,CI 里也只需要一条命令。完整的 CI 编排(覆盖率、lint、类型检查一起进门禁)留给下一节 15.3 覆盖率、lint 与 CI 门禁
。
真实工程示例:给 DeepPartial 写类型测试
回到开头那个 DeepPartial<T>。它是很多表单、配置、PATCH 接口的基石,一旦写错,症状是「类型精度悄悄丢失」。给它配一套类型测试:
// src/deep-partial.ts
export type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// src/deep-partial.test-d.ts
import { expectTypeOf } from "expect-type";
import { describe, it } from "vitest";
import type { DeepPartial } from "./deep-partial";
interface Config {
host: string;
port: number;
tls: { enabled: boolean; ca: string };
}
describe("DeepPartial", () => {
it("顶层字段可选", () => {
expectTypeOf<DeepPartial<Config>>().toMatchTypeOf<{ host?: string }>();
});
it("嵌套对象递归可选", () => {
expectTypeOf<DeepPartial<Config>["tls"]>().toMatchTypeOf<{
enabled?: boolean;
ca?: string;
}>();
});
it("原始类型不被再拆开", () => {
// string 是 object 的子类型吗?不是,所以不应被递归成 {}
expectTypeOf<DeepPartial<Config>["host"]>().toEqualTypeOf<string | undefined>();
});
it("非递归实现会被测出来", () => {
// 假设有人误写成 { [K in keyof T]?: T[K] }
type Shallow<T> = { [K in keyof T]?: T[K] };
expectTypeOf<Shallow<Config>["tls"]>().not.toEqualTypeOf<DeepPartial<Config>["tls"]>();
});
});
最后一条断言是这套测试的价值所在:它把「递归」这个不可见的实现要求固化成了一条会失败的断言。任何把 DeepPartial 简化掉的改动,都会在这里被拦下。
延伸阅读:既有专题里有一篇专门讲类型安全测试的文章 /typescript-testing-type-safe/ ,可与本节对照;类型编程的更多模式见 /typescript-advanced-types/ 与 /typescript-type-level-programming/ 。本书内部,手写工具类型的技巧在第 10.1 内置工具类型全解 与 9.3 条件类型与 infer 。
常见坑与报错
坑一:类型断言根本没被检查。 expectTypeOf 在运行时是空操作。如果类型测试文件既不在 tsc --noEmit 的范围内,也没跑 vitest --typecheck,那它就是一段「永远绿」的装饰。判断方法:故意把一条断言改错,看它会不会报错。
坑二:any 让断言全绿。 any 可以赋值给任何类型,所以 expectTypeOf<any>(x).toEqualTypeOf<number>() 之外的宽松断言会被 any 蒙混过关。需要显式断言 toBeAny():
expectTypeOf(someFn()).toBeAny(); // 精确判定 any
expectTypeOf(someFn()).not.toBeAny();
坑三:*.test-d.ts 被编译进产物。 这些文件只用于类型检查,务必在 tsconfig.json 里排除,或在构建用的配置里 "noEmit": true:
{
"exclude": ["**/*.test-d.ts", "**/*.test.ts"]
}
坑四:tsd 断言的是源码而不是产物。 忘了 "types" 字段,tsd 会去猜入口,结果检查的不是用户拿到的那份声明。发布前的类型入口体检,配合 exports 字段的正确性检查,可参考 11.3 npm 包、类型声明与 exports
与 12.1 .d.ts 与 @types 机制
。
坑五:把 @ts-expect-error 写在错误的行上。 它只作用于紧邻的下一行,写在语句上方多一行注释就会失效,并且反过来报 Unused '@ts-expect-error' directive.。
小结
- 类型在运行时被擦除,
expect这类运行时断言证明不了类型;类型的回归要靠编译器当裁判。 - 零依赖方案是
// @ts-expect-error(无错即报 ts(2578))与「赋给期望类型」的注解断言,简单但只能验证可赋值性。 expect-type的expectTypeOf(...).toEqualTypeOf<T>()能断言类型完全相等,是 Vitest 项目的首选;它运行时是空操作,必须让类型检查真正覆盖到。tsd直接检查.d.ts产物,是发布 npm 包的标准做法,断言写在test-d/*.test-d.ts。- 接进流水线只需两步:
tsc --noEmit覆盖测试文件,或开vitest --typecheck跑*.test-d.ts。 - 警惕假绿:
any会蒙混过关,没被检查的文件永远通过——故意改错一条断言来验证防线是否真的存在。
到这里,「测行为」和「测类型」都齐了。但零散的测试和断言还不足以守住一个工程的底线——覆盖率该定多少、lint 规则怎么配、CI 上哪几道门必须卡住,才是下一节 15.3 覆盖率、lint 与 CI 门禁 的主题。
阅读导航:上一节:15.1 单元测试(Vitest/Jest) · 下一节:15.3 覆盖率、lint 与 CI 门禁 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。