TypeScript 类型兼容性的高级规则:协变、逆变与双变性的实际影响

深入讲解 TypeScript 类型兼容性中的协变、逆变与双变性,分析函数参数为何逆变、方法为何保留双变,以及 strictFunctionTypes 在真实工程中的影响与避坑建议。

从一次赋值失败讲起

刚接触 TypeScript 不久的人,很可能被这一行代码卡住:const f: (a: Animal) => void = g; 编译器告诉你 g 的参数类型不兼容。但你把它换成方法语法,编译器又不吭声了。这类问题不只出现在新手身上,很多写了几年 TypeScript 的人,遇到函数参数相关的赋值报错依然靠试错。

AI technology illustration

这篇文章想把类型兼容性里的协变(covariance)、逆变(contravariance)和双变性(bivariance)讲透。搞懂它们不仅是为了通过编译,更是为了理解 TypeScript 为什么会在某些地方睁一只眼闭一只眼,以及这种宽容会在运行时长出什么刺。

假设你已经定义好 Animal 和 Dog 两个类型:

interface Animal {
  name: string;
}

interface Dog extends Animal {
  bark(): void;
}

然后有两个函数类型:

type AnimalFn = (a: Animal) => void;
type DogFn = (d: Dog) => void;

下面这两条赋值语句,一条安全,一条危险:

const acceptAll: AnimalFn = (a) => {
  console.log(a.name);
};

// 安全:acceptAll 能处理所有 Animal,当然也能处理 Dog
const acceptDog: DogFn = acceptAll;

// 危险:目标可能传入 Cat,而 Cat 不是 Dog
const acceptAnimal: AnimalFn = acceptDog; // 编译报错

acceptAll 能被当成 DogFn 使用,而 acceptDog 不能被当成 AnimalFn 使用,说明函数参数位置遵循的是与子类型相反的方向,也就是逆变。

如果把方向反过来看返回值位置,表现就不同了:返回 Dog 的函数当然可以满足期望返回 Animal 的调用方,返回值位置是典型的协变。

先把协变和逆变说清楚

类型兼容性本质上是在问:类型 A 能不能安全地放在需要类型 B 的位置上。如果 Dog 是 Animal 的子类型,那么:

  • Dog 可以直接赋值给 Animal。这是协变。
  • () => Dog 可以赋值给 () => Animal。这是返回值位置上的协变。
  • (a: Animal) => void 可以赋值给 (d: Dog) => void。这是参数位置上的逆变。
  • (d: Dog) => void 不能赋值给 (a: Animal) => void,否则调用方传入 Cat 时,函数内部可能调用 d.bark(),运行时才炸。

用一个通俗的说法:能处理所有动物的函数,当然能处理狗;但只会处理狗的函数,你交给它处理猫,就超出了它的能力边界。

在类型理论中,协变保证数据流出安全,逆变保证数据流入安全。函数同时有输入和输出,所以两个方向的规则都要考虑。

双变性:TypeScript 的折中

TypeScript 真正特殊的地方在双变性。双变性意味着参数位置既允许协变,也允许逆变,只要类型之间存在子类型关系,两个方向都能通过。

这种设计有几个现实原因。TypeScript 是从 JavaScript 生态里长出来的,早期版本把函数参数的检查做得很宽松,用来适配很多 JavaScript 里常见的写法。如果直接改成严格逆变,大量现有代码会被判为不兼容。后来他们在 strictFunctionTypes 里收紧了函数类型参数,但留下了对象方法这个口子——对象方法参数仍然按双变规则检查。

来看一个典型对比:

interface MethodHandler {
  onEvent(event: Animal): void;
}

interface PropHandler {
  onEvent: (event: Animal) => void;
}

const methodImpl: MethodHandler = {
  onEvent(event: Dog) {
    event.bark();
  }
};

const propImpl: PropHandler = {
  onEvent(event: Dog) {
    event.bark();
  }
};

strictFunctionTypes 开启时,methodImpl 仍然能赋值成功,因为 onEvent 是方法;propImpl 则会报错,因为 onEvent 是函数属性,要求实现里的参数必须是 Animal 的父类型或自身,Dog 太窄了。

方法双变性是刻意保留的兼容策略,但代价是类型安全会出现一个黑洞。尤其当方法声明出现在泛型接口里,双变性会被结构兼容性放大,产生更隐蔽的问题。

