TypeScript 类型兼容性

5,457 阅读13分钟

TypeScript 不仅内置了 stringnumberbooleanvoidnullundefinedsymbolunknownneveranyenum 等多种类型,我们亦可通过 interfacetype 来自定义类型。这些类型之间,有的可以相互赋值,有的则不行,这主要是由 TypeScript 的类型兼容系统决定的,今天我们就来聊聊 TypeScript 类型兼容的规则。

名义类型的兼容性

名义类型指的是:数据类型的兼容性或等价性是通过明确的声明或类型的名称决定的,常见于 JavaKotlinC# 等语言系统中,在 TypeScript 中内置的 stringnumberbooleanvoidnullundefinedsymbolunknownneveranyenum 这些类型都是基于名义类型的规则处理类型兼容的,具体规则如下所述:

any

  • 可将其值赋予除 never 之外的其它任何类型;
  • 任何类型的值都可赋予 any

unknown

  • 仅可将其值赋予其它类型 any
  • 任何类型的值都可赋予 unknown

never

  • 可将其值赋予其它任何类型;
  • 仅可将 never 类型的值赋予 never

void

  • 仅可将其值赋予其它类型 anyunknown
  • 仅可将其它类型 anynevernullundefined 的值赋予 void

null

  • 仅可将其值赋予其它类型 anyunknownundefined
  • 仅可将其它类型 anyneverundefined 的值赋予 null

undefined

  • 仅可将其值赋予其它类型 anyunknownnull
  • 仅可将其它类型 anynevernull 的值赋予 undefined

enum

  • 枚举与数字类型相互兼容。
  • 来自于不同枚举的枚举变量,被认为是不兼容的。

除了上述特殊类型外,像 stringnumberbooleansymbol 等这些基本类型的兼容性如下所示:

  • 仅可将其值赋予其它类型 anyunknown
  • 严格模式下,仅可将其它类型 anyneverunknown 的值赋予此类型。
  • 非严格模式下,仅可将其它类型 anyneverunknownnullundefined 的值赋予此类型。

结构类型的兼容性

除了以上所述的名义类型兼容规则外,TypeScript 的类型兼容更多地是基于结构类型兼容规则:如果两个类型的结构一样,就说它们是互相兼容的,且可相互赋值(即如果类型 x 要兼容类型 y,那么类型 y 至少具有与类型 x 相同的属性)。比如下面的例子:

interface Named {
  name: string;
}

class Person {
  name: string;
}

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

上述代码中,虽然 Person 类没有明确地声明自己实现了 Named 接口,但因为 Person 类与 Named 接口具有相同的结构,所以它们是互相兼容的。这样设计是因为 JavaScript 中广泛使用了匿名对象(比如匿名函数和对象字面量),而使用结构类型来描述类型比使用名义类型更加高效。

Freshness 特性

如上所述,只要满足结构类型兼容规则的两个类型便可相互兼容。那是否有例外存在呢?让我们看下面的例子:

interface Named {
  name: string;
}

interface Person {
  id: number;
  name: string;
}

let p: Named;
p = {
  id: 1, // 不能将类型“{ id: number; name: string; }”分配给类型“Named”。 对象文字可以只指定已知属性,并且“id”不在类型“Named”中。ts(2322)
  name: 'Tom',
};

上述代码中,虽然为变量 p 赋予的字面值完全符合结构类型兼容规则,但它却抛出了异常,这主要是由 TypeScript 中的 Freshness 特性导致的,该特性会对对象字面量进行更为严格的类型检测:只有目标变量的类型与该对象字面量的类型完全一致时,对象字面量才可赋值给目标变量,否则将抛出类型错误。我们可以通过以下方式来消除异常:

let p: Named;
p = {
  id: 1,
  name: 'Tom',
} as Person;

let p: Named;
let person: Person = {
  id: 1,
  name: 'Tom',
};
p = person;

类的兼容性

