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

这篇文章想把类型兼容性里的协变(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/