strictFunctionTypes 开启后,改变有多大

有一定 TypeScript 经验的读者应该已经意识到,strict 并不等于所有规则都变严格。strictFunctionTypes 只约束函数类型的参数,不约束方法声明。下面的表可以直观展示区别:

声明形式 strictFunctionTypes 关闭 strictFunctionTypes 开启
type Fn = (a: Animal) => void 双变 逆变
interface I { f: (a: Animal) => void } 双变 逆变
interface I { f(a: Animal): void } 双变 双变
class C { f(a: Animal): void } 双变 双变

对于大多数函数回调场景,开启 strictFunctionTypes 就能获得严格的逆变检查。但只要把回调声明成方法,检查器就会切回宽松模式。

这里有几个常见误区:

  • 误区一:以为开启 strict 后所有参数都严格逆变,实际上方法不算。
  • 误区二:以为双变性只是历史遗留,没有任何存在理由,其实它是兼容现实生态的取舍。
  • 误区三:以为把方法改成函数属性就万事大吉,还要注意泛型容器的结构兼容性。

工程场景里的真实影响

双变性最典型的坑出现在事件系统和容器类设计中。

先说事件回调。假设你的状态库里事件通道 emit 出去的是 UserEvent,订阅方某个回调却声明成 LoginEvent。如果事件监听接口用的是方法语法,那么 (e: LoginEvent) => void 可以被当成 (e: UserEvent) => void 使用。编译没问题,运行时一旦事件体里没有 loginTime,代码却把它当作 LoginEvent 处理,很容易得到 undefined 或者错误分支。严格逆变下这种赋值会直接报错。

再说泛型容器。看这个极简的 Box 接口:

interface Box<T> {
  set(value: T): void;
}

declare const dogBox: Box<Dog>;
const animalBox: Box<Animal> = dogBox; // 有风险,但可能不报错

animalBox.set({ name: '猫' }); // 运行时破坏 dogBox 的内部约束

set 是方法,所以参数位置使用了双变规则。Box<Dog> 被当作 Box<Animal> 后,外部就可以往里放任意 Animal,但内部逻辑可能默认每个元素都能 bark()。这种问题不会在类型层暴露,只在运行时变成一句 “xxx is not a function”。

另一个常见的场景是数组。数组在 TypeScript 里是协变的,Array<Dog> 可以直接赋值给 Array<Animal>,再配合 push 这类方法参数的双变,让“向狗数组里塞猫”的错误更容易滑过去。虽然这看起来是数组设计的老问题,但本质上仍然是方法双变性在发挥作用。

怎么让类型检查真正严格起来

如果你不希望自己的代码在这些黑洞里出问题,可以按下面的顺序调整。

第一,优先把回调接口定义成函数属性,而不是方法。这是一个立竿见影的变化:

interface Handler {
  // 推荐:函数属性,strictFunctionTypes 会进行逆变检查
  onEvent: (event: UserEvent) => void;
  // 避免:方法声明,参数检查保持双变
  // onEvent(event: UserEvent): void;
}

第二,确保 strictFunctionTypes 是开启的。在 tsconfig 中最好直接打开 strict 全家桶:

{
  "compilerOptions": {
    "strict": true,
    "strictFunctionTypes": true
  }
}

第三,为危险赋值增加类型级断言。用 @ts-expect-error 注释断言某个赋值必须失败,能防止后续重构意外放松这些位置的检查:

// @ts-expect-error 不该允许的赋值
const badHandler: Handler = {
  onEvent(event: LoginEvent) {
    event.loginTime;
  }
};

如果你维护的是比较老的项目,暂时无法开启 strictFunctionTypes,至少也要在新增代码里使用函数属性而不是方法。代码评审时,可以盯住那些“回调参数比声明更具体”的写法,它们往往是潜在的类型漏洞。

写在最后

类型兼容性不是数学定理,而是工程约束和兼容性压力的平衡。TypeScript 把函数参数设计成逆变,是为了安全;把方法参数保留成双变,是为了现实。你能做的最有效的事,就是认清哪些位置会被宽松检查,然后通过接口设计和配置,把危险区尽量缩小。

以后再遇到函数参数相关的报错,你至少能分辨它是负责任的安全护栏,还是双变性留下的历史包袱。至少,你能知道该看哪里。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/659/

(0)
上一篇 1天前
下一篇 1天前

相关推荐