在判断两个类是否兼容时,除了遵照上述的结构类型兼容规则,还需注意以下几点:

  • 只需比较类实例的属性和方法是否兼容即可。
  • 私有、受保护的属性和方法,必须来自相同的类。

下面我们通过具体的例子进行分析:

class Animal {
  feet: number;
  constructor(name: string, feet: number) {}
}

class Cat {
  feet: number;
  constructor(feet: number) {}
}

let animal: Animal;
let cat: Cat;

animal = cat;
cat = animal;

上述代码中,类 Animal 与类 Cat 拥有共同的属性 feet,即使它们的构造函数不相同,这两个类也是互相兼容。再看下面的例子:

class Animal {
  protected feet: number;
}

class Cat {
  protected feet: number;
}

let animal: Animal;
let cat: Cat;

animal = cat; // 不能将类型“Cat”分配给类型“Animal”。属性“feet”受保护,但类型“Cat”并不是从“Animal”派生的类。ts(2322)
cat = animal; // 不能将类型“Animal”分配给类型“Cat”。属性“feet”受保护,但类型“Animal”并不是从“Cat”派生的类。ts(2322)

上述代码中,我们在类 Animal 和类 Cat 中分别定义了受保护的 feet 属性,此刻如果对此类型的变量相互赋值,便会抛出异常。我们可以通过类继承的方式来消除此类方法,比如下面的例子:

class Animal {
  protected feet: number;
}

class Cat extends Animal {}

let animal: Animal;
let cat: Cat;

animal = cat;
cat = animal;

泛型的兼容性

对于泛型的兼容性,只有当它的类型参数被一个成员使用时,才会影响其兼容性。比如下面的例子中,类型参数 T 对兼容性无任何影响:

interface Empty<T> {}

let x: Empty<number>;
let y: Empty<string>;

x = y; // ok

当类型参数 T 被成员使用时,将会在泛型实例化后影响其兼容性:

interface Empty<T> {
  data: T;
}

let x: Empty<number>;
let y: Empty<string>;

x = y; // 不能将类型“Empty<string>”分配给类型“Empty<number>”。不能将类型“string”分配给类型“number”。ts(2322)

对于未明确指定类型入参泛型的兼容性,TypeScript 会把 any 类型作为所有未明确指定的入参类型实例化泛型,然后再检测其兼容性:

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

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

identity = reverse; // ok, 因为 `(x: any) => any` 匹配 `(y: any) => any`

父子类型

父子类型是理解 TypeScript 类型兼容的关键所在。百度百科给出了子类型的定义,但理解起来有点烧脑,下面给出个人的大白话理解:如果一个变量需要一个 A 类型的值,如果我们可以用 B 类型的值赋予该变量,那么类型 B 就是类型 A子类型,类型 A 就是类型 B父类型。比如下面的例子:

type StringOrNumber = string | number;

let value: StringOrNumber;
value = 123;
value = "12345678";

let arrayValue: Array<StringOrNumber> = [123];
value = arrayValue; // 不能将类型 “StringOrNumber[]” 分配给类型 “StringOrNumber”。 不能将类型 “StringOrNumber[]” 分配给类型 “string”。ts(2322)

上述代码中,我们定义了类型为 StringOrNumber 的变量 value,我们可以将该其值设置为 123"12345678",但当我们将一个数组赋予该变量时,便抛出了异常。这也就表明:

  • 类型 numberstring 是类型 StringOrNumber子类型,类型 StringOrNumber 是类型 numberstring父类型
  • 类型 Array<StringOrNumber> 与类型 StringOrNumber 无法构成父子类型关系

变型

如果能够推断出两个类型的父子类型关系,并且基于这层关系可以推断出由这两个类型构造出的更为复杂的类型之间的父子类型关系,我们称之为变型。

变型的意义是为了保证类型安全,避免应用在编译或运行时出现意想不到的问题,根据父子类型关系的转变规则,我们可将变型划分为:协变、逆变、双向协变和不变。下面我们对其一一进行介绍:

