类型变异(Type Variance)广泛存在于强类型的编程语言中,对于没有接触过类似 Java 语言的小伙伴来说,型变似乎有点难以理解。尤其是学习 TypeScript 时,可能会被它的类型系统绕得云里雾里,这篇文章我们就来对比 TypeScript 的类型层级,以及隐藏在幕后的理论------协变与逆变。
TypeScript 中的类型层级
类型的层级关系是静态类型语言的核心概念。首先,我们要弄清楚子类型和超类型。

如上图所示,给定两个类型 A 和 B,假设 B 是 A 的子类型,那么在需要 A 的地方都可以放心的使用 B。
很简单,有点像数学中集合的概念,即 B 是 A 的子集或 B 包含于 A。
如此,我们便可看出 TypeScript 各个类型之间的层级关系。比如:
- Array 是 Object 的子类型;
- Tuple 是 Array 的子类型;
- 所有类型都是 any 的子类型;
- never 是 所有类型的子类型;
- 如果 Brid 类扩展自 Animal 类,那么 Brid 是 Animal 的子类型;
根据前面给出的子类型定义,我们便可得出:
- 需要 Object 的地方都可以使用 Array;
- 需要 Array 的地方都可以使用 Tuple;
- 需要 any 的地方都可以使用 Object;
- never 可在任何地方使用;
- 需要 Animal 的地方都可以使用 Bird;
同理,超类型正好与子类型相反。在上图中,A 就是 B 的超类型。
型变
对于简单的数据类型,还是很容易判断它们的层级的,比如 number 包含在联合类型 number | string 中,那么 number 肯定是它的子类型。
但是对于较为复杂的类型(比如泛型),可能就不太好分析了。比如:
- 什么情况下
Array<A>是Array<B>的子类型? - 什么情况下
对象 A是对象 B的子类型? - 什么情况下函数
(a: A) => B是(c: C) => D的子类型?
会发现,如果一个类型中包含其他类型,使用上述规则就很难判断谁是子类型,而且不同的语言在判断上也不尽相同。
为了便于理解 TypeScript 是怎么做的,我们先做如下规定:
- A ≦ B,指 "A 类型是 B 类型的子类型,或者为同种类型"
- A ≧ B,指 "A 类型是 B 类型的超类型,或者为同种类型"
结构(对象和类)的型变
我们以对象为例,去描述两种类型的用户,一个是已注册用户,它包含 id 和 name,另一个是游客,只有 name:
ts
// 已注册的用户
type Account = {
id: number;
name: string;
}
// 游客
type Visitor = {
name: string;
}
现在实现一个删除用户 id 的代码:
ts
function deletaAccount(user: { id?: number; name: string }) {
delete user.id;
}
const account: Account = {
id: 12345,
name: "Jerry"
};
deletaAccount(account);
deletaAccount() 方法接收一个对象,类型为 { id?: number; name: string },其中 id 是可选的,而传入的实际用户的 id 是确定的 number。所以,id 为 number 的类型是 id 为 number | undefined 的子类型。
因此,Account 作为一个整体是 { id?: number; name: string } 的子类型,所以 TypeScript 不会报错。
不出意外的话,要出意外了。
这里有一个安全问题,我们使用 deletaAccount() 删除了 id,但是 TypeScript 并不知道用户的 id 已被删除,所以 TypeScript 仍然认为 account.id 是 number 类型。

可见,在预期使用超类型的地方,传入了子类型并不安全。但 TypeScript 并没有阻止我们,而是放宽了要求。
那么反过来呢?能不能在预期使用子类型的地方,传入超类型呢?
我们接着添加一个表示旧用户的类型,旧用户的 id 还能是 string 或没有:
ts
type LegacyUser = {
id?: number | string;
name: string;
}
const legacyUser = {
id: 'hahaha',
name: 'Tom'
}
同样做删除操作,此时 TypeScript 报错了。

