给团队做次TypeScript分享(四)—— 类型兼容性和协变、逆变、双向协变

667 阅读5分钟

类型兼容性

结构类型

ts 的类型兼容性是基于结构类型的,即基于类型的组成结构(鸭式辨型法)。

与基于名义类型不同,ts中数据类型的兼容性或等价性不需要通过明确的声明或类型的名称来决定的。

例如官网的例子

interface Named {
    name: string;
}

class Person {
    name: string;
}

let p: Named;
p = new Person(); // ok

例如上面的例子是可以正常运行的,原因是 Named 和 Person 的结构类型是一致的。

变量赋值检查

有变量 a 和 变量 b,如果 b 要赋值给 a,那么需要检查 a 的类型的属性是否在 b 类型中都有对应的属性

let a: { name: string } = { name: "jack" };
let b: { name: string; age: number; gender: string } = {
  name: "jack",
  age: 19,
  gender: "man",
};
a = b; // ok b类型有a
b = a; // error 类型a 缺少类型b 中的以下属性: age, gender

函数参数的赋值检查和变量赋值检查同理

function fun(user: { name: string }) {}
let myUser = {
  name: "jack",
  age: 19,
  gender: "man",
};
fun(myUser); // ok

这里注意,在上面例子中,如果如下调用函数会报错

fun({
  name: "jack",
  age: 19,
  gender: "man", 
}); // error

如果用上面的写法,在参数中写对象相当于直接给 user 参数赋值,这时候会遵循严格的类型定义,但如果先用一个变量接收(例如上面的myUser),就可以不经过额外属性检查。因为这时候相当于把 myUser 这个变量赋值给 user,根据上面的 变量赋值类型兼容性,可以赋值成功,这样就可以绕开额外类型检查。

函数参数个数比较

let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

y = x; // OK
x = y; // Error `y`有个必需的第二个参数,但是`x`并没有,所以不允许赋值。

两个函数x和y,如果函数x要赋值给函数y,函数x的每个参数都要在函数y中可以找到对应类型的参数。

这是由于在 js 中,额外的参数可以忽略不传。

例如

let x = (a: number):void => {
  console.log(a)
};
let y = (b: number, s: string):void => {
  console.log(b+s)
};
y = x // ok
y(1, 'a') // 这时候调用,因为函数y已经被赋值为 (a: number) => void; 但是其类型还是(b: number, s: string) => void,所以还是要传两个参数,但是调用是只会用到第一个参数,这时候程序可以正常执行,打印结果是1

想象一下,如果 y 可以赋值给 x,这时候根据 x 的类型 x 只能传入一个参数,而 y 需要两个参数,这时候运行函数就会报错

协变、逆变、双向协变

简单解释就是 假如 类型A 是 类型B 的子类型(类型系统中,类型属性更具体的是子类型,属性更少则说明该类型约束的更宽泛,称为父类型)

  • 协变:指 A 可以赋值给 B
  • 逆变:与协变相反, 指 B 可以赋值给 A
  • 双向协变:指既可以协变也可以逆变

协变

举个简单例子

class Animal {
  name: string | undefined
}

class Bird extends Animal {
  fly(){}
}

let val: Animal = new Animal()
val = new Bird() // ok 协变

上面的变量赋值检查也是协变

在比较两个函数时,先不考虑参数的情况下,类型系统规定源函数的返回值类型必须是被赋值函数返回值类型的子类型,即协变。

let x = () => ({name: 'Alice'});
let y = () => ({name: 'Alice', location: 'Seattle'});

x = y; // OK
y = x; // Error

let fun1 = ()=> Animal
let fun2 = ()=> Bird

fun1 = fun2 // ok 子类型可以赋值给父类型
fun2 = fun1 // Error 父类型不可以赋值给子类型

逆变、双变

逆变和双变只能针对函数的赋值

下面主要分析函数赋值中对参数的检查,返回值类型的检查,在上面函数比较中已经说明清楚了

按照的 tsconfig.json 说到的 strictFunctionTypes 配置,

首先在默认情况下 strictFunctionTypes 设置为 false,也就是支持函数参数双向协变的情况下

// --strictFunctionTypes : fasle
class Animal {
  name: string | undefined
}

class Bird extends Animal {
  fly(){}
}

let fun1: (x: Animal) => void = (x: Animal)=>{}
let fun2: (x: Bird) => void = (x: Bird)=>{}

fun1  = fun2 // ok
fun2  = fun1 // ok

如果开启了 strictFunctionTypes 设置为 true,则

// --strictFunctionTypes : true
fun1  = fun2 // error
fun2  = fun1 // ok

首先,在不考虑函数返回值类型的情况下,我们分析一下:

在 fun1 = fun2 中,也就是 fun2(参数类型 Bird ) 赋值 给 fun1(参数类型 Animal),这是协变

在 fun2 = fun1 中,也就是 fun1(参数类型 Animal ) 赋值 给 fun2(参数类型 Bird ),相反,这是逆变

可以发现,开启 strictFunctionTypes 后,参数逆变是支持的,协变错误了,为什么这样子对于我们的程序来说是更安全的呢?

举个例子

class Animal {
  name: string | undefined
}
class Bird extends Animal {
  fly(){}
}
class Fish extends Animal {
  swim(){}
}

let fun1: (x: Animal) => void = (x: Animal)=>{
  console.log(x.name)
}

let fun2: (x: Bird) => void = (x: Bird)=>{
  x.fly()
}
fun1 = fun2
let myFish = new Fish()
fun1(myFish) // 运行程序报错,x.fly is not a function

在上面的例子中,在函数参数类型支持协变的情况下,我们将 fun2 赋值给 fun1 ,即 fun1 变成 了 (x)=>{x.fly()},但是fun1的类型还是(x: Animal) => void,所以我们可以传入 new Fish() 作为参数,这时候运行函数由于 Fish 上没有 fly 方法,所以程序执行报错了。

所以在日常开发中,最好开启严格模式,禁用函数参数双向协变检查。