TS从入门到放弃【十一】:类型推论和兼容性

418 阅读7分钟

「这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战」。

我们在一些时候可以省略类型的指定,TS 会帮我们推断出省略类型地方适合的类型。

我们通过学习类型推论,可以了解 TS 的推论规则。

类型推论就是为了适应 JS 灵活的特点,从而在一些情况下,只要兼容的类型即可通过检测。

一、简单示例

我们先来看一个最基础的示例:

let name = 'dylan';
name = 123; // Error:不能将类型“number”分配给类型“string”。

我们定义了一个 name 属性,但没有指定是什么类型,直接给它设定了一个字符串的值,TS就会帮我们推断出 name 可能是想要一个字符串类型。所以当我们给 name 再赋一个数值类型的值时,就会报错了。

二、多类型联合

let arr = [1, 'a'];

我们定义一个数组 arr,其中有两个元素:数值类型 1,以及字符串类型 a

TS 会将上述数组类型推断为:Array<number | string>

这个时候如果我们想给这个数组赋另外一个值(带有number、string类型之外的值),就会报错。

arr = [1, 2, 3, 'a', true]; // Error:不能将类型“boolean”分配给类型“string | number”。

三、类型兼容性

interface Infos {
  name: string;
}
let infos: Info;
const infos1 = { name: 'dylan' };
const infos2 = { age: 18 };
const infos3 = { name: 'dylan', age: 18 };

我们定义了一个 Infos 接口、一个未赋值的参数 infos 以及三个已经赋值了的参数 infos1infos2infos3

接下来我们用这三个已经赋值了的对象,给 infos 赋值。

infos = infos1;
infos = infos2; // Error:类型 "{ age: number; }" 中缺少属性 "name"
infos = infos3; // 可以多,不能少
  • infos1infos 赋值(OK):infos1 完全符合 Infos 接口定义的内容
  • infos2infos 赋值(Error):infos1中缺少 name 字段
  • infos3infos 赋值(OK):infos3中是有 name字段的。赋值要求:赋值的值中必须要有被赋值的值中全部要求的字段,多了无所谓。

这里的兼容性检测是深层次递归检测的。

四、函数兼容性

1、参数个数

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

这里定义了 x、y 两个函数,这两个函数只有参数不同。

现在我们来进行赋值:

y = x; // OK

可以看到把 x 赋值给 y 是没有问题的。

接下来我们尝试一下把 y 赋值给 x

y = x; // Error:不能将类型“(b: number, c: string) => number”分配给类型“(a: number) => number”。

可以看到现在报错了。

TS关于参数个数的要求就是:右边函数的参数个数,必须小于等于左边函数的参数个数。

我们来看一个实际的例子:

const arr = [1, 2, 3];
arr.forEach((item, index, array) => {
  console.log(item)
})

先定义一个数组 arr,调用数组的 forEach 方法。我们知道 forEach 接收一个回调函数,回调函数的参数有三个,分别代表当前项、索引、数组本身

可以看到上述代码实际上我们只用到了第一个参数(当前项)。所以我们在一般使用到的时候一般会忽略后面两项:

arr.forEach(item => {
  console.log(item);
})

使用上述代码方式来调用 forEach

现在我们就可以理解之前的例子了:forEach要求回调函数参数有3个,但我们实际用的时候只用到1个。所以我们回调函数的参数个数,一定要小于等于被赋值函数的参数个数。

2、参数类型

let x = (a: number) => 0;
let y = (b: string) => 0;
x = y; // Error:参数“b”和“a” 的类型不兼容。

两个不同参数类型的函数,不能相互赋值:类型不兼容

3、可选参数和剩余参数

const getSum = (arr: number[], callback: (...args: number[]) => number): number => {
  return callback(...arr);
};

定义一个 getSum 函数

  • 参数1是 number类型的数组
  • 参数2是一个回调函数(参数是一个 number类型的数组,返回值是 number类型);
  • 返回值是 number 类型。

接下来我们调用一下 getSum

const result = getSum([1, 2, 3], (...args: number[]): number => {
  return args.reduce((pre, cur) => pre + cur, 0);
})
console.log(result); // 6

与上述代码相对应的就是设定好固定的参数

const result2 = getSum([1, 2, 3], (arg1: number, arg2: number, arg3: number): number => {
  return arg1 + arg2 + arg3;
})

可以看到,resultresult2 更加灵活一些。