协变

首先,我们来看下面的例子:

class Animal {
  run() {
  }
}

class Dog extends Animal {
}

let dogs: Dog[] = [new Dog()];
let animals: Animal[];

animals = dogs;
animals[0].run();

上述代码中,我们定义了类 Animal 和类 Dog,二者的父子类型关系为:

  • DogAnimal 的子类型;
  • AnimalDog 父类型。

因为上述代码能够正常运行,我们可以推断出类型 Animal[] 和类型 Dog[] 二者的父子类型关系为:

  • Dog[]Animal[] 的子类型;
  • Animal[]Dog[] 父类型。

由此可见,类型 Animal[] 和类型 Dog[] 保留了类型 Animal 和类型 Dog 之间的父子类型关系,对于这种保留了父子类型关系的变型我们称之为协变

逆变

理解了协变的概念,让我们再看一个例子:

class Animal {
  run() {
  }
}

class Dog extends Animal {
  woof() {
  }
}

function train(dog: Dog): void {
  dog.woof();
}

let animals: Animal[] = [new Dog()];
animals.forEach(train); // ts(2345)

上述代码中,我们定义了类 Animal 和类 Dog,二者的父子类型关系为:

  • DogAnimal 的子类型;
  • AnimalDog 父类型。

继续分析可知,forEach 的函数签名为:(arg: Animal) => voidtrain 的函数签名为: (arg: Dog) => void,按照前者类型 Dog 与类型 Animal 的关系,我们可以推导出:

  • (arg: Dog) => void(arg: Animal) => void 的子类型;
  • (arg: Animal) => void(arg: Dog) => void 的父类型。

按照父子类型的兼容规则,我们完全可以将 train 赋予了 forEach 方法,但实际上却抛出了异常,这是因为 train 需要的是 Dog 类型,但 animals 无法保证它的每一项都是 Dog 类型,为了保证类型安全,TypeScript 对函数的参数类型做了逆变处理:如果类型 B 是类型 A子类型,那么在函数的参数中,类型 AB父子类型关系将会发生逆转(即类型 B 变成了类型 A父类型)。对于上面的例子,我们可以修改为:

function train(animal: Animal): void {
  animal.run();
}

let dogs: Dog[] = [new Dog()];
dogs.forEach(train);

新的代码中 forEach 的函数签名为:(arg: Dog) => voidtrain 的函数签名为: (arg: Animal) => void,两者的类型关系变成了:

  • (arg: Animal) => void(arg: Dog) => void 的子类型;
  • (arg: Dog) => void(arg: Animal) => void 的父类型。

对比类型 Dog 与类型 Animal 本身的父子类型关系及其在函数参数中的父子类型关系,它们之间发生了反转,我们称这种变型逆变,且主要应用于函数的参数类型中。

双向协变

如果类型 A 是类型 B子类型,经过变型后,如果类型 A 既是类型 B子类型,又是类型 B父类型(反之亦然),我们称这种变型双向协变。比如下面的例子:

interface BaseEvent {
  timestamp: number;
}

interface MyMouseEvent extends BaseEvent {
  x: number;
  y: number;
}

function addEventListener(handler: (n: BaseEvent) => void) {
}

addEventListener((e: MyMouseEvent) => {
}); // ts(2345)

根据前文可知,在函数的参数类型中,只有逆变才能保证类型安全,因此在 TypeScript 的严格模式下,函数的参数类型逆变的。但类似上述事件处理的代码是我们经常遇到的场景,我们可以将 TypeScript 设置为非严格模式(将 strictFunctionTypesstrict 设置为 false),此刻函数的参数类型便变成了双向协变,但由于它不是类型安全的,故此不推荐使用,可通过泛型来保证类型安全,因此上诉代码中的 addEventListener 可修改为:

