# 函数的协变与逆变

# 从 equal 中说起

export type Equals<X, Y> = (<T>() => T extends X ? 1 : 2) extends <
  T
>() => T extends Y ? 1 : 2
  ? true
  : false;

经验老道的程序员都见过这样一段神奇的代码。

这段代码定义了一个名为 Equals 的类型别名。它使用了 TypeScript 的类型推断和类型扩展来检查两个类型 XY 是否完全相同。

最早出现在这位大佬的讨论中 这里

具体解释可以参考这里 (opens new window)

这一段关于函数的比较让我想起了关于函数的协变和逆变

# 函数的签名类型

对于函数类型比较,事实上我们要比较的是 参数类型 与 返回值类型。

让我们进入一个场景

class Animal {}

class Dog extends Animal {
  bark() {}
}

class Husky extends Dog {
  cool();
}

我们明确了两点

  • Dog 是 Animal 的子类,Animal 是 Dog 的父类
  • Husky 是 Dog 的子类,Dog 是 Husky 的父类

当我们有一个函数 接收的是 Dog 类型 ,返回的是 Dog 类型时可以写成这样

type DogFactory = (args: Dog) => Dog;

下面我简化为 Dog->Dog

因为对于函数的比较我们比较 参数类型 和 返回值类型

所以 我们会有以下几种情况

在比较函数的参数的时候。如果一个值能够被赋值给某个类型的变量,那么可以认为这个值的类型为此变量类型的子类型。

比如 :

function makeDogBark(dog: Dog) {
  dog.bark();
}

makeDogBark(new Husky()); // 没问题
makeDogBark(new Animal()); // 不行

这个函数就只能接受 Dog 类型或者 Dog 类型的子类型,而不接受 Dog 类型的父类型

严格的说,因为派生类会保留基类的方法和属性,因此其与基类类型兼容。

里氏替换原则:子类可以扩展父类的功能,但不能改变父类原有的功能,子类型(subtype)必须能够替换掉他们的基类型(base type)。

回到这个函数,他会实例化一个狗,然后叫两下。实际上这个函数同时约束了参数的类型和返回值的类型。首先必须是一个狗,而且返回的也是一个狗。 Dog->Dog

对于这两条约束依次进行检查:

对于Animal/Dog/Corgi -> Animal 类型,无论穿什么都不满足条件 ,因为返回的是 Animal 类型 不一定是 Dog 类型

对于 Husky -> Husky 与 Husky -> Dog,其返回值满足了条件,但是参数类型又不满足了。这两个类型需要接受 Husky 类型。但我们可没说一定会传入哈士奇,如果我们传个德牧,程序可能就崩溃了。

对于Dog -> Corgi、Animal -> Corgi、Animal -> Dog,首先它们的参数类型正确的满足了约束,能接受一只狗狗。其次,它们的返回值类型也一定会是一条狗。 狗满足了动物的类型。

而实际上,如果我们去掉了包含 Dog 类型的例子,会发现只剩下 Animal -> Corgi 了,也即是说,(Animal → Corgi) ≼ (Dog → Dog) 成立(A ≼ B 意为 A 为 B 的子类型)。

所以结论:

  • 参数类型允许为 Dog 的父类型,不允许为 Dog 的子类型。
  • 返回值类型允许为 Dog 的子类型,不允许为 Dog 的父类型。

# 协变与逆变

(Animal → Husky) ≼ (Dog → Dog) 成立(A ≼ B 意为 A 为 B 的子类型)。

考虑 Husky ≼ Dog ≼ Animal

当有函数类型 Dog -> Dog,仅有 (Animal → Corgi) ≼ (Dog → Dog) 成立(即能被视作此函数的子类型,)。这里的参数类型与返回值类型实际上可以各自独立出来看:

  1. Husky ≼ Dog 假设我们对其进行返回值类型的函数签名类型包装,则有 (T → Corgi) ≼ (T → Dog),也即是说,在我需要狗狗的地方,哈士奇都是可用的。即不考虑参数类型的情况,在包装为函数签名的返回值类型后,其子类型层级关系保持一致。
  2. 考虑 Dog ≼ Animal (狗是 Anilmal 的子类型),如果换成参数类型的函数签名类型包装,则有 (Animal -> T) ≼ (Dog -> T),也即是说,在我需要条件满足是动物时,狗狗都是可用的。即不考虑返回值类型的情况,在包装为函数签名的参数类型后,其子类型层级关系发生了逆转。

实际上,这就是 TypeScript 中的协变( covariance ) 与逆变( contravariance ) 在函数签名类型中的表现形式。这两个单词最初来自于几何学领域中:随着某一个量的变化,随之变化一致的即称为协变,而变化相反的即称为逆变。

简单的说:我需要参数是基类的类型,你传子类是可以的,我需要返回是基类的类型,你返回子类是可以的

用 TypeScript 的思路进行转换,即如果有 A ≼ B ,协变意味着Wrapper<A> ≼ Wrapper<B>,而逆变意味着 Wrapper<B> ≼ Wrapper<A>.

type AsFuncArgType<T> = (arg: T) => void;
type AsFuncReturnType<T> = (arg: unknown) => T;

再使用这两个包装类型演示我们上面的例子:

// 1 成立:(T -> Corgi) ≼ (T -> Dog)
type CheckReturnType = AsFuncReturnType<Corgi> extends AsFuncReturnType<Dog>
  ? 1
  : 2;

// 2 不成立:(Dog -> T) ≼ (Animal -> T)
type CheckArgType = AsFuncArgType<Dog> extends AsFuncArgType<Animal> ? 1 : 2;

进行一个总结:

函数类型的参数类型使用子类型逆变的方式确定是否成立,而返回值类型使用子类型协变的方式确定。

# StrictFunctionTypes 配置

strictFunctionTypes (opens new window):在比较两个函数类型是否兼容时,将对函数参数进行更严格的检查(When enabled, this flag causes functions parameters to be checked more correctly),而实际上,这里的更严格指的即是 对函数参数类型启用逆变检查。

如果启用了这个配置才是逆变检查,那么原来是什么样的?

  • 原来是双变的

在实际场景中的逆变检查又是什么样的?

总结: 现在学习了 TypeScript 函数类型的兼容性比较,这应该带给了你一些新的启发:原来不只是原始类型、联合类型、对象类型等可以比较,函数类型之间同样是能够比较的。而对我们开头提出的,如何对两个函数类型进行兼容性比较这一问题,我想你也有了答案:

比较它们的参数类型是否是反向的父子类型关系,返回值是否是正向的父子类型关系。也就是判断参数类型是否遵循类型逆变,返回值类型是否遵循类型协变

我们可以通过 TypeScript ESLint 的规则以及 strictFunctionTypes 配置,来为 interface 内的函数声明启用严格的检查模式。如果你的项目内已经配置了 TypeScript ESLint,不妨添加上 method-signature-style 这条规则来让你的代码质量更上一层楼。