TypeScript 类型兼容性
TypeScript 不仅内置了 string
、number
、boolean
、void
、null
、undefined
、symbol
、unknown
、never
、any
、enum
等多种类型,我们亦可通过 interface
、type
来自定义类型。这些类型之间,有的可以相互赋值,有的则不行,这主要是由 TypeScript 的类型兼容系统决定的,今天我们就来聊聊 TypeScript 类型兼容的规则。
名义类型的兼容性
名义类型
指的是:数据类型的兼容性或等价性是通过明确的声明或类型的名称决定的,常见于 Java
、Kotlin
、C#
等语言系统中,在 TypeScript 中内置的 string
、number
、boolean
、void
、null
、undefined
、symbol
、unknown
、never
、any
、enum
这些类型都是基于名义类型
的规则处理类型兼容的,具体规则如下所述:
any
- 可将其值赋予除
never
之外的其它任何类型; - 任何类型的值都可赋予
any
。
unknown
- 仅可将其值赋予其它类型
any
; - 任何类型的值都可赋予
unknown
。
never
- 可将其值赋予其它任何类型;
- 仅可将
never
类型的值赋予never
。
void
- 仅可将其值赋予其它类型
any
或unknown
; - 仅可将其它类型
any
、never
、null
、undefined
的值赋予void
。
null
- 仅可将其值赋予其它类型
any
、unknown
或undefined
; - 仅可将其它类型
any
、never
、undefined
的值赋予null
。
undefined
- 仅可将其值赋予其它类型
any
、unknown
或null
; - 仅可将其它类型
any
、never
、null
的值赋予undefined
。
enum
- 枚举与数字类型相互兼容。
- 来自于不同枚举的枚举变量,被认为是不兼容的。
除了上述特殊类型外,像 string
、number
、boolean
、symbol
等这些基本类型的兼容性如下所示:
- 仅可将其值赋予其它类型
any
或unknown
; - 严格模式下,仅可将其它类型
any
、never
、unknown
的值赋予此类型。 - 非严格模式下,仅可将其它类型
any
、never
、unknown
、null
、undefined
的值赋予此类型。
结构类型的兼容性
除了以上所述的名义类型兼容规则
外,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"
,但当我们将一个数组赋予该变量时,便抛出了异常。这也就表明:
- 类型
number
或string
是类型StringOrNumber
的子类型
,类型StringOrNumber
是类型number
或string
的父类型
。 - 类型
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
,二者的父子类型关系
为:
Dog
是Animal
的子类型;Animal
是Dog
父类型。
因为上述代码能够正常运行,我们可以推断出类型 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
,二者的父子类型关系
为:
Dog
是Animal
的子类型;Animal
是Dog
父类型。
继续分析可知,forEach
的函数签名为:(arg: Animal) => void
,train
的函数签名为:
(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
的子类型
,那么在函数的参数
中,类型 A
和 B
的父子类型
关系将会发生逆转(即类型 B
变成了类型 A
的父类型
)。对于上面的例子,我们可以修改为:
function train(animal: Animal): void {
animal.run();
}
let dogs: Dog[] = [new Dog()];
dogs.forEach(train);
新的代码中 forEach
的函数签名为:(arg: Dog) => void
,train
的函数签名为:
(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 设置为非严格模式
(将 strictFunctionTypes
或 strict
设置为 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
实例,由于animals
与dogs
指向同一个数组,所以对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)
上述代码中,由于 Point3D
是 Point2D
的子类型
,又可从 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
上述代码中,我们不能将 rest
和 required
赋值给 optional
,这是因为在严格模式
下,我们只能将 any
、never
、unknown
赋值给 number
,如果想要其正常工作,可以将编译选项 strictNullChecks
设置为 false
。
总结
本文我们针对 TypeScript 类型兼容性进行了详细的探讨,现如今 TypeScript 在大型应用的构建、维护过程中起到了举足轻重的作用,掌握并熟练使用 TypeScript 类型系统,有利于:
- 在团队协作中,为接口制定结构化的契约规范。
- 提前检测出常见的运行时错误(比如空值问题),从而提高系统的安全性与稳定性。
- 便于应用后续的维护与扩展。
由于个人知识认知有限,如果有疏漏、错误之处,还望大家一起探讨;最后,衷心感谢您的阅读。^ _ ^
参考链接
转载自:https://juejin.cn/post/7026611126087450655