「Typescript之旅」: 彻底理解Typescript中的逆变和协变最近在更新《在Typescript中旅行》的专
今日更文《「Typescript之旅」: 彻底理解Typescript中的逆变和协变》。
协变、逆变和不变简介
协变和逆变都是术语,协变指的是能够使用比原来类型的派生程度更大(更具体的)的类型,逆变指的是能够使用比原来类型的派生程度更小(不太具体的)的类型 。 还有一种是不变,也有人称之为固定类型,也就是使用最初定义指定的类型。固定类型既不是协变也不是逆变。
在Typescript中函数支持协变和逆变,在分配和使用类型时提供更大的灵活性,函数的参数具有逆变的特性,返回值则具有协变的特性。所谓逆变指的是父类型可以赋给子类型。使用ts声明函数类型时函数参数类型是子类型,实际可以赋值的函数参数是父类型。 而协变指的是返回值的子类型可以赋值给父类型。
简单介绍ts的类型兼容性
在写代码时,我们会经常做赋值操作,但是在类型系统中,赋值操作也是要符合规则的,比如一个Dog类型的变量,不能赋值给一个Cat类型的变量,一个number类型的变量不能赋值给一个boolean类型的变量,这就是类型的不兼容。

一个number类型的变量是肯定是可以赋值给一个number类型的变量的,这就是类型的兼容。

但是在庞大的类型系统中,类型之间的兼容性肯定不是那么简单,而在ts的类型系统中支持了父子类型,其基本规则是:子类型可以赋值给父类型。在下面的代码中因为extends
的原因,Child
与Parent
有了父子关系,成为父子类型。

在ts中基本规则是子类型是可以赋值给父类型,但是父类型不可以赋值给子类型。
并不只靠extends
构建父子关系,ts作为结构化类型系统,只要"长的像"也可以是父子关系。

上图中Child是Parent的子类型,同样的是子类型可以赋值给父类型,但是父类型不可以赋值给子类型。
协变
协变就是指上文中频频提到的“子类型可以赋值给父类型”, 在函数中的返回值是协变的。

返回值为啥是协变的也好理解,我们调用函数获取返回值。
const c1 = getChild()
c1.rich1000()
如果getChild定义了返回值是Child类型,但是返回的是Parent类型,在调用rich1000
方法的时候,就会发生运行时报错,因为Parent类型上,没有rich1000
这个方法啊。
在看一个例子:

定义xiebian函数时返回值类型为Child,实现xiebian函数时,返回值类型为Parent类型,返回值父类型不可以赋值给返回值子类型,报错❌。
定义xiebian1函数时返回值类型为Parent,实现xiebian1函数时,返回值类型为Child类型,返回值子类型可以赋值给返回值父类型,正确✅。
逆变
从字面意思来看,逆变就是将类型的可变化规则倒过来,即父类型可以赋值给子类型,在ts中函数的参数具有逆变这种特性。

我们定义函数的参数类型是Parent,实现函数的参数定义为Parent的子类型Child,报错❌,但是如果我们将父类型赋值给子类型
并没有报错,类型安全✅,那么函数参数为啥是逆变的呢?
假如函数参数符合子类型可以赋值给父类型这个规则

那么instance作为Child类型,可以调用rich1000,但是Parent中没有rich1000这个方法,没有起到类型保护的作用,类型不安全❌!
但是如果定义函数类型的时候参数是子类型,实际函数的参数类型是父类型呢?

