类型兼容性

TypeScript的类型兼容性是基于结构化子类型的。它与名义类型(nominal typing)相对立。思考以下代码:

  1. interface Named {
  2. name: string;
  3. }
  4. class Person {
  5. name: string;
  6. }
  7. var p: Named;
  8. // OK, because of structural typing
  9. p = new Person();

在大多数的名义类型编程语言中(如C#Java),以上代码将会得到一个报错,因为Person类没有明确表示它实现了Named接口。

TypeScript的结构化类型系统是基于JavaScript的典型编码场景所设计的。由于JavaScript经常使用像函数表达式或对象字面量这样的匿名对象,利用结构化类型系统,会更适用于描述它们间的关系。

关于可靠性的提醒

TypeScript的类型系统允许某些不能在编译阶段就确保安全的操作。当一个类型系统允许这些时,它会被认为是不可靠的。但TypeScript之所以允许这些行为是经过严格的思考的,我们将会在下文解释原因。

开始

TypeScript的结构化类型系统中,最基本的原则就是,如果y至少有和x一模一样的成员,那么x就是兼容y的。例子:

  1. interface Named {
  2. name: string;
  3. }
  4. var x: Named;
  5. // y’s inferred type is { name: string; location: string; }
  6. var y = { name: 'Alice', location: 'Seattle' };
  7. x = y;

为了检查y可否能被赋值给x,编译器会检查x中的所有属性,在y里是否有相匹配的。所以在例子里,y必须包含一个类型为stringname属性。它的确有,所以这个赋值操作是可行的。

在检查函数调用时的参数时,规则也是一样的:

  1. function greet(n: Named) {
  2. alert('Hello, ' + n.name);
  3. }
  4. greet(y); // OK

值得注意的是,y有一个额外的location属性,但这并不会产生一个错误。只有目标类型(例子中为Named)的成员,才会被纳入兼容性检查。

编译器会递归地比较两个类型下的成员以及子成员。

比较两个函数

两个基本类型值和对象的比较是十分直观的。那么是时候来探讨两个函数的兼容性了。让我们以一组只有参数不同的函数开始:

  1. var x = (a: number) => 0;
  2. var y = (b: number, s: string) => 0;
  3. y = x; // OK
  4. x = y; // Error

当将x赋值给y时,编译器首先会查看参数列表。y中的每一个参数都必须在x中有对应的类型兼容的参数。注意,参数名不同并没有关系,仅会考虑它们的类型。在例子里,x中的每一个参数都在y中有相兼容的参数,所以这个赋值是可行的。

而第二个赋值操作则会报错。因为y要求有第二个参数,而x并没有。

你或许会疑问,为什么我们在y = x的例子里,会允许“丢弃”第二个参数。这是因为,在JavaScript中,忽略后面的部分参数是很常见的做法。例如,Array#forEach提供了三个参数:数组单个元素,索引,和数组本身。但是实际使用中,人们经常只传递第一个参数:

  1. var items = [1, 2, 3];
  2. // Don't force these extra arguments
  3. items.forEach((item, index, array) => console.log(item));
  4. // Should be OK!
  5. items.forEach((item) => console.log(item));

现在让我们来看看返回值,以下是两个只有返回值不同的函数:

  1. var x = () => ({name: 'Alice'});
  2. var y = () => ({name: 'Alice', location: 'Seattle'});
  3. x = y; // OK
  4. y = x; // Error because x() lacks a location property

类型系统要求源函数的返回值是目标函数返回值的子集。

函数参数的双边变化

在比较两个函数参数的类型时,只要源参数是可以赋值给目标参数的,或反之,赋值都能成功。这被认为是不可靠的,因为一边的函数参数可能会被描述更精确的参数类型,但是执行是却被传入一个更宽泛的类型。在实践中,这类的错误时很罕见的,并且借此实现了很多JavaScript模式。一个简单的例子:

  1. enum EventType { Mouse, Keyboard }
  2. interface Event { timestamp: number; }
  3. interface MouseEvent extends Event { x: number; y: number }
  4. interface KeyEvent extends Event { keyCode: number }
  5. function listenEvent(eventType: EventType, handler: (n: Event) => void) {
  6. /* ... */
  7. }
  8. // Unsound, but useful and common
  9. listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + ',' + e.y));
  10. // Undesirable alternatives in presence of soundness
  11. listenEvent(EventType.Mouse, (e: Event) => console.log((<MouseEvent>e).x + ',' + (<MouseEvent>e).y));
  12. listenEvent(EventType.Mouse, <(e: Event) => void>((e: MouseEvent) => console.log(e.x + ',' + e.y)));
  13. // Still disallowed (clear error). Type safety enforced for wholly incompatible types
  14. listenEvent(EventType.Mouse, (e: number) => console.log(e));