我们得到的答案是不能。不能在预期使用子类型的地方传入超类型。
TypeScript 的行为是这样的:对预期的结构,可以使用 ≦ 预期类型的子类型结构,但不能使用 ≧ 预期类型的超类型结构。
在类型上,我们就说 TypeScript 对结构(对象和类)的属性进行了协变(covariant)。
即如果想保证 A 对象可以赋值给 B 对象,那么 A 对象的每个属性都必须 ≦ B 对象的对应属性。
其实,协变 只是型变的四种方式之一:
- 不变(Invariant):只能是 T;
- 协变(Covariant):可以是 ≦ T;
- 逆变(Contravariance):可以是 ≧ T;
- 双变(Bivariant ):可以是 ≦ T 或 ≧ T;
在 TypeScript 中,每个复杂类型的成员都会进行协变,包括对象、类、数组和函数的返回类型。
不过有个例外,函数的参数类型进行逆变。
函数的型变
先看函数本身,判断函数 A 是否 ≦ 函数 B,需要满足以下条件:
- 函数 A 的 this 类型未指定,或者 ≧ 函数 B 的 this 类型;
- 函数 A 的各个参数的类型 ≧ 函数 B 的相应参数;
- 函数 A 的返回类型 ≦ 函数 B 的返回类型;
细品几遍,你可能有疑问:
如果函数 A 是 函数 B 的子类型,那么函数 A 的 this 类型 和参数类型必定 ≧ 函数 B 的 this 类型 和参数类型。
但是函数 A 的返回类型却必定 ≦ 函数 B 的返回类型。
为什么两者的型变方向恰恰相反,而不都是 ≦ 哩?
函数返回类型的协变
为了回答这个问题,我们先定义三个 Class 类型(满足 A ≦ B ≦ C 的其他类型也可以):
ts
class Animal {};
class Cat extends Animal {
eat() {}
};
class Tom extends Cat {
catchJerry() {}
}
其中,Tom 是 Cat 的子类型,Cat 是 Animal 的子类型。即 Tom ≦ Cat ≦ Animal。
定义一个参数为 eat 的函数,该函数预期想要一个 Cat 类型的参数:
ts
function eat(cat: Cat): Cat {
cat.eat();
return cat;
}
看看 TypeScript 在校验类型时,允许我们把什么传给 eat() ?
ts
eat(new Animal());
// 类型"Animal"的参数不能赋给类型"Cat"的参数。
// 类型 "Animal" 中缺少属性 "eat",但类型 "Cat" 中需要该属性。
eat(new Cat());
eat(new Tom());
可以传入一个 Cat 实例,或者一个 Tom 实例(因为 Tom 是 Cat 的子类型),目前都能按预期正常工作。
可能有人会纳闷,不是说函数参数是逆变吗?为什么 Animal 不能传进去呢?请注意,上述结论是用来判断两个函数之间是否有层级关系的,而给函数传入参数则是在进行类型的校验,传入的类型必须为预期类型或其子类型,这里不要搞混了。
我们接着往下,再定义一个函数,现在该函数的参数变成了一个回调函数:
ts
function clone(f: (c: Cat) => Cat): void {
// ...
}
clone() 的参数是一个函数,该回调函数的参数是一个 Cat,返回值也是一个 Cat。什么类型的函数可以作为 f 传入呢?
我们控制变量,先测试返回不同的类型,看看有什么结果。
- 传入一个接收
Cat并返回Cat的函数
ts
function catToCat(cat: Cat): Cat {
return cat;
}
clone(catToCat); // OK
- 传入一个接收
Cat并返回Tom的函数
ts
function catToTom(cat: Cat): Tom {
// 这里的报错先无视
return cat;
}
clone(catToTom); // OK
- 传入一个接收
Cat并返回Animal的函数
ts
function catToAnimal(cat: Cat): Animal {
return cat;
}
clone(catToAnimal);
// 类型"(cat: Cat) => Animal"的参数不能赋给类型"(c: Cat) => Cat"的参数。
// 类型 "Animal" 中缺少属性 "eat",但类型 "Cat" 中需要该属性。
第三个报错了,TypeScript 发现返回的 Animal 缺少 Cat 中的某些属性,这可能会导致程序出现错误。因此在编译时,TypeScript 会确保传入的函数至少返回一个 Cat。
clone() 预期一个 catToCat() 类型的回调函数,catToTom() 可以,catToAnimal() 就会报错,显然这三个函数类型层级关系是:catToTom() ≦ catToCat() ≦ catToAnimal()。
这也应证了我们的结论:函数返回类型是协变的,即一个函数的返回值类型必须 ≦ 另一个函数的返回值类型。
函数参数类型的逆变
好,现在来看看回调函数的参数位置。
- 传入一个接收
Animal并返回Cat的函数
ts
function animalToCat(cat: Animal): Cat {
return cat;
}
clone(catToCat); // OK
- 传入一个接收
Tom并返回Cat的函数
ts
function tomToCat(tom: Tom): Cat {
return tom;
}
clone(tomToCat);
// 类型"(tom: Tom) => Cat"的参数不能赋给类型"(c: Cat) => Cat"的参数。
// 参数"tom"和"c" 的类型不兼容。
// 不能将类型"Cat"分配给类型"Tom"。
animalToCat() 的参数类型 Animal 是 Cat 的超类型,符合逆变的特征,所以 animalToCat() 是 catToCat() 的子类型,类型校验通过。
tomToCat() 的参数类型 Tom 是 Cat 的子类型,并不符合逆变的特征,所以 tomToCat() 不是 catToCat() 的子类型,类型校验不通过。
这也很好理解,因为 Tom 有自己独有的技能 .catchJerry(),但不是所有的 Cat 都会抓杰瑞,如果这都不报错,那报错的就是程序了。
这表明,函数不对参数和 this 的类型做型变 。也就是说,一个函数是另一个函数的子类型,必须保证该函数的参数和 this 的类型 ≧ 另一个函数相应参数的类型。
tsconfig 中的 strictFunctionTypes
其实,考虑历史遗留问题,TypeScript 中的函数默认会对参数和 this 的检查采用双变 ,即逆变与协变都被认为是可接受的。如果想像上述示例中那样报错,得手动在 tsconfig.json 中启用 {"strictFunctionTypes": true} 标识。
当然,strict 模式包含 strictFunctionTypes,如果已经设置了 {"strict": true},那就不用再启用 strictFunctionTypes 标识了。
条件类型中的类型推断
以 infer 关键字为例,我们看下型变在泛型的类型推断中的应用。
现在在有条件类型的 extends 子语句中,允许出现 infer 声明,它会引入一个待推断的类型变量。 这个推断的类型变量可以在有条件类型的 true 分支中被引用。 允许出现多个同类型变量的 infer 。
- 在协变位置上,同一个类型变量的多个候选类型会被推断为联合类型
ts
type Foo<T> = T extends { a: infer U, b: infer U } ? U : never;
type T10 = Foo<{ a: string, b: string }>; // string
type T11 = Foo<{ a: string, b: number }>; // string | number
T11 中结果可以是 string 也可以是 number,所以推断为 string | number。
- 在逆变位置上,同一个类型变量的多个候选类型会被推断为交叉类型
ts
type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>; // string
type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>; // string & number
T21 中参数类型既要满足 a 中的 string 又要满足 b 中的 number,所以是 string & number,即 never。
总结
协变意味着类型收窄,逆变意味着类型拓宽。
对于简单数据类型或结构(对象和类)类型而言,类型需要收窄到能确保它最安全的类型。对于函数的返回值同样如此。
只是对于函数的参数而言,参数类型应该拓宽到能确保它最安全的类型(比如至少得拥有相同的基类)。
函数更倾向于范围大的,参数是狗接收狗,参数是动物也能接收狗。 所以这事兼容允许的,但是反过来,狗不能接收其他动物。
从类型安全的角度能更好地理解层级关系,虽然型变的方向有所不同,但目的都是一样的。