因为 TypeScript 中有静态类型检测,所以我们再也不用像 JavaScript 中那样,赋给变量任意类型的值。
在 TypeScript 中,能不能把一个类型赋值给其他类型是由类型兼容性决定的。
特例
首先,我们回顾一下 any、never、unknown 等特殊类型,它们在类型兼容性上十分有特色。
(1)any
万金油 any 类型可以赋值给除了 never 之外的任意其他类型,反过来其他类型也可以赋值给 any。也就是说 any 可以兼容除 never 之外所有的类型,同时也可以被所有的类型兼容(即 any 既是 bottom type,也是 top type)。因为 any 太特殊,这里我就不举例子了。
再次强调:Any is 魔鬼,我们一定要慎用、少用。
(2)never
never 的特性是可以赋值给任何其他类型,但反过来不能被其他任何类型(包括 any 在内)赋值(即 never 是 bottom type)。比如以下示例中的第 5~7 行,我们可以把 never 赋值给 number、函数、对象类型。
{
let never: never = (() => {
throw Error('never');
})();
let a: number = never; // ok
let b: () => any = never; // ok
let c: {} = never; // ok
}
(3)unknown
unknown 的特性和 never 的特性几乎反过来,即我们不能把 unknown 赋值给除了 any 之外任何其他类型,反过来其他类型都可以赋值给 unknown(即 unknown 是 top type)。比如以下示例中的第 3~5 行提示了一个 ts(2322) unknown 类型不能赋值给其他任何类型的错误。
{
let unknown: unknown;
const a: number = unknown; // ts(2322)
const b: () => any = unknown; // ts(2322)
const c: {} = unknown; // ts(2322)
}
(4)void、null、undefined
void、null、undefined 这三大废材类型的兼容性也很特别,比如 void 类型仅可以赋值给 any 和 unknown 类型(下面示例第 9~10 行),反过来仅 any、never、undefined 可以赋值给 void(下面示例第 11~13 行)。
{
let thisIsAny: any;
let thisIsNever: never;
let thisIsUnknown: unknown;
let thisIsVoid: void;
let thisIsUndefined: undefined;
let thisIsNull: null;
thisIsAny = thisIsVoid; // ok
thisIsUnknown = thisIsVoid; // ok
thisIsVoid = thisIsAny; // ok
thisIsVoid = thisIsNever; // ok
thisIsVoid = thisIsUndefined; // ok
thisIsAny = thisIsNull; // ok
thisIsUnknown = thisIsNull; // ok
thisIsAny = thisIsUndefined; // ok
thisIsUnknown = thisIsUndefined; // ok
thisIsNull = thisIsAny; // ok
thisIsNull = thisIsNever; // ok
thisIsUndefined = thisIsAny; // ok
thisIsUndefined = thisIsNever; // ok
}
在我们推崇并使用的严格模式下,null、undefined 表现出与 void 类似的兼容性,即不能赋值给除 any 和 unknown 之外的其他类型,反过来其他类型(除了 any 和 never 之外)都不可以赋值给 null 或 undefined。
(5)enum
最后一个特例是 enum 枚举类型,其中数字枚举和数字类型相互兼容。
在如下示例中,我们在第 5 行把枚举 A 赋值给了数字(number)类型,并在第 7 行使用数字字面量 1 替代了枚举 A。
{
enum A {
one
}
let num: number = A.one; // ok
let fun = (param: A) => void 0;
fun(1); // ok
}
此外,不同枚举之间不兼容。如下示例中的第 10~11 行,因为枚举 A 和 B 不兼容,所以都会提示一个 ts(2322) 类型的错误。
{
enum A {
one
}
enum B {
one
}
let a: A;
let b: B;
a = b; // ts(2322)
b = a; // ts(2322)
}
类型兼容性
除了前边提到的所有特例,TypeScript 中类型的兼容性都是基于结构化子类型的一般原则进行判定的。
下面我们从结构化类型和子类型这两方面了解一下一般原则。
(1)子类型
从子类型的角度来看,所有的子类型与它的父类型都兼容,如下代码所示:
{
const one = 1;
let num: number = one; // ok
interface IPar {
name: string;
}
interface IChild extends IPar {
id: number;
}
let Par: IPar;
let Child: IChild;
Par = Child; // ok
class CPar {
cname = '';
}
class CChild extends CPar {
cid = 1;
}
let ParInst: CPar;
let ChildInst: CChild;
ParInst = ChildInst; // ok
let mixedNum: 1 | 2 | 3 = one; // ok
}
在示例中的第 3 行,我们可以把类型是数字字面量类型的 one 赋值给数字类型的 num。在第 12 行,我们可以把子接口类型的变量赋值给 Par。在第 21 行,我们可以把子类实例 ChildInst 赋值给 ParInst。
因为成员类型兼容它所属的类型集合(其实联合类型和枚举都算类型集合,这里主要说的是联合类型),所以在示例中的第 22 行,我们可以把 one 赋值给包含字面类型 1 的联合类型。
举一反三,由子类型组成的联合类型也可以兼容它们父类型组成的联合类型,如下代码所示:
let ICPar: IPar | CPar;
let ICChild: IChild | CChild;
ICPar = ICChild; // ok
在示例中的第 3 行,因为 IChild 是 IPar 的子类,CChild 是 CPar 的子类,所以 IChild | CChild 也是 IPar | CPar 的子类,进而 ICChild 可以赋值给 ICPar。
(2)结构类型
类型兼容性的另一准则是结构类型,即如果两个类型的结构一致,则它们是互相兼容的。比如拥有相同类型的属性、方法的接口类型或类,则可以互相赋值。
下面我们看一个具体的示例:
{
class C1 {
name = '1';
}
class C2 {
name = '2';
}
interface I1 {
name: string;
}
interface I2 {
name: string;
}
let InstC1: C1;
let InstC2: C2;
let O1: I1;
let O2: I2;
InstC1 = InstC2; // ok
O1 = O2; // ok
InstC1 = O1; // ok
O2 = InstC2; // ok
}
因为类 C1、类 C2、接口类型 I1、接口类型 I2 的结构完全一致,所以在第 18~19 行我们可以把类 C2 的实例 InstC2 赋值给类 C1 的实例 Inst1,把接口类型 I2 的变量 O2 赋值给接口类型 I1 的变量 O1。
在第 20~21 行,我们甚至可以把接口类型 I1 的变量 O1 赋值给类 C1 的实例,类 C2 的实例赋值给接口类型 I2 的变量 O2。
另外一个特殊的场景:两个接口类型或者类,如果其中一个类型不仅拥有另外一个类型全部的属性和方法,还包含其他的属性和方法(如同继承自另外一个类型的子类一样),那么前者是可以兼容后者的。
下面我们看一个具体的示例:
{
interface I1 {
name: string;
}
interface I2 {
id: number;
name: string;
}
class C2 {
id = 1;
name = '1';
}
let O1: I1;
let O2: I2;
let InstC2: C2;
O1 = O2;
O1 = InstC2;
}
在示例中的第 16~17 行,我们可以把类 C2 的实例 InstC2 和接口类型 I2 的变量 O2 赋值给接口类型 I1 的变量 O1,这是因为类 C2、接口类型 I2 和接口类型 I1 的 name 属性都是 string。不过,因为变量 O2、类 C2 都包含了额外的属性 id,所以我们不能把变量 O1 赋值给实例 InstC2、变量 O2。
这里涉及一个需要特别注意的特性:虽然包含多余属性 id 的变量 O2 可以赋值给变量 O1,但是如果我们直接将一个与变量 O2 完全一样结构的对象字面量赋值给变量 O1,则会提示一个 ts(2322) 类型不兼容的错误(如下示例第 2 行),这就是对象字面的 freshness 特性。
也就是说一个对象字面量没有被变量接收时,它将处于一种 freshness 新鲜的状态。这时 TypeScript 会对对象字面量的赋值操作进行严格的类型检测,只有目标变量的类型与对象字面量的类型完全一致时,对象字面量才可以赋值给目标变量,否则会提示类型错误。
当然,我们也可以通过使用变量接收对象字面量或使用类型断言解除 freshness,如下示例:
O1 = {
id: 2, // ts(2322)
name: 'name'
};
let O3 = {
id: 2,
name: 'name'
};
O1 = O3; // ok
O1 = {
id: 2,
name: 'name'
} as I2; // ok
在示例中,我们在第 5 行和第 13 行把包含多余属性的类型赋值给了变量 O1,没有提示类型错误。
另外,我们还需要注意类兼容性特性:实际上,在判断两个类是否兼容时,我们可以完全忽略其构造函数及静态属性和方法是否兼容,只需要比较类实例的属性和方法是否兼容即可。如果两个类包含私有、受保护的属性和方法,则仅当这些属性和方法源自同一个类,它们才兼容。
下面我们看一个具体的示例:
{
class C1 {
name = '1';
private id = 1;
protected age = 30;
}
class C2 {
name = '2';
private id = 1;
protected age = 30;
}
let InstC1: C1;
let InstC2: C2;
InstC1 = InstC2; // ts(2322)
InstC2 = InstC1; // ts(2322)
}
{
class CPar {
private id = 1;
protected age = 30;
}
class C1 extends CPar {
constructor(inital: string) {
super();
}
name = '1';
static gender = 'man';
}
class C2 extends CPar {
constructor(inital: number) {
super();
}
name = '2';
static gender = 'woman';
}
let InstC1: C1;
let InstC2: C2;
InstC1 = InstC2; // ok
InstC2 = InstC1; // ok
}
在示例中的第 14~15 行,因为类 C1 和类 C2 各自包含私有和受保护的属性,且实例 InstC1 和 InstC2 不能相互赋值,所以提示了一个 ts(2322) 类型的错误。
在第 38~39 行,因为类 C1、类 C2 的私有、受保护属性都继承自同一个父类 CPar,所以检测类型兼容性时会忽略其类型不相同的构造函数和静态属性 gender,也因此实例 InstC1 和 实例 InstC2 之间可以相互赋值。
(3)可继承和可实现
类型兼容性还决定了接口类型和类是否可以通过 extends 继承另外一个接口类型或者类,以及类是否可以通过 implements 实现接口。
下面我们看一个具体示例:
{
interface I1 {
name: number;
}
interface I2 extends I1 { // ts(2430)
name: string;
}
class C1 {
name = '1';
private id = 1;
}
class C2 extends C1 { // ts(2415)
name = '2';
private id = 1;
}
class C3 implements I1 {
name = ''; // ts(2416)
}
}
在示例中的第 5 行,因为接口类型 I1 和接口类型 I2 包含不同类型的 name 属性不兼容,所以接口类型 I2 不能继承接口类型 I1。
同样,在第 12 行,因为类 C1 和类 C2 不满足类兼容条件,所以类 C2 也不能继承类 C1。
而在第 16 行,因为接口类型 I1 和类 C3 包含不同类型的 name 属性,所以类 C3 不能实现接口类型 I1。
学习了类型兼容性的一般原则,下面再来看看拥有类型入参的泛型。
泛型
泛型类型、泛型类的兼容性实际指的是将它们实例化为一个确切的类型后的兼容性。
可以通过指定类型入参实例化泛型,且入参只有作为实例化后的类型的一部分时才能影响类型兼容性,下面看一个具体的示例:
{
interface I1<T> {
id: number;
}
let O1: I1<string>;
let O2: I1<number>;
O1 = O2; // ol
}
在示例中的第 7 行,因为接口泛型 I1 的入参 T 是无用的,且实例化类型 I1<string> 和 I1<numer> 的结构一致,即类型兼容,所以对应的变量 O2 可以给变量 O1赋值。
而对于未明确指定类型入参泛型的兼容性,例如函数泛型(实际上仅有函数泛型才可以在不需要实例化泛型的情况下赋值),TypeScript 会把 any 类型作为所有未明确指定的入参类型实例化泛型,然后再检测其兼容性,如下代码所示:
{
let fun1 = <T>(p1: T): 1 => 1;
let fun2 = <T>(p2: T): number => 2;
fun2 = fun1; // ok?
}
在示例中的第 4 行,实际上相当于在比较函数类型 (p1: any) => 1 和函数类型 (param: any) => number 的兼容性,那么这两个函数的类型兼容吗?答案:兼容。
为什么兼容呢?这就涉及接下来我们要介绍的函数类型兼容性。在此之前,我们先了解一下判定函数类型兼容性的基础理论知识:变型。
变型
TypeScript 中的变型指的是根据类型之间的子类型关系推断基于它们构造的更复杂类型之间的子类型关系。比如根据 Dog 类型是 Animal 类型子类型这样的关系,我们可以推断数组类型 Dog[] 和 Animal[] 、函数类型 () => Dog 和 () => Animal 之间的子类型关系。
在描述类型和基于类型构造的复杂类型之间的关系时,我们可以使用数学中函数的表达方式。比如 Dog 类型,我们可以使用 F(Dog) 表示构造的复杂类型;F(Animal) 表示基于 Animal 构造的复杂类型。
这里的变型描述的就是基于 Dog 和 Animal 之间的子类型关系,从而得出 F(Dog) 和 F(Animal) 之间的子类型关系的一般性质。而这个性质体现为子类型关系可能会被保持、反转、忽略,因此它可以被划分为协变、逆变、双向协变和不变这 4 个专业术语。
接下来我们分别看一下这 4 个专业术语的具体定义。
(1)协变
协变也就是说如果 Dog 是 Animal 的子类型,则 F(Dog) 是 F(Animal) 的子类型,这意味着在构造的复杂类型中保持了一致的子类型关系,下面举个简单的例子:
{
type isChild<Child, Par> = Child extends Par ? true : false;
interface Animal {
name: string;
}
interface Dog extends Animal {
woof: () => void;
}
type Covariance<T> = T;
type isCovariant = isChild<Covariance<Dog>, Covariance<Animal>>; // true
}
在示例中的第 1 行,我们首先定义了一个用来判断两个类型入参 Child 和 Par 子类型关系的工具类型 isChild,如果 Child 是 Par 的子类型,那么 isChild 会返回布尔字面量类型 true,否则返回 false。
然后在第 3~8 行,我们定义了 Animal 类型和它的子类型 Dog。
在第 9 行,我们定义了泛型 Covariant 是一个复杂类型构造器,因为它原封不动返回了类型入参 T,所以对于构造出来的复杂类型 Covariant<Dog> 和 Covariant<Animal> 应该与类型入参 Dog 和 Animal 保持一致的子类型关系。
在第 10 行,因为 Covariant<Dog> 是 Covariant<Animal> 的子类型,所以类型 isCovariant 是 true,这就是协变。
实际上接口类型的属性、数组类型、函数返回值的类型都是协变的,下面看一个具体的示例:
type isPropAssignmentCovariant = isChild<{ type: Dog }, { type: Animal }>; // true
type isArrayElementCovariant = isChild<Dog[], Animal[]>; // true
type isReturnTypeCovariant = isChild<() => Dog, () => Animal>; // true
在示例中的第1~3 行,我们看到 isPropAssignmentCovariant、isArrayElementCovariant、isReturnTypeCovariant 类型都是 true,即接口类型 { type: Dog } 是 { type: Animal } 的子类型,数组类型 Dog[] 是 Animal[] 的子类型,函数类型 () => Dog 也是 () => Animal 的子类型。
(2)逆变
逆变也就是说如果 Dog 是 Animal 的子类型,则 F(Dog) 是 F(Animal) 的父类型,这与协变正好反过来。
实际场景中,在我们推崇的 TypeScript 严格模式下,函数参数类型是逆变的,具体示例如下:
type Contravariance<T> = (param: T) => void;
type isNotContravariance = isChild<Contravariance<Dog>, Contravariance<Animal>>; // false;
type isContravariance = isChild<Contravariance<Animal>, Contravariance<Dog>>; // true;
在示例中的第 1 行,我们定义了一个基于类型入参构造函数类型的构造器 Contravariance,且类型入参 T 仅约束返回的函数类型参数 param 的类型。因为 TypeScript 严格模式的设定是函数参数类型是逆变的,所以 Contravariance<Animal> 会是 Contravariance<Dog> 的子类型,也因此第 2 行 isNotContravariance 是 false,第 3 行 isContravariance 是 true。
为了更易于理解,我们可以从安全性的角度理解函数参数是逆变的设定。
如果函数参数类型是协变而不是逆变,那么意味着函数类型 (param: Dog) => void 和 (param: Animal) => void 是兼容的,这与 Dog 和 Animal 的兼容一致,所以我们可以用 (param: Dog) => void 代替 (param: Animal) => void 遍历 Animal[] 类型数组。
但是,这样是不安全的,因为它不能确保 Animal[] 数组中的成员都是 Dog(可能混入 Animal 类型的其他子类型,比如 Cat),这就会导致 (param: Dog) => void 类型的函数可能接收到 Cat 类型的入参。
下面我们来看一个具体示例:
const visitDog = (animal: Dog) => {
animal.woof();
};
let animals: Animal[] = [{ name: 'Cat', miao: () => void 0, }];
animals.forEach(visitDog); // ts(2345)
在示例中,如果函数参数类型是协变的,那么第 5 行就可以通过静态类型检测,而不会提示一个 ts(2345) 类型的错误。这样第 1 行定义的 visitDog 函数在运行时就能接收到 Dog 类型之外的入参,并调用不存在的 woof 方法,从而在运行时抛出错误。
正是因为函数参数是逆变的,所以使用 visitDog 函数遍历 Animal[] 类型数组时,在第 5 行提示了类型错误,因此也就不出现 visitDog 接收到一只 cat 的情况。
(3)双向协变
双向协变也就是说如果 Dog 是 Animal 的子类型,则 F(Dog) 是 F(Animal) 的子类型,也是父类型,既是协变也是逆变。
对应到实际的场景,在 TypeScript 非严格模式下,函数参数类型就是双向协变的。如前边提到函数只有在参数是逆变的情况下才安全,且本课程一直在强调使用严格模式,所以双向协变并不是一个安全或者有用的特性,因此我们不大可能遇到这样的实际场景。
但在某些资料中有提到,如果函数参数类型是双向协变,那么它是有用的,并进行了举例论证 (以下示例缩减自网络):
interface Event {
timestamp: number;
}
interface MouseEvent extends Event {
x: number;
y: number;
}
function addEventListener(handler: (n: Event) => void) {}
addEventListener((e: MouseEvent) => console.log(e.x + ',' + e.y)); // ts(2769)
在示例中,我们在第 4 行定义了接口 MouseEvent 是第 1 行定义的接口 Event 的子类型,在第 8 行定义了函数的 handler 参数是函数类型。如果参数类型是双向协变的,那么我们就可以在第 9 行把参数类型是 Event 子类型(比如说 MouseEvent 的函数)作为入参传给 addEventListener。
这种方式确实方便了很多,但是并不安全,原因见前边 Dog 和 Cat 的示例。而且在严格模式下,参数类型是逆变而不是双向协变的,所以第 9 行提示了一个 ts(2769) 的错误。
由此可以得出,真正有用且安全的做法是使用泛型,如下所示:
function addEventListener<E extends Event>(handler: (n: E) => void) {}
addEventListener((e: MouseEvent) => console.log(e.x + ',' + e.y)); // ok
在示例中的第 1 行,因为我们重新定义了带约束条件泛型入参的 addEventListener,它可以传递任何参数类型是 Event 子类型的函数作为入参,所以在第 2 行传入参数类型是 MouseEvent 的箭头函数作为入参时,则不会提示类型错误。
(4)不变
不变即只要是不完全一样的类型,它们一定是不兼容的。也就是说即便 Dog 是 Animal 的子类型,如果 F(Dog) 不是 F(Animal) 的子类型,那么 F(Animal) 也不是 F(Dog) 的子类型。
对应到实际场景,出于类型安全层面的考虑,在特定情况下我们可能希望数组是不变的(实际上是协变),见示例:
interface Cat extends Animal {
miao: () => void;
}
const cat: Cat = {
name: 'Cat',
miao: () => void 0,
};
const dog: Dog = {
name: 'Dog',
woof: () => void 0,
};
let dogs: Dog[] = [dog];
animals = dogs; // ok
animals.push(cat); // ok
dogs.forEach(visitDog); // 类型 ok,但运行时会抛出错误
在示例中的第 1~3 行,我们定义了一个 Animal 的另外一个子类 Cat。在第 4~8 行,我们分别定义了对象 cat 和对象 dog,并在第 12 行定义了 Dog[] 类型的数组 dogs。
因为数组是协变的,所以我们可以在第 13 行把 dogs 数组赋值给 animals 数组,并且在第 14 行把 cat 对象塞到 animals 数组中。那么问题就来了,因为 animals 和 dogs 指向的是同一个数组,所以实际上我们是把 cat 塞到了 dogs 数组中。
然后,我们在第 15 行使用了 visitDog 函数遍历 dogs 数组。虽然它可以通过静态类型检测,但是运行时 visitDog 遍历数组将接收一个混入的 cat 对象并抛出错误,因为 visitDog 中调用了 cat 上没有 woof 的方法。
因此,对于可变的数组而言,不变似乎是更安全、合理的设定。不过,在 TypeScript 中可变、不变的数组都是协变的,这是需要我们注意的一个陷阱。
介绍完变型相关的术语以及对应的实际场景,我们已经了解了函数参数类型是逆变的,返回值类型是协变的,所以前面的函数类型 (p1: any) => 1 和 (param: any) => number 为什么兼容的问题已经给出答案了。因为返回值类型 1 是 number 的子类型,且返回值类型是协变的,所以 (p1: any) => 1 是 (param: any) => number 的子类型,即是兼容的。
函数类型兼容性
因为函数类型的兼容性、子类型关系有着更复杂的考量(它还需要结合参数和返回值的类型进行确定),所以下面我们详细介绍一下函数类型兼容性的一般规则。
(1)返回值
前边我们已经讲过返回值类型是协变的,所以在参数类型兼容的情况下,函数的子类型关系与返回值子类型关系一致。也就是说返回值类型兼容,则函数兼容。
(2)参数类型
前边我们也讲过参数类型是逆变的,所以在参数个数相同、返回值类型兼容的情况下,函数子类型关系与参数子类型关系是反过来的(逆变)。
(3)参数个数
在索引位置相同的参数和返回值类型兼容的前提下,函数兼容性取决于参数个数,参数个数少的兼容个数多,下面我们看一个具体的示例:
{
let lessParams = (one: number) => void 0;
let moreParams = (one: number, two: string) => void 0;
lessParams = moreParams; // ts(2322)
moreParams = lessParams; // ok
}
在示例中,lessParams 参数个数少于 moreParams,所以如第 5 行所示 lessParams 和 moreParams 兼容,并可以赋值给 moreParams。
注意:如果你觉得参数个数少的函数兼容参数个数多的函数不好理解,那么可以试着从安全性角度理解(是参数少的函数赋值给参数多的函数安全,还是参数多的函数赋值给参数少的函数安全),这里限于篇幅有限就不展开了(你可以作为思考题)。
(4)可选和剩余参数
可选参数可以兼容剩余参数、不可选参数,下面我们具体看一个示例:
let optionalParams = (one?: number, tow?: number) => void 0;
let requiredParams = (one: number, tow: number) => void 0;
let restParams = (...args: number[]) => void 0;
requiredParams = optionalParams; // ok
restParams = optionalParams; // ok
optionalParams = restParams; // ts(2322)
optionalParams = requiredParams; // ts(2322)
restParams = requiredParams; // ok
requiredParams = restParams; // ok
在示例中的第 4~5 行,我们可以把可选参数 optionalParams 赋值给不可选参数 requiredParams、剩余参数 restParams ,反过来则提示了一个 ts(2322) 的错误(第 5~6 行)。
在第 8~9 行,不可选参数 requiredParams 和剩余参数 restParams 是互相兼容的;从安全性的角度理解第 9 行是安全的,所以可以赋值。
最让人费解的是,在第 8 行中,把不可选参数 requiredParams 赋值给剩余参数 restParams 其实是不安全的(但是符合类型检测),我们需要从方便性上理解这个设定。
正是基于这个设定,我们才可以将剩余参数类型函数定义为其他所有参数类型函数的父类型,并用来约束其他类型函数的类型范围,比如说在泛型中约束函数类型入参的范围。
下面我们看一个具体的示例:
type GetFun<F extends (...args: number[]) => any> = Parameters<F>;
type GetRequiredParams = GetFun<typeof requiredParams>;
type GetRestParams = GetFun<typeof restParams>;
type GetEmptyParams = GetFun<() => void>;
在示例中的第 1 行,我们使用剩余参数函数类型 (...args: number[]) => any 约束了入参 F 的类型,而第 2~4 行传入的函数类型入参都是这个剩余参数函数类型的子类型。