类型兼容性
类型兼容性用于确定一个类型能否赋值给其他类型。TypeScript里的类型兼容性是基于结构子类型的。 结构类型是一种只使用其成员来描述类型的方式。 它正好与名义(nominal)类型形成对比(在基于名义类型的类型系统中,数据类型的兼容性或等价性是通过明确的声明和/或类型的名称来决定的。这与结构性类型系统不同,它是基于类型的组成结构,且不要求明确地声明。)。通常来说结构相同,类型也就兼容。
如下面的string类型和number类型是不兼容的,因此它们之前不能相互赋值。
let str: string = '123';
let num: number = 123;
num = str; // error 不能将类型“string”分配给类型“number”。
str = num; // error 不能将类型“number”分配给类型“string”
但是下面的Named接口和Person类,它们之间的类型是兼容的。
interface Named {
name: string;
}
class Person {
name: string;
}
let p: Named;
// OK, because of structural typing
p = new Person();
在这里Named和Person都有成员name,且都是number类型,因为它们是兼容的,可以赋值。
稳定性
TypeScript类型系统的设计运行你有一些不正确的行为。例如,任何类型都能被赋值给any,意味着编译器允许你做任何想做的事情。
let foo: any = 'foo';
foo = 123; // ok
结构化
TypeScript对象是一种结构化的类型,这就意味着只要结构匹配,名称也就无所谓了,编译器就认为它们类型是兼容的。比如上面例子中的Named和Person。下面这个例子也说明了这一点:
interface Point {
x: number;
y: number
}
class Point2D {
constructor(public x: number, public y: number) {
}
}
let p: Point = new Point2D(1, 2); // ok 结构化的类型
这种结构化类型需要满足最小结构原则,也即是赋值目标结构至少要满足源目标的所有结构(可以有额外的成员):
interface Point2D {
x: number;
y: number
}
interface Point3D {
x: number;
y: number;
z: number
}
const point2D = {
x: 0, y: 0 };
const point3D = {
x: 0, y: 0, z: 0 };
function iTakePoint2D(point: Point2D) {
// do something
}
iTakePoint2D(point2D) // ok 完全匹配
iTakePoint2D(point3D) // ok 有额外属性z
iTakePoint2D({
x: 0 }) // error 缺少属性y
变体
对类型兼容分析来说,变体是一个很容易理解,而且很重要的概念。
对简单的类型Base和Child来说,如果Child是Base的子类,则Child的实例能被赋值给Base类型的变量:
class Base {
name: string
}
class Child extends Base{
name: string
}
let base:Base;
let child = new Child();
base = child; // ok 多态性
在由Base和Child组合的复杂类型的兼容性中,它取决于相似场景下的Base与Child的变体。
- 协变(Covariant):只在同一个方向兼容。
- 逆变(Contravariant):只在相反的方向上兼容
- 双向协变(Bivariant):包括同一个方向和不同方向的兼容
- 不变(Invariant):如果类型不完全相同,则它们是不兼容的。
函数
比较两个函数的时候,下面是一些需要考虑的事情。
返回类型
协变(Covariant):返回类型必须包含足够的数据。
interface Point2D {
x: number;
y: number
}
interface Point3D {
x: number;
y: number;
z: number
}
let iMakePoint2D = (): Point2D => ({
x: 0, y: 0 });
let iMakePoint3D = (): Point3D => ({
x: 0, y: 0, z: 0 });
iMakePoint2D = iMakePoint3D; // ok
iMakePoint3D = iMakePoint2D // error 不能将类型“() => Point2D”分配给类型“() => Point3D”。类型 "Point2D" 中缺少属性 "z",但类型 "Point3D" 中需要该属性。
参数数量
相对来讲,在比较原始类型和对象类型的时候是比较容易理解的,问题是如何判断两个函数是兼容的。 下面我们从两个简单的函数入手,它们仅是参数列表略有不同:
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // OK
x = y; // Error
要查看x是否能赋值给y,首先看它们的参数列表。 x的每个参数必须能在y里找到对应类型的参数。 注意的是参数的名字相同与否无所谓,只看它们的类型。 这里,x的每个参数在y中都能找到对应的参数,所以允许赋值。
第二个赋值错误,因为y有个必需的第二个参数,但是x并没有,所以不允许赋值。
你可能会疑惑为什么允许忽略参数,像例子y = x中那样。 原因是忽略额外的参数在JavaScript里是很常见的。 例如,Array#forEach给回调函数传3个参数:数组元素,索引和整个数组。 尽管如此,传入一个只使用第一个参数的回调函数也是很有用的:
let items = [1, 2, 3];
// Don't force these extra arguments
items.forEach((item, index, array) => console.log(item));
// Should be OK!
items.forEach((item) => console.log(item));
可以看出函数类型兼容,从返回类型来看,赋值操作左边需要被满足,从参数数量来看,赋值操作右边需要被满足。
可选参数和剩余参数
为了方便起见,可选参数(预先确定的)和rest参数(任何数量的参数)是兼容的。
let foo = (x: number, y: number) => {
};
let bar = (x?: number, y?: number) => {
};
let bas = (...args: number[]) => {
};
foo = bar = bas;
bas = bar = foo;
函数参数类型
当比较函数参数类型时,只有当源函数参数能够赋值给目标函数或者反过来时才能赋值成功。 这是不稳定的,因为调用者可能传入了一个具有更精确类型信息的函数,但是调用这个传入的函数的时候却使用了不是那么精确的类型信息。 实际上,这极少会发生错误,并且能够实现很多JavaScript里的常见模式。
双向协变(Bivariant):旨在支持常见的事件处理场景。例如:
enum EventType {
Mouse, Keyboard }
interface Event {
timestamp: number; }
interface MouseE extends Event {
x: number; y: number }
interface KeyE extends Event {
keyCode: number }
function listenEvent(eventType: EventType, handler: (n: Event) => void) {
/* ... */
}
// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MouseE) => console.log(e.x + ',' + e.y));
// Undesirable alternatives in presence of soundness
listenEvent(EventType.Mouse, (e: Event) => console.log((<MouseE>e).x + ',' + (<MouseE>e).y));
listenEvent(EventType.Mouse, <(e: Event) => void>((e: MouseE) => console.log(e.x + ',' + e.y)));
// Still disallowed (clear error). Type safety enforced for wholly incompatible types
listenEvent(EventType.Mouse, (e: number) => console.log(e));
枚举
枚举类型与数字类型兼容,并且数字类型与枚举类型兼容。
enum Status {
Ready, Waiting };
let ready = Status.Ready;
let num = 1;
num = ready; // ok
ready = num;// ok
不同枚举类型之间是不兼容的。比如:
enum Status {
Ready, Waiting };
enum Color {
Red,
Yellow
}
let state = Status.Ready;
let color = Color.Red;
state = color; // error 不能将类型“Color.Red”分配给类型“Status”。
color = state; // error 不能将类型“Status”分配给类型“Color”
类
类与对象字面量和接口差不多,但有一点不同:类有静态部分和实例部分的类型。 比较两个类类型的对象时,只有实例的成员会被比较。 静态成员和构造函数不在比较的范围内。
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
类的私有成员和受保护成员
类的私有成员和受保护成员会影响兼容性。 当检查类实例的兼容时,如果目标类型包含一个私有成员,那么源类型必须包含来自同一个类的这个私有成员。 同样地,这条规则也适用于包含受保护成员实例的类型检查。 这允许子类赋值给父类,但是不能赋值给其它有同样类型的类。
也就是说私有的和受保护的成功的成员必须来自相同的类。
class Animal {
protected feet: number;
}
class Cat extends Animal {
}
let animal: Animal;
let cat: Cat;
animal = cat; // ok
cat = animal; // ok
class Size {
protected feet: number
}
let size: Size;
animal = size; //error 不能将类型“Size”分配给类型“Animal”。属性“feet”受保护,但类型“Size”并不是从“Animal”派生的类。
size = animal // error 不能将类型“Animal”分配给类型“Size”。属性“feet”受保护,但类型“Animal”并不是从“Size”派生的类。
泛型
因为TypeScript是结构性的类型系统,类型参数只影响使用其做为类型一部分的结果类型。也即是类型参数只有在被成员使用时,才会影响兼容性。
interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;
x = y; // OK, because y matches structure of x
上面代码里,x和y是兼容的,因为它们的结构使用类型参数时并没有什么不同。 把这个例子改变一下,增加一个成员,就能看出是如何工作的了:
interface NotEmpty<T> {
data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
x = y; // Error, because x and y are not compatible
在这里,泛型类型在使用时就好比不是一个泛型类型。
对于没指定泛型类型的泛型参数时,会把所有泛型参数当成any比较。 然后用结果类型进行比较,就像上面第一个例子。
let identity = function<T>(x: T): T {
return x;
}
let reverse = function<U>(y: U): U {
return y;
}
identity = reverse; // OK, because (x: any) => any matches (y: any) => any
高级主题
子类型与赋值
目前为止,我们使用了“兼容性”,它在语言规范里没有定义。 在TypeScript里,有两种兼容性:子类型和赋值。 它们的不同点在于,赋值扩展了子类型兼容性,增加了一些规则,允许和any来回赋值,以及enum和对应数字值之间的来回赋值。
语言里的不同地方分别使用了它们之中的机制。 实际上,类型兼容性是由赋值兼容性来控制的,即使在implements和extends语句也不例外。