function addEventListener<E extends BaseEvent>(handler: (n: E) => void) {
}

不变

如果类型 A 是类型 B子类型,经过变型后,如果类型 A 与类型 B 无法构成父子类型关系,我们便称这种变型为不变。比如下面的例子:

class Animal {
  run() {
  }
}

class Dog extends Animal {
  woof() {
  }
}

class Cat extends Animal {
}

let dogs: Dog[] = [new Dog()];
let animals: Animal[];

animals = dogs;

animals.push(new Animal());
dogs.forEach(dog => dog.woof());

上述代码可以编译通过,但在运行时则会抛出 dog.woof is not a function 的异常,我们接下来分析其中的原因:

  • 根据前文的叙述,分析 animals.push(new Animal()) 之前的代码可知 Dog[]Animal[]父子类型关系协变
  • 然后为数组 animals 新增了一个 Animal 实例,由于 animalsdogs 指向同一个数组,所以对 animals 的操作直接影响到了 dogs,此时 Dog[]Animal[]父子类型关系是不安全且无法确定的,在某些语言(比如 kotlin)中,是不允许这种情况发生的,但在 TypeScript 虽然能够编译通过,但依旧会在运行时抛出异常。

所以在 TypeScript 中,为了避免不变可能导致的问题,一定要小心处理可变数组的兼容性,以避免发生不可预料的运行时错误。

函数的兼容性

两个函数只有下面几个选项都兼容的情况下,才可以相互兼容且可相互赋值:

返回类型

函数返回值类型属于协变,比如下面的例子:

interface Point2D {
  x: number;
  y: number;
}

interface Point3D {
  x: number;
  y: number;
  z: number;
}

let iMakePoint2D = (): Point2D => ({ x: 0, y: 0 });
let iMakePoint3D = (): Point3D => ({ x: 0, y: 0, z: 0 });

iMakePoint2D = iMakePoint3D; // OK
iMakePoint3D = iMakePoint2D; //ts(2322)

上述代码中,由于 Point3DPoint2D子类型,又可从 iMakePoint3D 可赋值给 iMakePoint2D,而 iMakePoint2D 不能赋值给 iMakePoint3D可推出 () => Point3D() => Point2D子类型,因此可断定函数返回值类型属于协变

参数类型

函数的参数类型属于逆变,推导步骤在讨论逆变的过程中已进行了详细的讨论,此处不再重述。

参数个数

在索引位置相同的参数和返回值类型兼容的前提下,函数兼容性取决于参数个数,参数个数少的兼容个数多的,比如下面的例子:

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

y = x; // OK
x = y; // ts(2322)

上述代码中,由于函数 x 的参数个数小于 y,所以可以将 x 赋值给 y

可选和剩余参数

可选参数可兼容剩余参数和不可选参数,比如下面的例子:

let optional = (x?: number, y?: number) => {};
let required = (x: number, y: number) => {};
let rest = (...args: number[]) => {};
required = optional; // ok
rest = optional; // ok
optional = rest; // ts(2322)
optional = required; // ts(2322)
rest = required; // ok
required = rest; // ok

上述代码中,我们不能将 restrequired 赋值给 optional,这是因为在严格模式下,我们只能将 anyneverunknown 赋值给 number,如果想要其正常工作,可以将编译选项 strictNullChecks 设置为 false

总结

本文我们针对 TypeScript 类型兼容性进行了详细的探讨,现如今 TypeScript 在大型应用的构建、维护过程中起到了举足轻重的作用,掌握并熟练使用 TypeScript 类型系统,有利于:

  • 在团队协作中,为接口制定结构化的契约规范。
  • 提前检测出常见的运行时错误(比如空值问题),从而提高系统的安全性与稳定性。
  • 便于应用后续的维护与扩展。

由于个人知识认知有限,如果有疏漏、错误之处,还望大家一起探讨;最后,衷心感谢您的阅读。^ _ ^

参考链接