参数可以调用rich100这个方法,而Child中也有这个方法,类型是安全的✅,这就是在ts中函数的参数的逆变特性。
总结函数逆变和协变的特性
为什么协变是返回值的兼容性,逆变是参数的兼容性?
协变:当调用xiebian1函数的时候, const result = xiebian1()
, 返回的变量是Parent类型的,要保证返回变量中需要能访问Parent中的属性,所以返回Child没有问题,Parent中的属性Child中都存在,类型安全✅。
逆变:当调用nibian1函数的时候, nibian1(xxx)
, xxx函数的参数定义时是Child类型的,实现时要保证xxx中的属性不可越过Child属性这个范畴。所以参数是Parent,Child的范围中包含Parent,类型安全✅。
如果反之:
协变:当调用xiebian函数的时候, const result = xiebian()
, 返回的变量是Child类型的,如果实际定义的函数返回Parent类型,当使用result变量时,缺少属性 "rich1000",类型不安全❌。
逆变:当调用xiebian函数的时候, xiebian(xxx)
, xxx函数的参数定义时是Parent类型的,如果实际定义的函数是Child类型的,当在函数体里面访问参数中的属性时,rich1000不在定义的Parent类型中,类型不安全❌。
在传递函数时,A =》B , A函数参数的个数要比B函数少(这个暂未在本文中提到相关的概念),A函数参数可以是B函数参数的父类,A函数参数的返回值可以是B函数参数返回值的子类。
协变和逆变的公式
我们可以使用**“类型体操”**来表示协变和逆变的公式:
type Arg<T> = (arg: T) => void
type Return<T> = (arg: any) => T
type ArgNB = Arg<Parent> extends Arg<Child> ? true : false // true 符合逆变
type ReturnXB = Return<Child> extends Return<Parent> ? true : false // true 符合逆变
type ArgNB = Arg<Child> extends Arg<Parent> ? true : false // false 不符合逆变
type ReturnXB = Return<Parent> extends Return<Child> ? true : false // false 不符合逆变
ArgNB判断是否符合逆变的特性,ReturnXB判断是否符合协变的特性。
定义接口中函数时逆变和协变的表现(值得一看)
ts中有很多种类型(内置类型,自定义类型等等),要保证类型的安全,而且类型之间的操作也需要保持一定的灵活性,所以在父子类型之间相互赋值时会引起型变。这也就是上文我们说的逆变和协变的概念。ts型变的设计很安全,但是还有一种情况函数协变会引发运行时报错的问题。难道与标题中的接口有关?先不着急,一步一步来,先来看在ts语法中如果我们想在接口类型中定义函数类型的话,有两种方法:
interface Obj {
// 函数简写语法
version1(param: string): void;
// 对象属性语法
version2: (param: string) => void;
}
有啥区别?
当函数是简写形式的时候该函数是原型方法。
当函数是对象属性形式的时候该函数是实例方法。
记住这个特性。
ts函数协变引发的运行时报错
假设我们在开发一款宠物交易网站,可以购买喜欢的宠物。为了保证前端代码的可维护与扩展性采用了Typescript,在项目中定义了下面这个类型:
interface Animal {
name: string
eat: () => void
}
interface Dog extends Animal {
bark(dog: Dog): void
}
interface YellowDog extends Dog {
// 大黄狗是友善的
friendly: () => void
}
我们在业务代码中创建两只狗与一只大黄狗:
const dog1: Dog = {
name: '狗狗',
eat() {
console.log('吃骨头')
},
bark(dog: Animal) {
console.log(dog.name + 'wangwang')
}
}
const dog2: Dog = {
name: '狗狗',
eat() {
console.log('吃骨头')
},
bark(dog: Dog) {
console.log(dog.name + 'wangwang')
}
}
const xiaohuang: YellowDog = {
name: '小黄',
eat() {
console.log('吃狗粮')
},
bark(dog: YellowDog) {
console.log(dog.friendly() + dog.name + 'wuwu')
},
friendly() {
console.log('友善的小黄')
}
}
观察bark函数👀,在ts中,函数参数是逆变的。所以dog1的bark方法参数可以传入父类型Animal(父可以赋值给子)。
dog1的bark方法中只能调用Animal类型中的方法,类型安全,没啥问题,如下图所示可以调用eat方法和name属性。

dog2的bark方法参数传入的就是Dog类型,这个也没啥问题,此时dog2的bark方法能调用Dog类型中的方法。

但是xiaohuang的bark方法参数是YellowDog类型(ts没有报错),我们在代码中调用xiaohuang的bark方法。

我们知道dog1是没有friendly
这个方法的,所以红框中的这行代码在运行时肯定会报错,而且是
TypeError: dog.friendly is not a function

防不胜防
我们倒推回来,问题就出在xiaohuang的bark方法参数的类型定义为YellowDog类型,是这行代码的问题,因为我们知道ts默认的类型检查模式中函数的参数是逆变的,只有在关闭strictFunctionTypes的情况下是双变的,演示的代码ts配置中已经开启了"strictFunctionTypes"。所以为什么在这里会双变了呢?
先说结论:***在接口类型中使用简写形式来定义方法,方法的参数是双变的。很可能因为类型的使用不当会产生运行时报错。***所以解决起来也很简单: 将方法定义成对象属性的形式

这样写ts会关闭参数双变,只会进行逆变。
函数简写形式参数会开启双变(方法参数可以接受更宽或者更窄的类型),但是对象形式的方法不会进行双变,只会逆变(只接受更窄的类型)。(在开启strictFunctionTypes的情况下)。
还记得我们上文中说这句话吗?

在js中原型方法是不受约束的,更灵活,ts会更加宽松。
在typescript-eslint中有method-signature-style这样一条规则可以强制要求使用特定的方法签名语法。这条规则的默认值是property
, 强制使用对象属性的写法定义方法。当开启了TypeScript 的严格模式时会最大程度上保证正确性。
在.eslintrc.cjs中配置
module.exports = {
"rules": {
"@typescript-eslint/method-signature-style": "error"
}
};
所以在我们日常开发中尽量避免使用函数的简写形式。
最后
本篇文章讲述了Typescript的逆变和协变,而且通过定义接口中函数时逆变和协变的表现更加了解这两者的区别,也提出了我们在日常业务写接口时定义函数的最佳实践。
参考文献
[1]. TypeScript官网
转载自:https://juejin.cn/post/7379117970797461558