TypeScript 里的类型兼容性是基于结构子类型的。 结构类型是一种只使用其成员来描述类型的方式。 它正好与名义(nominal)类型形成对比。在基于名义类型的类型系统中,数据类型的兼容性或等价性是通过明确的声明和/或类型的名称来决定的。这与结构性类型系统不同,它是基于类型的组成结构,且不要求明确地声明。
TypeScript 的结构性子类型是根据 JavaScript 代码的典型写法来设计的。 因为 JavaScript 里广泛地使用匿名对象,例如函数表达式和对象字面量,所以使用结构类型系统来描述这些类型比使用名义类型系统更好。
:::warning
关于可靠性的注意事项
TypeScript的类型系统允许某些在编译阶段无法确认其安全性的操作。当一个类型系统具此属性时,被当做是“不可靠”的。TypeScript允许这种不可靠行为的发生是经过仔细考虑的。
:::
类型兼容性是指:当一个类型 Y 可以被赋值给另一个类型 X 时,我们可以说类型 X 兼容类型 Y。
对象兼容性
TypeScript 结构化类型系统的基本规则是,如果 x 要兼容 y,那么 y 至少具有与 x 相同的属性。
interface Named {
name: string;
}
let x: Named;
// y's inferred type is { name: string; location: string; }
let y = { name: 'Alice', location: 'Seattle' };
x = y;
要检查 y 是否能赋值给 x,编译器检查 x 中的每个属性,看是否能在 y 中也找到对应属性。
函数参数中的对象类型检查也是相同的规则:
function greet(n: Named) {
console.log('Hello, ' + n.name);
}
greet(y); // OK
只有目标类型(这里是Named)的成员会被一一检查是否兼容,且这个比较过程是递归进行的,检查每个成员及子成员。
函数兼容性
检查函数的兼容性,主要分为两个部分:参数和返回值。
(1)参数的比较
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // 正确
x = y; // 报错
首先看它们的参数列表。 x 的每个参数必须能在 y 里找到对应类型的参数。 注意的是参数的名字相同与否无所谓,只看它们的类型。 这里,x 的每个参数在 y 中都能找到对应的参数,所以允许赋值。
比较的规则是:x 中的所有参数,在 y 中都能找到对应的参数,那么 y 就能兼容 x,即 x 能赋值给 y。
(2)返回值的比较
let x = () => ({name: 'Alice'});
let y = () => ({name: 'Alice', location: 'Seattle'});
x = y; // 正确
y = x; // 报错
类型系统强制源函数的返回值类型必须是目标函数返回值类型的子类型。
比较的规则是:x 的返回值必须是 y 的子类型,那么 x 才能兼容 y**,即 y 能赋值给 x
函数参数的双向协变
当比较函数参数类型时,只有当源函数参数能够赋值给目标函数或者反过来时才能赋值成功。 这是不稳定的,因为调用者可能传入了一个具有更精确类型信息的函数,但是调用这个传入的函数的时候却使用了不是那么精确的类型信息。 实际上,这极少会发生错误,并且能够实现很多JavaScript里的常见模式。
enum EventType { Mouse, Keyboard }
interface Event { timestamp: number; }
interface MouseEvent extends Event { x: number; y: number }
interface KeyEvent extends Event { keyCode: number }
function listenEvent(eventType: EventType, handler: (n: Event) => void) {
/* ... */
}
// 不健全,但是更普遍和有用
listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + ',' + e.y));
// 健全,但是不大好的一种用法,使用了类型断言
listenEvent(EventType.Mouse, (e: Event) => console.log((<MouseEvent>e).x + ',' + (<MouseEvent>e).y));
listenEvent(EventType.Mouse, <(e: Event) => void>((e: MouseEvent) => console.log(e.x + ',' + e.y)));
// 报错,不允许的
listenEvent(EventType.Mouse, (e: number) => console.log(e));
可选参数和剩余参数
比较函数兼容性的时候,可选参数与必须参数是可互换的。 源类型上有额外的可选参数不是错误,目标类型的可选参数在源类型里没有对应的参数也不是错误。
当一个函数有剩余参数时,它被当做无限个可选参数。
这对于类型系统来说是不稳定的,但从运行时的角度来看,可选参数一般来说是不强制的,因为对于大多数函数来说相当于传递了一些undefinded。
function invokeLater(args: any[], callback: (...args: any[]) => void) {
}
// 不健全的,invokeLater 可能提供了任意的参数
invokeLater([1, 2], (x, y) => console.log(x + ', ' + y));
// 混乱且无法被发现,实际上 x y 是必须的
invokeLater([1, 2], (x?, y?) => console.log(x + ', ' + y));
函数重载
对于有重载的函数,源函数的每个重载都要在目标函数上找到对应的函数签名。 这确保了目标函数可以在所有源函数可调用的地方调用。
枚举兼容性
枚举类型与数字类型兼容,并且数字类型与枚举类型兼容。不同枚举类型之间是不兼容的。
enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };
let status = Status.Ready; // 枚举类型
status = Color.Green; // 报错,不同的枚举类型不兼容
// 对比
let status:number = Status.Ready; // 数字类型兼容枚举类型,赋值之后仍然是数字类型
status = Color.Green; // 数字类型兼容枚举类型
类兼容性
类与对象字面量和接口差不多,但有一点不同:类有静态部分和实例部分的类型。 比较两个类类型的对象时,只有实例的成员会被比较。 静态成员和构造函数不在比较的范围内。
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
类的私有成员和受保护成员
类的私有成员和受保护成员会影响兼容性。 当检查类实例的兼容时,如果目标类型包含一个私有成员,那么源类型必须包含来自同一个类的这个私有成员。 同样地,这条规则也适用于包含受保护成员实例的类型检查。 这允许子类赋值给父类,但是不能赋值给其它有同样类型的类。
也就是说,当类中有私有或者受保护成员时,只能允许子类赋值给父类。
泛型
因为 TypeScript 是结构性的类型系统,类型参数只影响使用其做为类型一部分的结果类型。
interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;
x = y; // 正确
// 对比
interface Empty<T> {
data: T
}
let x: Empty<number>;
let y: Empty<string>;
x = y; // 报错,内部的 data 类型不一致
对于没指定具体的泛型类型的泛型参数时,会把所有泛型参数当成 any 比较。
let identity = function<T>(x: T): T { // (x: any) => any
// ...
}
let reverse = function<U>(y: U): U { // (y: any) => any
// ...
}
identity = reverse; // 正确
子类型与赋值
目前为止,我们使用了“兼容性”,它在语言规范里没有定义。 在 TypeScript 里,有两种兼容性:子类型和赋值。 它们的不同点在于,赋值扩展了子类型兼容性,增加了一些规则,允许和 any 来回赋值,以及 enum 和对应数字值之间的来回赋值。
语言里的不同地方分别使用了它们之中的机制。 实际上,类型兼容性是由赋值兼容性来控制的,即使在 implements 和 extends 语句也不例外。