Type Compatibility
在TypeScript中,类型兼容性基于结构子类型化。结构子类型化是一种仅基于成员来关联类型的方法。这与名义类型相对。考虑以下代码:
interface Pet {
name: string;
}
class Dog {
name: string;
}
let pet: Pet;
// OK, because of structural typing
pet = new Dog();
在名义类型的语言中,比如C#或Java,上述代码会产生错误,因为Dog类没有显式地声明自己是Pet接口的实现者。
TypeScript 的结构类型系统是根据 JavaScript 代码的典型编写方式设计的。由于 JavaScript 广泛使用匿名对象,如函数表达式和对象字面量,因此使用结构类型系统来表示 JavaScript 库中的关系比使用名义类型系统更加自然。结构类型允许 TypeScript 根据对象的成员来推断类型,而不是依赖于显式的类型声明。
基本兼容规则
TypeScript的结构类型系统的基本规则是:如果y至少具有与x相同的成员,那么x与y是兼容的。考虑如下示例:
interface Pet {
name: string;
}
let pet: Pet;
let dog = { name: 'Lassie', owner: "Rudd Weatherman" };
pet = dog
为了检查是否可以将dog赋值给pet,编译器会检查pet的每个属性,以找到dog中相应的兼容属性。在这种情况下,dog必须有一个名为name且类型为字符串的成员。它确实有这个成员,因此允许进行赋值。
相同的规则也适用于函数调用参数:
interface Pet {
name: string;
}
let dog = { name: 'Lassie', owner: "Rudd Weatherman" };
function printPet(pet: Pet) {
console.log("The pet's name is " + pet.name);
}
printPet(dog); // OK
以上两种场景,dog都有一个额外的属性,但是这不会产生一个错误。在检测类型兼容时,只有目标类型的的成员会被考虑。这个比较过程会递归进行,探索每个成员和子成员的类型。
请注意,对象字面量只能指定已知的属性。例如,由于明确指定了dog的类型为Pet,因此以下代码是无效的:
let dog: Pet = { name: "Lassie", owner: "Rudd Weatherwax" }; // Error
函数比较
虽然比较基本类型和对象类型相对简单,但对于哪些类型的函数应被视为兼容,这个问题则稍微复杂一些。
参数
考虑如下示例:
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // OK
// Target signature provides too few arguments. Expected 2 or more, but got 1.
x = y;
要检查x是否可以赋值给y,首先我们查看参数列表。x中的每个参数必须有一个对应的参数在y中,其类型是兼容的。请注意,不考虑参数的名称,只考虑它们的类型。在这种情况下,x的每个参数都有一个对应的兼容参数在y中,所以允许进行赋值。
而第二个赋值是错误的,因为y有一个必需的第二个参数,而x没有,所以不允许进行赋值。
你可能会想知道为什么允许像例子中的y = x这样"丢弃"参数。之所以允许进行这种赋值,是因为在JavaScript中忽略额外的函数参数实际上是相当普遍的。
返回值
类型系统强制源函数的返回类型是目标类型的返回类型的子类型。
let x = () => ({name: "Alice"});
let y = () => ({name: "Alice", location: "Seattle"});
x = y; // OK
// because x() lacks a location property
y = x;
函数参数的双面性
在比较函数参数的类型时,如果源参数可以赋值给目标参数,或者反过来,赋值就会成功。这是不安全的,因为调用者可能最终得到一个接受更特定类型的函数,但是使用一个不太特定的类型来调用该函数。在实践中,这种错误很少见,而允许这种情况可以支持许多常见的JavaScript模式。考虑如下示例:
enum EventType {
Mouse,
Keyboard
}
interface Event {
timestamp: number;
}
interface MyMouseEvent extends Event {
x: number;
y: number;
}
interface MyKeyEvent extends Event {
keyCode: number
}
function listenEvent(eventType: EventType, handler: (n: Event) => void) {
/* ... */
}
// Type 'Event' is missing the following properties from type 'MyMouseEvent': x, y
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y));
listenEvent(EventType.Mouse, (e: Event) =>
console.log((e as MyMouseEvent).x + "," + (e as MyMouseEvent).y)
);
listenEvent(EventType.Mouse, ((e: MyMouseEvent) =>
console.log(e.x + "," + e.y)) as (e: Event) => void);
// Type 'Event' is not assignable to type 'number'.
listenEvent(EventType.Mouse, (e: number) => console.log(e));
可选参数与剩余参数
在比较函数的兼容性时,可选和必需的参数是可以互换的。源类型的额外可选参数不会产生错误,而目标类型的可选参数如果在源类型中没有相应的参数也不会产生错误。当一个函数有一个剩余参数时,它被视为无限个可选参数的序列。
这在类型系统的角度来看是不安全的,但从运行时的角度来看,可选参数的概念通常并不严格执行,因为对于大多数函数来说,在该位置传递undefined是等效的。
function invokeLater(args: any[], callback: (...args: any[]) => void) {
/* ... Invoke callback with 'args' ... */
callback(...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 color = Color.Red;
let num = 1;
num = color; // ok
color = num; // ok
let status = Status.Ready;
// Type 'Color.Green' is not assignable to type 'Status'.
status = Color.Green; //error
类
当比较两个类类型的对象时,只会比较实例的成员,静态成员和构造函数不影响兼容性。
class Animal {
feet: number;
static fn() {}
constructor(name:string, numFeet: number) {
this.feet = numFeet;
}
}
class Size {
feet: number;
constructor(numFeet: number) {
this.feet = numFeet;
}
}
let a: Animal = new Animal("name", 4);
let s: Size = new Size(1);
a = s; // OK
s = a; // OK
在类中,私有(private)和受保护(protected)成员会影响它们的兼容性。当检查类的实例是否兼容时,如果目标类型包含私有成员,则源类型也必须包含来自相同类的私有成员。同样地,对于具有受保护成员的实例也是如此。这使得一个类可以与其超类具有赋值兼容性,但与来自不同继承层次结构的类(虽然形状相同)没有赋值兼容性。
class X {
private x: number = 1;
name: string;
constructor(name: string) {
this.name = name;
}
}
class Y {
name: string;
constructor(name: string) {
this.name = name;
}
}
let xx: X = new X("X");
let yy: Y = new Y("Y");
// Property 'x' is missing in type 'Y' but required in type 'X'.
xx = yy
yy = xx / OK
泛型
因为TypeScript是结构类型系统,类型参数只在作为成员类型的一部分进行消费时才会影响结果类型。
interface Empty<T> {}
let x: Empty<number>;
let y: Empty<string>;
x = y
如上,由于 Empty 接口没有任何属性或方法,因此它们的实例都是空对象。因此,它们的类型是结构相同的,类型兼容。
但是如果在接口中添加一个成员,则会产生不同的结果:
interface NotEmpty<T> {
data: T;
}
let xx: NotEmpty<number> = {
data: 1
};
let yy: NotEmpty<string> = {
data: "1"
};
// Type 'string' is not assignable to type 'number'.
xx = yy;
以这种方式,对于已指定其类型参数的泛型类型,其行为与非泛型类型相同。
对于没有指定类型参数的泛型类型,兼容性是通过在所有未指定类型参数的位置上使用any进行检查的。然后,将得到的类型进行兼容性检查,就像在非泛型情况下一样。
let identity = function <T>(x: T): T {
return x;
};
let reverse = function <U>(y: U): U {
return y;
};
identity = reverse;
子类型 vs 赋值
到目前为止,我们使用了“compatible”这个术语,但这并没有在语言规范中定义。在TypeScript中,存在两种类型的兼容性:子类型(subtype)和赋值(assignment)兼容性。这两者之间的区别仅在于赋值兼容性扩展了子类型兼容性,并允许使用一些规则进行任意类型(any)的赋值,以及与具有相应数值的枚举类型之间的赋值。
在语言中的不同位置,根据情况使用这两种兼容性机制之一。在实际情况中,类型兼容性受到赋值兼容性的影响,即使在implements和extends子句的情况下也是如此。
以下表格总结了一些抽象类型之间的可赋值性。行表示每个类型可赋值给哪些类型,列表示可以赋值给它们的类型。 "✓" 表示组合在 strictNullChecks 关闭时仅兼容的情况。
| any | unknown | object | void | undefined | null | never | |
|---|---|---|---|---|---|---|---|
| any → | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | |
| unknown → | ✓ | ✕ | ✕ | ✕ | ✕ | ✕ | |
| object → | ✓ | ✓ | ✕ | ✕ | ✕ | ✕ | |
| void → | ✓ | ✓ | ✕ | ✕ | ✕ | ✕ | |
| undefined → | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ |
| null → | ✓ | ✓ | ✓ | ✓ | ✕ | ||
| never → | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
- 每个类型都可以赋值给它自己。
any****和unknown在可赋值性方面是相同的,不同之处在于unknown除了可以赋值给any之外不能赋值给其他任何类型。unknown和never是彼此的相反。任何类型都可以赋值给unknown,而never可以赋值给任何类型。但是,没有任何类型可以赋值给never,而unknown除了可以赋值给any之外不能赋值给其他任何类型。- 当
strictNullChecks关闭时,null和undefined与never类似:可以赋值给大多数类型,但大多数类型不能赋值给它们。它们之间可以互相赋值。 - 当
strictNullChecks开启时,null和undefined表现更像void:不能赋值给或从任何类型赋值得到,除了any、unknown、never和 (undefined总是可以赋值给void)。