《TypeScript编程入门》15.2 类型测试(tsd/expect-type)

本节讲一个常被忽视的话题:类型本身也要写测试。先说明运行时断言为什么测不到类型,再用 @ts-expect-error 给出零依赖方案,随后系统讲解 expect-type 与 tsd 两套断言库的 API、配置与接入流水线的方式,并以手写工具类型 DeepPartial 为例演示如何为类型写回归测试。读完你能为发布给团队或社区的库建立一道类型契约防线。

本节目标:读完这一节,你能解释「为什么单元测试覆盖不了类型」,用 @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 门禁 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes