在 ts 的开发中,类型兼容性无处不在。
可是大家在学习 ts 时,往往会忽略类型兼容性的重要性。
不理解类型兼容性,就容易在使用时出现很多无法理解的错误。实际使用时,往往需要对各种类型声明进行融合,而要合理的融合,那么类型兼容性就是关键。
子类型
非常简单的案例,
1let a = 202let b = 3034b = a // ✅ ok
在实践中,将一个变量,赋值给另外一个变量的场景非常多,因此这里就会涉及到,这两个变量的类型比较问题
如果 a 的类型,无法兼容 b 的类型,那么,这样的赋值就存在风险。
上面的例子中,a 与 b 的类型相同,因此可以相互兼容。
如果稍微调整一下,就会出现错误
1let a = 202let b = '500'34b = a // ❌ error
错误提示:不能讲 number 类型分配给 string 类型
如果此时,我们将 b 的类型,进行一个扩展,让 b 的类型从 number 变成 number | string
,就会发现错误消失了
1let a = 202let b: number | string = '500'34b = a // ✅ ok
因为我们可以将 number
类型分配给 number | string
类型。
但是反过来就不行,
1let a = 202let b: number | string = '500'34a = b // ❌ error
原因就是因为 b 的类型可能是一个 string 类型,当其为 string 类型时,就无法分配给 a 的 number 类型。
因此,在这种情况下。被赋值的类型范围,要大于等于赋值的类型范围。
此时我们基于这个例子,来探讨一下子类型的概念。
从语义上,我们可以很容易知道,Person 类,是父类,而 Student 类,是子类。因为 Person 类,概念更宽泛。而 Student 类,概念更具体。
因此,更具体的我们称之为子类。也可以称之为我们这里的子类型。
那么有如下两个类型
1type A = number2type B = number | string
他们谁是子类型,谁是父类型呢?
概念更宽泛的为父类型,也就是 B 概念更具体的为子类型,也就是 A
从概念范围上来说,我们用 A <= B
来表示 A 是 B 的子类型。
在设计模式的章节中,我们学习过里氏替换原则,也可以扩展到我们的类型兼容性里,任何使用父类型的地方,都能够用子类型替换。
因此,我们再来看一下这个例子,b = a
,其实就是子类型,替换父类型。也就是说,子类型能够赋值给父类型。反之则不行。
1let a = 202let b: number | string = '500'34// 子类型、替换 父类型5b = a
再来看一个对象的例子。
首先定义一个数据类型如下
1interface User {2id: string,3name: string,4phone: string,5pwd: string6}
声明一个变量,该变量的类型为 User,具体赋值的字段对象,要比 User 中的多一个
10interface User {20id: string,30name: string,40phone: string,50pwd: string60}7080let defUser1 = {90id: 'xxx1234sd',10name: '张三',11phone: '12312312313',12pwd: '123123',13age: 2014}1516let defUser2 = {17id: 'xxx1234sd',18name: '张三',19phone: '12312312313',20}2122const user: User = defUser1 // ✅ ok23const user2: User = defUser2 // ❌ error
在这里我们要根据概念区分谁是父类型,谁是子类型。
从概念上来说,
字段更少的对象,概念更宽泛,为父类型
字段更多的对象,概念更具体,为子类型
因此,defUser1 <= User <= DefUser2
,defUser1 能够赋值给 User,defUser2 不能赋值给 User。
这里对于子类型与父类型的理解,我们要多思考一下,如果没从概念范围的角度思考清楚,你会觉得好像我们列举的这两个例子明明是反着的,但是结论却又一样,就会很困扰。所以如果没能马上理解,建议回过头多阅读几遍。
函数类型
函数类型兼容性的理解,是一个难点。
首先我们要明确场景。如下场景,并非是把函数当类型进行比较,本质上仍然是比较的基础类型或对象类型。
1type Param = { a: number, b: number }23function foo(p: Param){}45// 试图将父类型赋值给子类型,❌ error6foo({a: 20}) //78// 将子类型赋值给父类型, ✅ ok9foo({a: 20, b: 20, c: 20})
比较函数类型,我们来看一个简单的例子
10let x = (a: number) => {20return a + 130}4050let y = (a?: number) => {60return a ? a + 1: 070}8090x = y // ✅ ok10y = x // ❌ error
函数类型的比较,比的是参数类型与返回值类型。这里返回值类型相同,我们暂时不考虑。参数类型上来看,
xP <= yP
x 的参数类型是 y 参数类型的子类型。按道理来说,函数 x 应该能够赋值给 y。但是事实上恰好相反。
y 才是 x 的子类型。
y <= x
这种情况,我们称之为逆变。
为什么会出现这种情况呢,我们思考一下。
我们把函数 x 赋值给 y 之后,
那么对于 y 的函数类型来说,是可以接收 undefined
作为参数的。
但是,此时 y 的真实函数已经变成了 x。
而 x 的内部实现并没有处理 undefined
.
因此,把 x 赋值给 y,是一个危险操作。
泛型
泛型的兼容性问题,最后落点通常情况下在于泛型变量的类型。
1let p: Array<string> = ['1', '2']2let q: Array<number> = [1, 2]34p = q // ❌ error
而当我们调整一下,就可以搞定,让情况符合第一种基础类型,赋值就能成立
1let p: Array<string | number> = ['1', '2']2let q: Array<number> = [1, 2]34p = q // ✅ ok56// number <= number | string7// 最后是将 number 赋值给 string | number,这是合理的
这种 Comp<T>
的类型兼容性,和 T
的类型兼容保持一致时,我们称之为 协变。
泛型还可以有更复杂的区别,我们看看下一个例子
10interface SuperType {20base: string;30}40interface SubType extends SuperType {50addition: string;60};7080let p: SuperType = { base: 'base' }90let c: SubType = { base: 'base', addition: 'hex' }1011p = c // ✅ ok12c = p // ❌ error1314type Contravariant<T> = (p: T) => void;15let pf: Contravariant<SuperType> = function (p) { }16let cf: Contravariant<SubType> = function (p) { }1718pf = cf; // ❌ error19cf = pf; // ✅ ok 逆变
这种 Comp<T>
的类型兼容性与 T
相反的场景,我们称之为逆变。
出现这种情况的原因,在于函数的特殊性。这个在上面我们已经分析过,就不在继续分析。
双向协变 指的
Comp<T>
类型双向兼容。关于双向协变的理解,我个人认为不用过度解读,在实践中可以通过强制类型的方式来实现双向协变,但是这并非完美的解决方案,因此此处只是做个术语解释,不做强制理解。
在 tsconfig.json
中,我们可以通过配置属性 strictFunctionTypes
来选择是否启用逆变。如果该属性值为 false,规则判断时会使用双向协变,当该属性为 true 时,规则判断会使用逆变。
TypeScript 2.6 之后,strictFunctionTypes
都是默认启用,默认为逆变。