类型兼容性 -- Typescript基础篇(15)

1,022 阅读7分钟

我们为不同的变量定义了类型,所以不能像js一样,为一个变量自由赋值。类型兼容性就是描述一个类型能否被赋值为其他类型。兼容的本质是保证变量被赋值为其他类型的值,该变量在后续过程中仍能正常使用

对于基础类型,他们的兼容性十分简单,在基础类型中也有提到,就不再赘述:

let a: number = 1;
a = "str"; // '"str"' is not assignable to type 'number'

但是ts还有很多更为复杂的类型,本节主要讲讲各种类型的兼容性。

结构兼容性

ts的类型兼容都是基于结构兼容,即比较的是是两个类型各自包含的属性,两个类型的名字不是重点。基本的原则是:如果Y类型至少具有X类型中的所有属性(X ⊆ Y),那么X类型兼容Y类型。即X=Y合法:

interface X {
  name: string;
}

interface Y {
  name: string;
  age: number;
}

let a: X;
let b: Y;

a = b; // 合法
b = a; // 不合法 Property 'age' is missing in type 'X' but required in type 'Y'.

Y具有X中的所有属性,所以X兼容Y,即a=b合法。而X却没有Y所需要age属性,所以Y不兼容X,即b=a不合法。

大白话的解释就是:如果有一个类型为X的变量a,现在我要a赋值,为了保证a在赋值后,能正常调用它的属性和方法。那赋的值所具有的属性和方法一定是大于或等于a所需要的(饱和式赋值)。如果a需要name(string)age(number),给它赋的值只是name(string),当a使用age属性时就找不到。但是如果赋的值是name(string)age(number)other(any),那么没问题,a所需要的都有,至于多余的属性,不用即可。

函数兼容性

函数兼容性相对来说要复杂一些,需要考虑两个方面:参数和返回值。先抛出结论:函数参数是逆变,返回值是协变(strictFunctionTypes开启的前提下)。更多内容请查看函数的逆变协变

参数兼容性

在返回值兼容的前提下,如果函数A都每一个参数都能函数B中找到对应的类型(关注的是类型兼容,参数名字不相同也没关系),即B ⊆ A,那么函数A兼容函数B:

let fn1: (x: string) => void;
let fn2: (x: string, y: string) => void;

fn1 = fn2; // 不合法
fn2 = fn1; // 合法

咋一看好像和前面提到的结构兼容是冲突的,让我们结合具体的函数声明和实现再来看看:

let fn1: (x: string) => void = (x) => console.log(x);
let fn2: (x: string, y: string) => void = (x, y) => console.log(x, y);

fn2 = fn1;
fn2("x", "y"); // 多余的y不会被使用

fn1 = fn2;
fn1("x"); // fn2中的y拿不到需要的值

我们在比较两个函数是否兼容时,比较的是函数声明。函数声明规定了我们怎样传参才是合法的,但函数实现才实际消费这些参数。所以函数声明的参数需要大于等于实现中实际所需要的参数(饱和式传参)即实际目标函数参数 ⊆ 源函数声明参数,才能保证函数实现能正常运行。

再次强调:我们为一个函数类型变量A赋值另一个函数类型实现B时,在调用该函数时,还是需要按照该函数A声明传参,参数实际是被B使用。所以函数参数的兼容实际上是参数传递和参数使用之间的兼容。

比较常见的例子就是高阶函数forEach:

// array forEach的函数声明大概如下:
function forEach<T>(
  callbackfn: (value: T, index: number, array: T[]) => void
): void;


const items = [1, 2, 3];
// 以下形式皆可
items.forEach((item, index, array) => console.log(item));
items.forEach(item => console.log(item));

forEach在实际调用时,callbackFn永远会被传递valueindexnumberarray四个变量,而我们传递callback不一定都需要使用这些变量,可以只用到一部分。

对于可选参数,和剩余参数,在之前的基础上,多了以下规则:

  • 源函数中多余的可选参数,不算错误
  • 目标函数中多余的可选参数,即使没有和源函数中参数对应,不算错误
  • 如果函数具有剩余参数,该参数会被认为无限个的可选类型参数(但实际上类型不会被认为是undefined和当前类型的联合类型,这是和真正的可选类型不同的地方)
  let foo = (x: number, y: number) => {};
  let bar = (x: number, y: number, z?: number) => {};
  
  foo = bar; //合法,第2条规则
