一、说明

TypeScript 的类型兼容都是基于结构子类型。

结构类型是一种只使用其成员描述类型的方式。

在基于名义(nominal)类型的类型系统中,数据类型的兼容性或等价性是通过明确的声明和/或类型的名称类决定的,而结构性类型系统不同,它是基于类型的组成结构,而且不要求明确的声明。

interface Named {
    name: string;
}
class Person {
    name: string; 
   // strictPropertyInitialization 检查这里会报错误,因为没有在 constructor 初始化
}
let p: Named = new Person();

// 可行,因为这是一个结构类型

在传统的面向对象的语言中(比如 C# 或者 Java)上面代码会报错误,因为没有明确声明 Person 与 Named 的关系,Person 没有实现 Named 接口

使用结构类型系统来描述这些内容比名义类型方便,也是基于 JavaScript 代码来设计的,因为在 JavasScript 中广泛的使用匿名对象,比如函数表达式和对象字面量,因此结构类型优于名义类型。

关于可靠性的注意事项

TypeScript 的类型系统允许在某些编译阶段无法确认其安全性的操作。当一个类型系统存在这种属性时,被当做是“不可靠的”。

二、使用

TypeScript 结构化类型系统的基本规则是,如果 x 要兼容 y ,那么 y 至少具有与 x 相同的属性:

interface Named {
    name: string;
}

let x: Named;
// y 的类型结构式 { name: string; location: string; }
let y = { name: 'Alice', location: 'Seattle' };
x = y;

上面代码中检查 y 能否赋值给 x,编译器会检查 x 中的每个属性,看是否能在 y 中也找到对应属性。而 y 有 name 属性,而这也是 x 的唯一属性,因此 y 能够赋值给 x

函数参数的检查试用的规则是一样的:

interface Named {
    name: string;
}
let y = {name: 'nameA', age: 18};
function getName(p: Named) {
}
getName(y); // OK

虽然上面 y 变量有一个额外的 age 属性,但是接口类型检查只对 y 检查是否存在 name 属性,因此是兼容的。

比较的过程是递归进行了,会进行深层次的比较,比如下面的代码中编译器会报错:

interface Named {
    last: string;
    first: string;
}
interface Person {
    name: Named;
}
let y = {
    name: {
        first: 'first',
        last: 12
}};
function getName(p: Person) {
}
getName(y); // 报错,深层检查不通过

1.jpg

三、比较两个函数

判断基础类型或者是对象格式的类型还是比较容易判断出来,关键在于如何判断两个函数是兼容的。

let funcA = (a: number) => 0;
let funcB = (b: number, s: string) => 1;

funcB = funcA; // ok

funcA = funcB; // 报错

2.jpg

比较函数主要是比较它们的参数列表能否兼容,形参的名称是不重要的,重要的是顺序和类型。

将 funcA 赋给 funcB 是没问题的, 因为 funcB 的参数足以兼容 funcA 的参数,但是将 funcB 赋给 funcA 是不行的,funcA 无法兼容多的 s 参数。

funcB = funcA 这样的形式是 OK 的,因为 JavaSript 本身就经常忽略额外的参数,但是缺少参数是不行的。

函数的兼容除了比较参数之外,还会比较返回值类型。

let funcA = () => ({name: 'name'});
let funcB = () => ({name: 'name', age: 18});

funcA = funcB; // ok
funcB = funcA; // 报错

上面 funcB=funcA 会报错,因为 funcA 无法兼容 funcB 的返回值中的 age 属性。

类型系统强制源函数的返回值类型必须是目标函数返回值类型的子类型。

函数参数双向协变

当比较函数参数类型时,只有当源函数参数能够复制给目标函数或者反过来的时候才能赋值成功。这是不稳定的,因为调用者可能传入了一个具有更精确类型信息的函数,但是调用这个传入的函数的时候却是用了不是那么精确的类型信息。

enum EventType {Mouse, Keybord}

interface Event {timestamp: number}
interface MouseEvent extends Event {x:numner; 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 的调用会报错,因为第二个参数是一个函数,而且函数的类型是 Event 使用的时候,却传入了 MouseEvent。

虽然会报错,但是这确实是非常常见的用法。

上面错误的代码其实是有替代方案,但是这个方案非常的繁杂,大部分场景下是没有必要的:

// 通过 <MouseEvent> 泛型强制声明
listenEvent(EventType.Mouse, (e: Event) => console.log((<MouseEvent>e).x + ',' + (<MouseEvent>e).y));

// 强制声明函数的泛型 <(e: Event) => void>
listenEvent(EventType.Mouse, <(e: Event) => void>((e: MouseEvent) => console.log(e.x + ',' + e.y)));

可选参数及剩余参数

比较函数的兼容性的时候,可选参数和必选参数是可以互换的。源类型上有额外的可选参数不是错误,目标类型的可选参数在源类型里没有对应的参数也不是错误。

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

这对于类型系统来说是不稳定的,但从运行时的角度来看,可选参数一般来说是不强制的,因为对于大多数函数来说相当于传递了一些undefinded

函数接收一个回调函数,而对于程序员来说是可预知的参数,但对类型系统来说是不确定的参数来调用:

function invokeLater(args: any[], callback:(...args: any[]) => void) {}

invokeLater([1,2], (x,y) => { console.log(x +' '+y) });

invokeLater([1, 2], (x?, y?) => console.log(x + ', ' + y));

函数重载

对于重载的函数,源函数的每个重载都要在目标函数上找到对应的函数名。确保了目标函数可以在所有源函数可调用的地方调用。

四、枚举

枚举类型与数字类型兼容,并且数字类型与枚举类型兼容。不同枚举类型之间是不兼容的。比如,

enum Status {Reday, Waiting};
enum Color {Red, Blue, Green};

let s= Status.Ready;
s = Color.Red; // 报错

3.jpg

五、类

类与对象字面量和接口差不多,但有一点不同:类有静态部分和实例部分的类型。 比较两个类类型的对象时,只有实例的成员会被比较。 静态成员和构造函数不在比较的范围内。

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<number>;

x = y; // OK 因为 y 能够匹配 x 的结构

上面代码没有指定具体的接口成员,因此此时 x 和 y 结构类型其实是相同的。

但是如果此时加了一个成员:

interface Empty<T> {
    name: T
}
let x: Empty<number>;
let y: Empty<number>;
 y = x; // 报错

对于没指定泛型类型的泛型参数时,会把所有泛型参数当成any比较。 然后用结果类型进行比较,就像上面第一个例子。