类型变异(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
。
总结
协变意味着类型收窄,逆变意味着类型拓宽。
对于简单数据类型或结构(对象和类)类型而言,类型需要收窄到能确保它最安全的类型。对于函数的返回值同样如此。
只是对于函数的参数而言,参数类型应该拓宽到能确保它最安全的类型(比如至少得拥有相同的基类)。
函数更倾向于范围大的,参数是狗接收狗,参数是动物也能接收狗。 所以这事兼容允许的,但是反过来,狗不能接收其他动物。
从类型安全的角度能更好地理解层级关系,虽然型变的方向有所不同,但目的都是一样的。