TypeScript 中的类型兼容性(Type Compatibility)是建立在结构化子类型(Structural Subtyping)之上的。结构化子类型是一种仅通过类型的成员为类型建立关联的方式。结构化子类型与命名类型(Nominal Typing)是相反的。看一个例子:
interface Pet {name: string;}class Dog {name: string;}let pet: Pet;// OK, because of structural typingpet = new Dog();
在 Java 或者 C# 这种基于命名类型的语言中,同等的上述代码会报错,因为 Dog 类型并没有显式声明它实现了 Pet 的接口。
TypeScript 之所以采用结构化子类型,与 JavaScript 本身的特性是息息相关的。在 JavaScript 的日常使用中,会普遍使用函数表达式、对象字面量等创建匿名对象,这种情况下,使用结构类型系统(Structural type system)来表达对象之间的关系比使用命名类型系统要更加自然。
:::tips A is compatible with B,A 有大类型,B 是小类型,B 可以赋值给 A。 :::
健全性(A Note on Soundness)
TypeScript 的类型系统允许某些在编译时无法确定类型的操作通过类型检测,即认为它们是安全的。当一个类型系统拥有这样的特性时,我们说它是不健全的(not be sound)。当然了,对于那些被允许的不健全行为,TypeScript 都是经过慎重考虑的,这篇文档将会阐述这些行为以及背后的考虑。
开始吧(Starting out)
结构化类型系统的基本规则是,对于类型 x 和 y,如果 y 至少包含与 x 完全相同的成员,那么 x 就与 y 兼容,即 y 可以被认为是 x 类型。看下面这个例子:
interface Pet {
name: string;
}
let pet: Pet;
// dog's inferred type is { name: string; owner: string; }
let dog = { name: "Lassie", owner: "Rudd Weatherwax" };
pet = dog;
为了检查 dog 是否可以赋值给 Pet 类型的变量,即 pet,编译器会遍历检查 Pet 中的每一个成员,看它们是否在 dog 中都有体现。在上面的例子中,dog 必需包含一个值为 string 类型,名称为 name 的成员。结果是确定的,所以赋值通过类型检查。
当给函数指定参数时,也是同样的规则,看下面这个例子:
interface Pet {
name: string;
}
let dog = { name: "Lassie", owner: "Rudd Weatherwax" };
function greet(pet: Pet) {
console.log("Hello, " + pet.name);
}
greet(dog); // OK
需要注意的是,dog 有一个额外的 owner 成员,而 Pet 中并没有,但这并不会报错。在类型兼容检查过程中,只有目标类型(即被赋值的哪个变量的类型)的成员会被考虑。
这个比较的过程是递归进行的,适用于类型的成员类型。
比较两个函数(Comparing two functions)
比较原始值和对象的类型相对来说比较直接,比较两个函数是否兼容就有点复杂了。从一个简单的例子开始,函数的区别只存在于参数中:
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // OK
x = y; // Error
为了检查 x 是否可以赋值给 y,首先要查看参数列表。x 的每一个参数,都必须在 y 的参数中存在(位置相同,类型兼容)。在这个过程中,只有参数的类型会被考虑,参数的名字并不在考虑范围内。在上面这个例子中,x 只有一个参数,类型为 number,y 的第一个参数类型也为 number,number 可以赋值给 number,所以 x 与 y 是兼容的,x 可以被赋值给 y。
x = y 这个赋值会失败,因为 y 的第二个参数是必须的,而 x 并不具备第二个参数,所以赋值不被允许。
你可能会困惑,为什么会允许像 y = x 这种赋值中将多余的参数丢掉的行为呢?理由是,在 JavaScript 中,忽略额外的函数参数的行为是非常普遍的。比如,Array#forEach 的回调函数会收到三个参数,分别是当前的数组项、当前数组项的索引、整个数组。然而,并不是所有情况下都会使用到所有的参数,甚至在大多数情况下我们都只会使用一个参数而已:
let items = [1, 2, 3];
// Don't force these extra parameters
items.forEach((item, index, array) => console.log(item));
// Should be OK!
items.forEach((item) => console.log(item));
再来考虑函数的返回类型如何处理,用两个只有返回类型不同的函数来说明:
let x = () => ({ name: "Alice" });
let y = () => ({ name: "Alice", location: "Seattle" });
x = y; // OK
y = x; // Error, because x() lacks a location property
源函数的返回值类型应该是目标函数返回值类型的子类型。
函数参数偏差(Function Parameter Bivariance)
当比较函数参数的类型时,如果源函数的参数类型与目标函数的参数类型是匹配的,赋值就能成功。这是不健全的,因为调用者可能会收到一个接受更加具体的参数类型的函数,最终却将不那么具体的类型的值传递给这个函数,在实践中,这种情况是比较少的,而且允许这种情况发生为我们带来很多好处。比如:
enum EventType {
Mouse,
Keyboard,
}
interface Event {
timestamp: number;
}
interface MyMouseEvent extends Event {
x: number;
y: number;
}
interface MyKeyEvent extends Event {
keyCode: number;
}
function listenEvent(eventType: EventType, handler: (n: Event) => void) {
/* ... */
}
// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y));
// Undesirable alternatives in presence of soundness
listenEvent(EventType.Mouse, (e: Event) =>
console.log((e as MyMouseEvent).x + "," + (e as MyMouseEvent).y)
);
listenEvent(EventType.Mouse, ((e: MyMouseEvent) =>
console.log(e.x + "," + e.y)) as (e: Event) => void);
// Still disallowed (clear error). Type safety enforced for wholly incompatible types
listenEvent(EventType.Mouse, (e: number) => console.log(e));
可以通过设置 strictFunctionTypes 来禁止这一行为(strictFunctionTypes)。
可选参数和剩余参数(Optional Parameters and Rest Parameters)
当比较函数的兼容性时,可选参数和必需参数是可以互换的。源函数的额外可选参数不会报错,目标函数可选参数也不要求源函数中有相对应的类型兼容的参数。
对于函数的剩余参数,将会被视为该函数有无限个可选参数。
从类型系统的角度来看,这是不健全的,但从运行时的角度来看,可选参数本来就意味着该位置传递正确类型的参数和传递 undefined 对于函数的运行没有实质影响。
举个例子:我们经常会创建这样的函数,它会接受一个回调函数,并在将来的某个时间调用,调用这个函数时为它传递的参数数量对于开发者来说是可预测的,但对于类型系统来说却是未知的。
function invokeLater(args: any[], callback: (...args: any[]) => void) {
/* ... Invoke callback with 'args' ... */
}
// Unsound - invokeLater "might" provide any number of arguments
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));
// Confusing (x and y are actually required) and undiscoverable
invokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));
函数重载(Function with overloads)
当一个函数有重载时,源函数的每一个重载类型,都必须在目标函数的类型中找到兼容类型。保证目标函数在任何情况下都可以像源函数一样调用。
枚举(Enums)
枚举与数值兼容,数值也跟枚举兼容。来自不同枚举类型的值是不兼容的,哪怕它们的值是相等的。
enum Status {
Ready,
Waiting,
}
enum Color {
Red,
Blue,
Green,
}
let status = Status.Ready;
status = Color.Green; // Error
类(Classes)
类除了同时具备静态类型(static type)和实例类型(instance type)之外,其它方面与对象字面量和接口类似。
当比较具有类类型的两个对象时,只会考虑实例的成员,静态成员和构造函并不在类型兼容性考虑范围内。
class Animal {
feet: number;
constructor(name: string, numFeet: number) {}
}
class Size {
feet: number;
constructor(numFeet: number) {}
}
let a: Animal;
let s: Size;
a = s; // OK
s = a; // OK
类的私有成员和受保护成员(Private and protected members in class)
类的私有和被保护成员会影响类型兼容性比较。当类的实例进行兼容性检查时,如果目标类型含有一个私有成员,那么源类型也应该含有一个继承自同一个类的私有成员。含有受保护的私有成员也同理。这保证子类型的实例可以正确地赋值给父类型的值,而不会被错误地赋值给具有相同形状但不具备继承关系的其它类型的值。
泛型(Generics)
由于 TypeScript 是结构化类型系统,所以类型参数只会在它作用于成员的类型时才会影响结果的类型。
interface Empty<T> {}
let x: Empty<number>;
let y: Empty<string>;
x = y; // OK, because y matches structure of x
在这个例子中,x 和 y 是互相兼容的,因为类型参数 T 并没有实际影响结果类型。但是,如果换一种方式,就不一样了:
interface NotEmpty<T> {
data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
x = y; // Error, because x and y are not compatible
这里,类型参数 T 被用于成员类型,当类型参数指定为不同的类型时,结果的类型自然也不同。
当泛型参数没有被明确指定类型时,会按照 any 来处理。
let identity = function <T>(x: T): T {
// ...
};
let reverse = function <U>(y: U): U {
// ...
};
identity = reverse; // OK, because (x: any) => any matches (y: any) => any
进阶内容(Advanced Topics)
子类型 vs 赋值(Subtype vs Assignment)
目前为止,我们使用的一直都是“兼容性”,这个词在语言的规范中并没有定义。在 TypeScript 中,兼容性有两种场景:子类型和赋值。赋值的兼容性基本是基于子类型的,在子类型兼容性的基础之上添加了对 any 的处理,以及数值类型和数值枚举的处理。
语言的不同位置会使用两种兼容机制之一,具体使用哪种视情况而定。出于实际目的,类型兼容性由赋值兼容性决定,即使是在 implements 或者 extends 等语句中。
any, unknown, object, void, undefined, null, never 的赋值(Assignability)
下面的表格梳理了不同的抽象类型之间的赋值关系。行代表该类型可以赋值的目标类型,列代表哪些类型可以赋值给该类型。✓ 表示这个赋值关系是否只有当关闭 strictNullCheck 选项时可用(strickNullCheck)。
| any | unknown | object | void | undefined | null | never | ||
|---|---|---|---|---|---|---|---|---|
| any → | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | ||
| unknown → | ✓ | ✕ | ✕ | ✕ | ✕ | ✕ | ||
| object → | ✓ | ✓ | ✕ | ✕ | ✕ | ✕ | ||
| void → | ✓ | ✓ | ✕ | ✕ | ✕ | ✕ | ||
| undefined → | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | ||
| null → | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | ||
| never → | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
有一些基本原则:
- 任何类型都可以赋值给它自己。
- 任何类型都可以赋值给
any和unknown,any可以赋值给除never外的任何类型,unknown只能赋值给any类型。 unknown和never几乎完全相反。任何类型都可以赋值给unknown,never可以赋值给任何类型。任何类型都不可以赋值给never,unknown不能赋值给任何类型(除any)。void不能赋值给任何类型,任何类型都不能赋值给void,除了any、unknown、never、undefined、null(如果strictNullCheck关闭的话)这几个例外。- 当
strictNullCheck关闭的时候,null和undefined与never很像。能够赋值给大多数类型,大多数类型都能赋值给它们。null和undefined可以互相赋值。 - 当
strictNullCheck开启的时候,null和undefined与void很像。不能够赋值给任何类型,也不能被赋值为任何类型,除了any、unknown、never、void(undefined始终可以赋值给void)。
:::tips 官方文档最后这一部分跟糊浆糊一样,本来看个表格就比较清楚了,非要讲这么多废话。 :::
