深入理解 TypeScript 的协变与逆变:为什么你的函数类型总是不兼容

你是否遇到过这样的场景:明明两个类型看起来“差不多”,TypeScript 却偏偏报错说它们不兼容?尤其是在处理函数类型的赋值、回调参数、泛型约束时,编译器仿佛在和你作对。很多时候,问题的根源并不在于你写错了类型,而在于你忽略了类型系统中的一个核心概念——型变(Variance),具体来说就是协变(Covariance)逆变(Contravariance)

本文将从实际代码出发,逐步拆解 TypeScript 中协变与逆变的规则,帮你彻底理解为什么函数类型总是不兼容,以及如何写出更健壮的类型定义。

一、什么是型变?

型变描述的是:当类型 A 是类型 B 的子类型时,由 A 和 B 构造出的更复杂类型(如 Array<A>(x: A) => void)之间是否仍然保持这种子类型关系。

简单来说,型变回答的问题是:“子类型关系能否安全地传递到复合类型中?”

TypeScript 中主要涉及两种型变:

  • 协变(Covariant)AB 的子类型,则 F<A> 也是 F<B> 的子类型。方向一致。
  • 逆变(Contravariant)AB 的子类型,则 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()) 时,内部把狗当作动物处理,完全没问题。

这就是函数参数逆变:如果 DogAnimal 的子类型,那么 (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; // 不变
}

对于 ProducerProducer<Dog> 可以赋值给 Producer<Animal>;对于 ConsumerConsumer<Animal> 可以赋值给 Consumer<Dog>;而 ProducerConsumer 则要求类型完全一致。

TypeScript 4.7 引入了 inout 修饰符,允许你显式标注型变:

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 的地方,反而会报错,因为参数类型太具体了。

八、总结

位置 型变 方向
属性/返回值 协变 子类型关系保持一致
函数参数 逆变 子类型关系反转
方法参数 双变 两个方向都允许
同时读写 不变 必须完全相同

记住三句话:

  1. 返回值可以“更具体”:狗工厂可以当动物工厂用。
  2. 参数必须“更宽泛”:能处理动物的函数才能处理狗。
  3. 方法参数是例外:为了兼容性,TypeScript 对方法参数放宽为双变。

理解协变与逆变,不仅能帮你快速定位类型不兼容的根源,还能让你在设计泛型接口、回调类型时更加得心应手。下次再遇到“类型不兼容”的报错,不妨先问自己:这个位置是参数还是返回值?方向对了吗?

未经允许不得转载:任鹏个人博客 » 深入理解 TypeScript 的协变与逆变:为什么你的函数类型总是不兼容

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