你是否遇到过这样的场景:明明两个类型看起来“差不多”,TypeScript 却偏偏报错说它们不兼容?尤其是在处理函数类型的赋值、回调参数、泛型约束时,编译器仿佛在和你作对。很多时候,问题的根源并不在于你写错了类型,而在于你忽略了类型系统中的一个核心概念——型变(Variance),具体来说就是协变(Covariance)与逆变(Contravariance)。
本文将从实际代码出发,逐步拆解 TypeScript 中协变与逆变的规则,帮你彻底理解为什么函数类型总是不兼容,以及如何写出更健壮的类型定义。
一、什么是型变?
型变描述的是:当类型 A 是类型 B 的子类型时,由 A 和 B 构造出的更复杂类型(如 Array<A>、(x: A) => void)之间是否仍然保持这种子类型关系。
简单来说,型变回答的问题是:“子类型关系能否安全地传递到复合类型中?”
TypeScript 中主要涉及两种型变:
- 协变(Covariant):
A是B的子类型,则F<A>也是F<B>的子类型。方向一致。 - 逆变(Contravariant):
A是B的子类型,则F<B>是F<A>的子类型。方向相反。
此外还有双变(Bivariant)和不变(Invariant),但本文聚焦前两者。
二、协变:直观且符合直觉
先看一个最简单的协变例子:
interface Animal { name: string }
interface Dog extends Animal { breed: string }
let animal: Animal;
let dog: Dog = { name: '旺财', breed: '中华田园犬' };
animal = dog; // ✅ 合法:Dog 是 Animal 的子类型
这很自然:狗一定是动物,所以把狗赋值给动物变量是安全的。
对于数组、Promise、只读属性等,TypeScript 默认采用协变:
let animals: Animal[] = [];
let dogs: Dog[] = [{ name: '旺财', breed: '中华田园犬' }];
animals = dogs; // ✅ 合法,数组是协变的
但注意:数组的协变在严格意义上是不安全的,因为你可以向 animals 中推入一个 Cat,而 dogs 实际上指向的是同一个数组。TypeScript 为了实用性允许了这种协变,但在某些场景下会通过 readonly 来避免问题。
三、逆变:函数参数的“反直觉”规则
真正让开发者困惑的是函数类型。先看一个例子:
type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;
let handleAnimal: AnimalHandler;
let handleDog: DogHandler;
handleAnimal = handleDog; // ❌ 报错?
handleDog = handleAnimal; // ✅ 合法?
很多人第一反应是:DogHandler 只能处理狗,而 AnimalHandler 能处理所有动物,所以 handleDog = handleAnimal 应该合法,反过来不合法。但 TypeScript 的结论恰恰相反——handleDog = handleAnimal 是合法的,而 handleAnimal = handleDog 是非法的。
为什么?让我们从安全性角度分析。
假设 handleAnimal = handleDog 被允许。那么你可以这样调用:
handleAnimal(new Cat()); // 传入一只猫
但此时 handleAnimal 实际指向的是 handleDog,它内部可能访问 dog.breed,而猫没有 breed 属性,运行时就会出错。因此,把“只能处理狗”的函数赋值给“能处理所有动物”的函数变量是不安全的。
反过来,handleDog = handleAnimal 是安全的:handleAnimal 能处理任何动物,自然也能处理狗。调用 handleDog(new Dog()) 时,内部把狗当作动物处理,完全没问题。
这就是函数参数逆变:如果 Dog 是 Animal 的子类型,那么 (animal: Animal) => void 是 (dog: Dog) => void 的子类型。参数类型的方向与整体类型的方向相反。
用一句话总结:函数参数类型越宽泛,函数类型越“具体”(子类型);参数类型越具体,函数类型越“宽泛”(父类型)。
四、返回值协变
与参数相反,函数返回值是协变的:
type AnimalFactory = () => Animal;
type DogFactory = () => Dog;
let createAnimal: AnimalFactory;
let createDog: DogFactory;
createAnimal = createDog; // ✅ 合法:狗也是动物
createDog = createAnimal; // ❌ 非法:动物不一定是狗
返回值方向与整体类型方向一致,符合直觉。
五、TypeScript 中的 strictFunctionTypes
在 TypeScript 2.6 之前,函数参数默认是**双变(Bivariant)**的,即上面两种赋值都被允许。这虽然灵活,但不安全。从 2.6 开始,在 strictFunctionTypes 开启(strict: true 默认包含)的情况下,函数类型(非方法)的参数位置严格按逆变检查。
注意一个细节:方法(method)的参数仍然是双变的。例如:
interface Comparer<T> {
compare(a: T, b: T): number;
}
let animalComparer: Comparer<Animal>;
let dogComparer: Comparer<Dog>;
animalComparer = dogComparer; // ✅ 方法参数双变,允许
dogComparer = animalComparer; // ✅ 也允许
这是因为方法通常设计为可重写,双变提供了更大的灵活性。而函数属性则严格逆变:
interface Comparer<T> {
compare: (a: T, b: T) => number;
}
let animalComparer: Comparer<Animal>;
let dogComparer: Comparer<Dog>;
animalComparer = dogComparer; // ❌ 严格模式下报错
dogComparer = animalComparer; // ✅ 合法
理解这一点,能解释很多“为什么接口方法可以赋值,但函数属性却不行”的疑惑。
六、泛型中的型变
泛型类型参数的型变取决于其使用方式:
- 只出现在返回值位置 → 协变
- 只出现在参数位置 → 逆变
- 同时出现在两者 → 不变(Invariant)
例如:
interface Producer<T> {
produce(): T; // 协变
}
interface Consumer<T> {
consume(value: T): void; // 逆变
}
interface ProducerConsumer<T> {
produce(): T;
consume(value: T): void; // 不变
}
对于 Producer,Producer<Dog> 可以赋值给 Producer<Animal>;对于 Consumer,Consumer<Animal> 可以赋值给 Consumer<Dog>;而 ProducerConsumer 则要求类型完全一致。
TypeScript 4.7 引入了 in 和 out 修饰符,允许你显式标注型变:
interface Producer<out T> {
produce(): T;
}
interface Consumer<in T> {
consume(value: T): void;
}
这能帮助编译器更精确地检查,也能提升性能。
七、实战:为什么你的回调总是不兼容?
回到最常见的痛点:使用数组方法或事件回调时,类型不匹配。
const dogs: Dog[] = [{ name: '旺财', breed: '中华田园犬' }];
// forEach 期望 (value: Dog, index: number, array: Dog[]) => void
dogs.forEach((animal: Animal) => {
console.log(animal.name);
}); // ✅ 合法:参数逆变,Animal 比 Dog 更宽泛
上面是合法的,因为回调参数可以接受更宽泛的类型。但反过来:
const animals: Animal[] = [{ name: '旺财' }, { name: '咪咪' }];
animals.forEach((dog: Dog) => {
console.log(dog.breed);
}); // ❌ 报错:Animal 不能赋值给 Dog
这很合理:数组中可能有猫,回调却要求狗,不安全。
再看一个常见错误:
type Handler = (event: MouseEvent) => void;
function addHandler(handler: Handler) { /* ... */ }
addHandler((event: Event) => {
console.log(event.type);
}); // ✅ 合法:Event 比 MouseEvent 更宽泛
如果你写成 (event: MouseEvent) => void 传给期望 (event: Event) => void 的地方,反而会报错,因为参数类型太具体了。
八、总结
| 位置 | 型变 | 方向 |
|---|---|---|
| 属性/返回值 | 协变 | 子类型关系保持一致 |
| 函数参数 | 逆变 | 子类型关系反转 |
| 方法参数 | 双变 | 两个方向都允许 |
| 同时读写 | 不变 | 必须完全相同 |
记住三句话:
- 返回值可以“更具体”:狗工厂可以当动物工厂用。
- 参数必须“更宽泛”:能处理动物的函数才能处理狗。
- 方法参数是例外:为了兼容性,TypeScript 对方法参数放宽为双变。
理解协变与逆变,不仅能帮你快速定位类型不兼容的根源,还能让你在设计泛型接口、回调类型时更加得心应手。下次再遇到“类型不兼容”的报错,不妨先问自己:这个位置是参数还是返回值?方向对了吗?
未经允许不得转载:任鹏个人博客 » 深入理解 TypeScript 的协变与逆变:为什么你的函数类型总是不兼容


朋友圈点赞图在线生成源码