TypeScript Office - 类型兼容性
# 类型兼容性
TypeScript 中的类型兼容性是基于结构子类型的。结构分型是一种完全基于其成员的类型关系的方式。这与名义类型不同。考虑一下下面的代码:
interface Pet {
name: string;
}
class Dog {
name: string;
}
let pet: Pet;
// 正确,因为结构化类型
pet = new Dog();
2
3
4
5
6
7
8
9
在像 C# 或 Java 这样的名义类型语言中,相应的代码将是一个错误,因为 Dog 类没有明确地描述自己是 Pet 接口的实现者。
TypeScript 的结构类型系统是根据 JavaScript 代码的典型写法设计的。因为 JavaScript 广泛使用匿名对象,如函数表达式和对象字面量,用结构类型系统而不是命名类型系统来表示 JavaScript 库中的各种关系要自然得多。
# 关于健全性的说明
TypeScript 的类型系统允许某些在编译时无法知道的操作是安全的。当一个类型系统具有这种属性时,它被称为不「健全」。我们仔细考虑了 TypeScript 允许不健全行为的地方,在这篇文档中,我们将解释这些发生的地方以及它们背后的动机情景。
# 起步
TypeScript 的结构类型系统的基本规则是,如果 y 至少有与 x 相同的成员,那么 x 与 y 是兼容的。
interface Pet {
name: string;
}
let pet: Pet;
// dog's 推断类型是 { name: string; owner: string; }
let dog = { name: "Lassie", owner: "Rudd Weatherwax" };
pet = dog;
2
3
4
5
6
7
为了检查 dog 是否可以被分配给 pet,编译器检查 pet 的每个属性,以找到 dog 中相应的兼容属性。在这种情况下,dog 必须有一个名为 name 的成员,它是一个字符串。它有,所以赋值是允许的。
在检查函数调用参数时,也使用了同样的赋值规则。
interface Pet {
name: string;
}
let dog = { name: "Lassie", owner: "Rudd Weatherwax" };
function greet(pet: Pet) {
console.log("Hello, " + pet.name);
}
greet(dog); // 正确
2
3
4
5
6
7
8
请注意,dog有一个额外的 owner 属性,但这并不产生错误。在检查兼容性时,只考虑目标类型(本例中为 Pet)的成员。
这个比较过程是递归进行的,探索每个成员和子成员的类型。
# 对比两个函数
虽然比较原始类型和对象类型是相对直接的,但什么样的函数应该被认为是兼容的,这个问题就有点复杂了。让我们从两个函数的基本例子开始,这两个函数只在参数列表上有所不同:
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // 正确
x = y; // 错误
2
3
4
为了检查 x 是否可以分配给 y,我们首先看一下参数列表。x 中的每个参数在 y 中都必须有一个类型兼容的对应参数。注意,参数的名称不被考虑,只考虑它们的类型。在这种情况下,x 中的每个参数在 y 中都有一个对应的兼容参数,所以这个赋值是允许的。
第二个赋值是一个错误,因为 y 有一个 x 没有的必要的第二个参数,所以这个赋值是不允许的。
你可能想知道为什么我们允许像例子中的 y = x
那样「丢弃」参数。这个赋值被允许的原因是,忽略额外的函数参数在 JavaScript 中其实很常见。例如, Array#forEach 为回调函数提供了三个参数:数组元素、其索引和包含数组。尽管如此,提供一个只使用第一个参数的回调是非常有用的:
let items = [1, 2, 3];
// 不要强迫这些额外参数
items.forEach((item, index, array) => console.log(item));
// 应该没有问题!
items.forEach((item) => console.log(item));
2
3
4
5
现在让我们看看如何处理返回类型,使用两个只因返回类型不同的函数:
let x = () => ({ name: "Alice" });
let y = () => ({ name: "Alice", location: "Seattle" });
x = y; // 正确
y = x; // 错误,因为x()缺少一个location属性
2
3
4
类型系统强制要求源函数的返回类型是目标类型的返回类型的一个子类型。
# 函数参数的双差性
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) {
/* ... */
}
// 不健全,但有用且常见
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);
// 仍然不允许(明确的错误)。对于完全不兼容的类型强制执行类型安全
listenEvent(EventType.Mouse, (e: number) => console.log(e));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
当这种情况发生时,你可以让 TypeScript 通过编译器标志 strictFunctionTypes 引发错误。
# 可选参数和其余参数
在比较函数的兼容性时,可选参数和必需参数是可以互换的。源类型的额外可选参数不是错误,而目标类型的可选参数在源类型中没有对应的参数也不是错误。
当一个函数有一个剩余参数时,它被当作是一个无限的可选参数系列。
从类型系统的角度来看,这是不健全的,但从运行时的角度来看,可选参数的概念一般不会得到很好的加强,因为在这个位置传递 undefined 的参数对大多数函数来说是等价的。
激励性的例子是一个函数的常见模式,它接受一个回调,并用一些可预测的(对程序员)但未知的(对类型系统)参数数量来调用它。
function invokeLater(args: any[], callback: (...args: any[]) => void) {
/* ... 用'args'调用回调 ... */
}
// 不健全 - invokeLater "可能 "提供任何数量的参数
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));
// 令人困惑的是(x和y实际上是需要的),而且是无法发现的
invokeLater([1, 2], (x?, y?) => console.log(x + ", " + y));
2
3
4
5
6
7
# 带有重载的函数
当一个函数有重载时,源类型中的每个重载必须由目标类型上的兼容签名来匹配。这保证了目标函数可以在所有与源函数相同的情况下被调用。
# 枚举
枚举与数字兼容,而数字与枚举兼容。来自不同枚举类型的枚举值被认为是不兼容的。比如说:
enum Status {
Ready,
Waiting,
}
enum Color {
Red,
Blue,
Green,
}
let status = Status.Ready;
status = Color.Green; // 错误
2
3
4
5
6
7
8
9
10
11
# 类
类的工作方式与对象字面类型和接口类似,但有一个例外:它们同时具有静态和实例类型。当比较一个类类型的两个对象时,只有实例的成员被比较。静态成员和构造函数不影响兼容性。
class Animal {
feet: number;
constructor(name: string, numFeet: number) {}
}
class Size {
feet: number;
constructor(numFeet: number) {}
}
let a: Animal;
let s: Size;
a = s; // 正确
s = a; // 正确
2
3
4
5
6
7
8
9
10
11
12
# 类中的私有和受保护成员
一个类中的私有成员和保护成员会影响其兼容性。当一个类的实例被检查兼容性时,如果目标类型包含一个私有成员,那么源类型也必须包含一个源自同一类的私有成员。同样地,这也适用于有保护成员的实例。这允许一个类与它的超类进行赋值兼容,但不允许与来自不同继承层次的类进行赋值兼容,否则就会有相同的形状。
# 泛型
因为 TypeScript 是一个结构化的类型系统,类型参数只在作为成员类型的一部分被消耗时影响到结果类型。比如说:
interface Empty<T> {}
let x: Empty<number>;
let y: Empty<string>;
x = y; // 正确,因为y符合x的结构
2
3
4
在上面, x 和 y 是兼容的,因为它们的结构没有以区分的方式使用类型参数。通过给 Empty 增加一个成员来改变这个例子,显示了这是如何工作的。
interface NotEmpty<T> {
data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
x = y; // 错误,因为x和y不兼容
2
3
4
5
6
这样一来,一个指定了类型参数的泛型类型就像一个非泛型类型一样。
对于没有指定类型参数的泛型,兼容性的检查是通过指定任何来代替所有未指定的类型参数。然后产生的类型被检查是否兼容,就像在非泛型的情况下一样。
比如说:
let identity = function <T>(x: T): T {
// ...
};
let reverse = function <U>(y: U): U {
// ...
};
identity = reverse; // 正确, 因为 (x: any) => any 匹配 (y: any) => any
2
3
4
5
6
7
# 子类型与赋值
到目前为止,我们已经使用了「兼容」,这并不是语言规范中定义的一个术语。在 TypeScript 中,有两种兼容性:子类型和赋值。这些不同之处只在于,赋值扩展了子类型的兼容性,允许赋值到 any,以及赋值到具有相应数值的 enum。
语言中不同的地方使用这两种兼容性机制中的一种,取决于情况。在实际应用中,类型兼容性是由赋值兼容性决定的,即使是在 implements 和 extends 子句中。
# 可分配性
any,unknown,object,void,undefined,null 和 never 可分配性。
下表总结了一些抽象类型之间的可分配性。行表示每个类型可被分配到什么,列表示什么可被分配到它们。✓
表示只有在关闭 strictNullChecks 时才是兼容的组合。
any | unknown | object | void | undefined | null | nerver | |
---|---|---|---|---|---|---|---|
any | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | |
unknown | ✓ | ✕ | ✕ | ✕ | ✕ | ✕ | |
object | ✓ | ✓ | ✕ | ✕ | ✕ | ✕ | |
void | ✓ | ✓ | ✕ | ✕ | ✕ | ✕ | |
undefined | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | |
null | ✓ | ✓ | ✓ | ✓ | ✓ | ✕ | |
nerver | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
- 所有的东西都是可以分配给自己的
- any 和 unknown 在可分配的内容方面是相同的,不同的是 unknown 不能分配给任何东西,除了 any
- unknown 和 never 就像是彼此的反义词。一切都可以分配给 unknown,never 就可以分配给一切。没有任何东西可以分配给 never,unknown 不能分配给任何东西(除了 any)
- void 不能赋值给任何东西,以下是例外情况:any、unknown、never、undefined 和 null(如果 strictNullChecks 是关闭的,详见表)
- 当 strictNullChecks 关闭时,null 和 undefined 与 never 类似:可赋值给大多数类型,大多数类型不可赋值给它们。它们可以互相赋值
- 当 strictNullChecks 打开时,null 和 undefined 的行为更像 void:除了 any、unknown、never 和 void 之外,不能赋值给任何东西( undefined 总是可以赋值给 void)