当要被赋值的函数参数中包含剩余参数(例如上述代码中的 ...args)的时候,赋值的函数可以用任意个数参数代替(类型需要一样)。

同时,剩余参数可以看做是无数可选参数

4、函数参数双向协变

let funcA = (arg: number | string): void => {};
let funcB = (arg: number): void => {};

定义了两个函数:funcAfuncB,现在我们进行赋值:

funcA = funcB; // 可以
funcB = funcA; // 也可以

两种赋值方式都是可以的。

我们可以看出 funcA的参数类型即可以是 number 也可以是 string;而 funcB 的参数类型是 number。这两个函数都可以只接收一个参数(number类型)

5、返回值类型

let x = (): string | number => 0;
let y = (): string => 'a';
x = y; // OK
y = x; // Error:不能将类型“string | number”分配给类型“string”。

我们把 y 赋值给 x 是没有问题的,因为 ystring 类型,x 即可以是 string 类型,也可以是 number 类型。

反过来把 x 赋值给 y 就不行了。

6、函数重载

function merge(arg1: number, arg2: number): number;
function merge(arg1: string, arg2: string): string;
function merge(arg1: any, arg2: any) { // 实际的函数体就不算作函数重载的一部分了
  return arg1 + arg2;
}

五、枚举的兼容性

数字枚举类型和数字类型是互相兼容的。

enum Status {
  On,
  Off,
}
let s = Status.On;
s = 2; // OK:它是和数值类型兼容的

如果我们再定义一个枚举

enum Animal {
  Dog,
  Cat
}

再对 s 进行赋值

s = Animal.Dog; // Error:不能将类型“Animal.Dog”分配给类型“Status”。

发现报错了,虽然 Status.OnAnimal.Dog 代表的值都是 0,但这里是不兼容的。

所以,数字枚举类型只与数字类型兼容,在不同枚举之间是不兼容的。

六、类的兼容性

只比较实例的成员。类的静态成员和构造函数不进行比较。

我们来定义三个类:

class AnimalClass {
  public static age: number;
  constructor(public name: string) {}
}
class PeopleClass {
  public static age: string;
  constructor(public name: string) {}
}
class FoodClass {
  constructor(public name: number) {}
}

定义三个变量,把上述 class 当作类型来使用:

let animal: AnimalClass
let people: PeopleClass;
let food: FoodClass;

当使用来指定一个变量的类型时,TS 检测的是实例

animal = people; // OK
animal = food; // Error:属性“name”的类型不兼容。

虽然 AnimalClassPeopleClass 的静态属性 age 的类型不一样,但是类作为类型时并不会检测静态成员和构造函数,只会比较实例上的成员。他俩的实例都通过 public name: string 给实例上添加了一个 name 属性,而且类型都是 string,所以这两个类的类型是兼容的。

同理,FoodClass 的实例成员也有 name 属性,但类型是 number ,所以是不兼容的。

private、protected

使用 private、protected 这两个修饰符的话,会对类的兼容性造成影响。

当检查类的实例的兼容性时,如果目标类型(被赋值的一方)包含私有成员,那么原类型(赋值的一方)必须包含来自同一个类的私有成员

class ParentClass {
  private age: number;
  constructor() {}
}
class ChildrenClass extends ParentClass {
  constructor() {
    super();
  }
}
class OtherClass {
  private age: number;
  constructor() {}
}

看类定义的话,ParentClassOtherClass 是一模一样的,除了类名不同。

接下来我们创建实例测试:

const children: ParentClass = new ChildrenClass(); // OK:子类可以赋值给父类类型的值
const other: ParentClass = new OtherClass(); // Error:不能将类型“OtherClass”分配给类型“ParentClass”。类型具有私有属性“age”的单独声明。

protectedprivate 一样

七、泛型的兼容性

泛型包含类型参数,这个类型参数可以是任意类型。

在使用时,类型参数会被指定为一个特定的类型,这个类型只影响使用了类型参数的部分

interface Data<T> {}
let data1: Data<number>;
let data2: Data<string>;
data1 = data2; // OK:因为函数块内部是空的(没有用到)

但如果给接口定义一个实际的东西:

interface Data<T> {
  data: T
}
let data1: Data<number>;
let data2: Data<string>;
data1 = data2; // Error:不能将类型“Data<string>”分配给类型“Data<number>”。

现在赋值就会报错了,因为 data1 要求里面有个 data 属性,类型是 number;而 data2 要求里面有个 data 属性,类型是 string。这时他俩就是不兼容的了。