let foo = (x: number, y: number) => {};
let bar = (x?: number, y?: number) => {};
let baz = (...args: number[]) => {};

// 在strictNullChecks关闭时,以下都是合法的
foo = bar = baz;
baz = bar = foo;

// 否则
// 原因:可选类型的参数可能是 undefined,不能与 number 兼容
foo = baz;
foo = bar;
baz = foo;
baz = bar;

返回值兼容性

在函数参数兼容的前提下,如果源函数的返回值结构兼容目标函数的返回值(饱和式返回值),即源声明函数返回值 ⊆ 目标函数返回值则源函数兼容目标函数:

let returnFn1: () => { name: string };
let returnFn2: () => { name: string; age: number };

returnFn1 = returnFn2; // 合法
returnFn2 = returnFn1; // 不合法

根据函数声明,当我们调用returnFn1时返回值类型是{ name: string }。但是调用returnFn1时,实际执行的的是returnFn2的函数实现。为了保证返回值能够正常使用name属性。那么returnFn2的返回值一定要包括name,这就是第一节提到的结构兼容。

函数重载

对于重载函数,源函数每个重载的函数都需要和目标函数的重载兼容,即源函数重载 ⊆ 目标函数重载。保证源函数的各个重载都能正常使用:

interface CallbackFn1 {
  (a: number): number;
  (a: string): string;
  (a: string | number): string | number;
}

interface CallbackFn2 {
  (a: string): string;
  (a: number): string;
  (a: string | number): string | number;
}

interface CallbackFn3 {
  (a: string): string;
  (a: number): number;
  (a: number): string;
  (a: string | number): string | number;
}

let cb1: CallbackFn1;
let cb2: CallbackFn2;
let cb3: CallbackFn3;

// 不合法
cb1 = cb2;
// 合法
cb1 = cb3;

可以看出,重载函数的兼容就是第一节提到的结构兼容。

枚举兼容性

数值枚举与数值相互兼容:

enum Status {
  Ready,
  Waiting,
}

let s: Status = Status.Ready;
let num = 10;
s = num;
num = s;

字符串兼容字符串枚举,反之不成立:

enum Status {
  Ready = "ready",
  Waiting = "waiting",
}

let s: Status = Status.Ready;
let str = "str";
s = str; // 不合法
str = s; // 合法

来自不同的枚举的变量相互不兼容:

enum Status {
  Ready,
  Waiting,
}

enum Color {
  Red,
  Blue,
  Yellow,
}

let s: Status = Status.Ready;
let c: Color = Color.Red;
s = c; // 不合法
c = s; // 不合法

类兼容性

类也是结构兼容。类的兼容性只关注属性和方法,不会关注构造方法和静态成员:

class Animal {
  feet: number = 1;
  static prop: string;
  static getSomething() {}
  constructor(name: string, numFeet: number) {}
}

class Size {
  feet: number = 0;
  constructor(numFeet: number) {}
}

let animal: Animal;
let size: Size;

animal = size; // 合法
size = animal; // 合法

如果一个类具有私有或者受保护的成员,则必须来自相同的类:

class Animal {
  protected feet: number = 1;
}

class Cat extends Animal {}

class Size {
  protected feet: number = 0;
}

let animal: Animal;
let cat: Cat;
let size: Size;

cat = animal; // 合法,因为Cat属性和Animal一致
animal = cat; // 合法,兼容子类

size = animal; // 不合法,feet不是来自同一个类
animal = size; // 不合法,同上

泛型兼容性

正如前面提到的ts的类型兼容是基于结构兼容。泛型的类型参数没有被使用时不会影响兼容性:

interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;

x = y;  // 合法,结构兼容

当类型参数在被使用后才会影响到兼容性:

interface Empty<T> {
  prop: T;
}
let x: Empty<number>;
let y: Empty<string>;

x = y; // 不合法 Type 'Empty<string>' is not assignable to type 'Empty<number>'.Type 'string' is not assignable to type 'number'

如果泛型还未被实例化,在检查兼容性时会被类型参数转换为any

let identity = function <T>(x: T): void {
};

let reverse = function <U>(y: U): void {
};

identity = reverse; // 合法,(x: any) => void兼容(x: any) => void