可选参数和rest参数

当比较两个函数时,可选和必选参数是可以相互交换的。源函数的额外可选参数将不会导致一个报错,目标函数的不对应的可选参数也不会导致报错。

当一个函数有rest参数时,它被视为有无限个可选参数。

这也被认为是不可靠的,因为在大多数的运行时里,可选参数的空缺往往会被强制传入一个undefined

下面的例子里,一个函数接受一个回调,并且使用一个(对于程序员)可预测的,但是(对于类型系统)未知数量的参数执行:

  1. function invokeLater(args: any[], callback: (...args: any[]) => void) {
  2. /* ... Invoke callback with 'args' ... */
  3. }
  4. // Unsound - invokeLater "might" provide any number of arguments
  5. invokeLater([1, 2], (x, y) => console.log(x + ', ' + y));
  6. // Confusing (x and y are actually required) and undiscoverable
  7. invokeLater([1, 2], (x?, y?) => console.log(x + ', ' + y));

有重载的函数

当一个函数具有重载时,它重载列表中的每一个函数类型都必须与目标匹配。这保证了目标函数可以在相同的情况下被执行。

枚举类型

枚举类型和数字类型兼容,反之也成立。不同的枚举类型的枚举值是不兼容的。例子:

  1. enum Status { Ready, Waiting };
  2. enum Color { Red, Blue, Green };
  3. var status = Status.Ready;
  4. status = Color.Green; //error

类的兼容性与对象字面量和接口类似,但只有一个区别:类有静态和实例部分。当比较两个类的实例时,只有实例部分会被比较。静态部分和构造函数并不会影响兼容性。

  1. class Animal {
  2. feet: number;
  3. constructor(name: string, numFeet: number) { }
  4. }
  5. class Size {
  6. feet: number;
  7. constructor(numFeet: number) { }
  8. }
  9. var a: Animal;
  10. var s: Size;
  11. a = s; //OK
  12. s = a; //OK

类中的私有成员

当一个类中有私有成员时,目标类必须有来自同一出处的私有成员,才会被认作是兼容的。举个例子,子类是兼容父类的,但具有相同描述的具有私有成员的两个不同类则不兼容。

泛型

由于TypeScript使用的是结构化类型系统,类型参数只影响其作为部分成员的结果类型。例子:

  1. interface Empty<T> {
  2. }
  3. var x: Empty<number>;
  4. var y: Empty<string>;
  5. x = y; // okay, y matches structure of x

上述例子中,xy是兼容的,因为它们的机构体里没有以不同方式使用类型参数。让我们改变一下:

  1. interface NotEmpty<T> {
  2. data: T;
  3. }
  4. var x: NotEmpty<number>;
  5. var y: NotEmpty<string>;
  6. x = y; // error, x and y are not compatible

这样的话,它们就有了各自独特类型的属性,就像有了非泛型属性一样。

对于没有在内部使用过类型参数的泛型,兼容性检查会将类型参数视为any。然后再以非泛型的方式得出检测结果:

For example,

  1. var identity = function<T>(x: T): T {
  2. // ...
  3. }
  4. var reverse = function<U>(y: U): U {
  5. // ...
  6. }
  7. identity = reverse; // Okay because (x: any)=>any matches (y: any)=>any

高级话题

子类型 vs 赋值

至今为止,我们讨论了“兼容性”,这并不是一个被定义在了语言层面的概念。在TypeScript中,有两种兼容性:子类型和赋值。它们仅有的不同是,赋值操作通过传递any的规则,拓展了子类型兼容性。

不同的情况下,TypeScript会选择不同的兼容性。实际生产中,甚至在implementsextends语句里,类型兼容性也是由赋值兼容性所控制的。更多信息,请参阅 这里