类型兼容
简介
TypeScript 中的类型兼容性基于结构化子类型,结构化类型是一种仅基于其成员相关类型的处理方式。这和名义类型系统(nominal typing)相反,看下面的例子。
interface Named {
name: string;
}
class Person {
name: string;
}
let p: Named;
// OK, because of structural typing
p = new Person();
在像 C# 和 Java 的名义类型编程语言中,同样的代码将会出错,因为 Person 类并没有显式地声明自己是 Named 接口的实现类。
TypeScript 的结构化类型系统是基于 JavaScript 代码的编写习惯来设计的。因为 JavaScript 中广泛使用匿名对象,比如函数表达式和对象字面量。用结构化类型系统而不是名义类型系统来表示 JavaScript 库中的各种依赖关系是很自然的。
合理性的声明
TypeScript 的类型系统允许某些在编译时未知的操作是安全的。当一个类型系统有这个问题时,我们一般就说它不合理。我们仔细考虑了 TypeScript 允许不合理的地方,在整个文档中,我们将说明发生这些情况的原因以及背后的出发点。
开始
TypeScript 结构类型系统的基本规则是:如果 y 与 x 至少具有相同的成员,则 x 与 y 兼容。例如。
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,编译器检查 y 中是否有 x 的每个相应的属性。在本例中,y 必须要有一个叫做 name 的 string 类型的属性。y 中确实有这个属性,所以把 y 赋值给 x 是可以的。
检查函数调用的参数时也是同样的规则。
function greet(n: Named) {
console.log("Hello, " + n.name);
}
greet(y); // OK
注意,y 有一个额外的属性 location,但这不会造成错误。在做类型兼容性检查的时候只会检查目标类型(在本例中是 Named)。
这种类型检查是递归进行的,类型检查器会检查所有的成员及其子成员。
比较两个函数
比较原始类型和对象类型相对简单,但是比较两个函数是否兼容会更复杂一些。让我们从一个基本的示例开始,示例中两个函数仅仅只是参数列表不一致。
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // OK
x = y; // Error
为了确认 x 是否可以赋值给 y,我们首先需要看参数列表。x 的参数列表中的每个参数在 y 中都必须要有与之兼容的参数。注意参数的名字不影响检查结果,仅仅比较其类型。在本例中, x 的每个参数在 y 中都有与之兼容的参数,所以 x 可以赋值给 y。
第二个赋值会出错,因为 y 有一个必选参数在 x 中是没有的,所以 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
类型系统强制要求源函数的返回类型是目标函数的返回类型的子类型。
函数参数的二变量性 (Bivariance)
当比较函数参数的类型时,如果源函数的参数可以指定给目标函数对应的参数,那么赋值将成功,反之亦然。这种现象是不合理的,因为如果任一函数有一个参数是函数类型,该函数类型参数自身也有参数,那么呼叫方得到的函数类型参数的参数有可能是定义的参数类型的子类型。在实践中这种错误是很少见的,但是这种错误又能实现许多 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) {
/* ... */
}
// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + "," + e.y));
// Undesirable alternatives in presence of soundness
listenEvent(EventType.Mouse, (e: Event) => console.log((e as MouseEvent).x + "," + (e as MouseEvent).y));
listenEvent(EventType.Mouse, ((e: MouseEvent) => 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 可以让 TypeScript 报错。
可选参数和 Rest 参数
当比较函数的兼容性时,可选参数和必选参数是可以互换的,源类型的可选参数不会引发错误,目标类型的可选参数如果在源类型中没有,也不会引发错误。
当一个函数有 Rest 参数时,将其视为无数的可选参数。
从类型系统的角度来看这是不合理的,但是从运行时的角度来看可选参数可以当作是传入了 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));
函数重载
当函数重载时,源类型中的每一个重载方法在目标类型中都必须有与之兼容的方法。这样能确保在与源函数相同的情况下可以顺利调用目标函数。
枚举
枚举和数字类型本身是兼容的,不同类型的枚举值不兼容。例如,
enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };
let status = Status.Ready;
status = Color.Green; // Error
类
类的工作方式类似于对象字面量和接口,但有一个例外:类同时拥有静态类型和实例类型。当比较一个类的两个实例时,只考虑实例部分,静态成员和构造方法不影响类的兼容性。
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; // OK, because y matches structure of x
上面代码中,x 和 y 是兼容的,因为它们的结构中并没有以不同的方式来使用类型参数。现在修改代码给 Empty<T> 接口加上一个成员。
interface NotEmpty<T> {
data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
x = y; // Error, because x and y are not compatible
有了成员 data 以后。指定了类型参数的泛型类型就和非泛型类型拥有同样表现。
对于未指定类型参数的泛型类型,默认会给类型参数设置 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
进阶内容
子类型和赋值
到目前为止,我们使用了“兼容性”一词,它不是语言规范中的术语。在 TypeScript 中,有两种类型兼容性:子类型兼容性和赋值兼容性。两者唯一不同的是赋值兼容性是子类型兼容性的延伸。赋值兼容性允许将 any 类型赋值给其它类型或将其它类型赋值给 any 类型。赋值兼容性还允许将 enum 类型赋值给数字类型或将数字类型赋值给 enum 类型。
更多的信息请查阅 TypeScript 规范