类型兼容性

TypeScript 中的类型兼容性基于结构子类型。结构类型是一种根据类型成员来关联类型的方式。这与名义类型形成对比。考虑以下代码

ts
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 允许不健壮行为的地方经过仔细考虑,并且在本文件中,我们将解释这些行为发生的位置以及它们背后的动机场景。

入门

TypeScript 结构类型系统的基本规则是:如果 y 至少拥有与 x 相同的成员,则 xy 兼容。例如,考虑以下代码,其中包含一个名为 Pet 的接口,该接口具有一个 name 属性

ts
interface Pet {
name: string;
}
let pet: Pet;
// dog's inferred type is { name: string; owner: string; }
let dog = { name: "Lassie", owner: "Rudd Weatherwax" };
pet = dog;

要检查 dog 是否可以赋值给 pet,编译器会检查 pet 的每个属性,以在 dog 中找到相应的兼容属性。在本例中,dog 必须有一个名为 name 的成员,该成员是一个字符串。它确实有,因此允许赋值。

检查函数调用参数时,使用相同的赋值规则

ts
interface Pet {
name: string;
}
let dog = { name: "Lassie", owner: "Rudd Weatherwax" };
function greet(pet: Pet) {
console.log("Hello, " + pet.name);
}
greet(dog); // OK

请注意,dog 还有一个额外的 owner 属性,但这不会产生错误。在检查兼容性时,只考虑目标类型(在本例中为 Pet)的成员。此比较过程会递归进行,探索每个成员和子成员的类型。

但是,请注意,对象字面量 只能指定已知属性。例如,因为我们已经明确指定 dog 的类型为 Pet,所以以下代码无效

ts
let dog: Pet = { name: "Lassie", owner: "Rudd Weatherwax" }; // Error

比较两个函数

比较原始类型和对象类型相对简单,但关于哪些类型的函数应该被认为是兼容的这个问题就比较复杂了。让我们从一个基本的例子开始,这两个函数只在参数列表上有所不同

ts
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 为回调函数提供了三个参数:数组元素、其索引和包含的数组。但是,提供只使用第一个参数的回调非常有用

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

现在让我们看看如何处理返回值类型,使用两个仅返回值类型不同的函数。

ts
let x = () => ({ name: "Alice" });
let y = () => ({ name: "Alice", location: "Seattle" });
x = y; // OK
y = x; // Error, because x() lacks a location property

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

函数参数双变性

在比较函数参数类型时,如果源参数可分配给目标参数,或反之,则分配成功。这并不安全,因为调用者最终可能会得到一个接受更专门类型的函数,但使用一个不太专门的类型调用该函数。在实践中,这种错误很少见,允许这样做可以实现许多常见的 JavaScript 模式。一个简短的例子

ts
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) {
/* ... */
}
// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y));
// Undesirable alternatives in presence of soundness
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);
// Still disallowed (clear error). Type safety enforced for wholly incompatible types
listenEvent(EventType.Mouse, (e: number) => console.log(e));

您可以通过编译器标志 strictFunctionTypes 让 TypeScript 在发生这种情况时引发错误。

可选参数和剩余参数

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

当一个函数有剩余参数时,它被视为一个无限的可选参数序列。

从类型系统的角度来看,这并不安全,但从运行时的角度来看,可选参数的概念通常没有得到很好的执行,因为在该位置传递 undefined 对于大多数函数来说是等效的。

最典型的例子是,一个函数接受一个回调函数,并用一些可预测的(对程序员来说)但未知的(对类型系统来说)数量的参数调用它。

ts
function invokeLater(args: any[], callback: (...args: any[]) => void) {
/* ... Invoke callback with '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));

带重载的函数

当一个函数有重载时,目标类型中的每个重载都必须在源类型上匹配一个兼容的签名。这确保了源函数可以在与目标函数相同的所有情况下被调用。

枚举

枚举与数字兼容,数字也与枚举兼容。来自不同枚举类型的枚举值被认为是不兼容的。例如,

ts
enum Status {
Ready,
Waiting,
}
enum Color {
Red,
Blue,
Green,
}
let status = Status.Ready;
status = Color.Green; // Error

类与对象字面量类型和接口的工作方式类似,只有一个例外:它们同时具有静态类型和实例类型。当比较两个类类型的对象时,只比较实例的成员。静态成员和构造函数不影响兼容性。

ts
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 是一个结构化类型系统,所以类型参数只有在作为成员类型的一部分被使用时才会影响结果类型。例如,

ts
interface Empty<T> {}
let x: Empty<number>;
let y: Empty<string>;
x = y; // OK, because y matches structure of x

在上面,xy 是兼容的,因为它们的结构没有以区分的方式使用类型参数。通过向 Empty<T> 添加一个成员来更改此示例,可以显示其工作原理

ts
interface NotEmpty<T> {
data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
x = y; // Error, because x and y are not compatible

通过这种方式,具有指定类型参数的泛型类型就像非泛型类型一样。

对于没有指定类型参数的泛型类型,通过在所有未指定类型参数的位置指定any来检查兼容性。然后,检查生成的类型是否兼容,就像非泛型情况一样。

例如,

ts
let identity = function <T>(x: T): T {
// ...
};
let reverse = function <U>(y: U): U {
// ...
};
identity = reverse; // OK, because (x: any) => any matches (y: any) => any

高级主题

子类型与赋值

到目前为止,我们一直使用“兼容”这个词,它不是语言规范中定义的术语。在 TypeScript 中,有两种兼容性:子类型和赋值。它们的区别仅在于赋值扩展了子类型兼容性,并添加了允许对any进行赋值的规则,以及对具有相应数值的enum进行赋值的规则。

语言中的不同位置使用两种兼容性机制之一,具体取决于情况。在实践中,类型兼容性由赋值兼容性决定,即使在implementsextends子句的情况下也是如此。

anyunknownobjectvoidundefinednullnever的可赋值性

下表总结了一些抽象类型之间的可赋值性。行表示每个类型可以赋值给哪些类型,列表示哪些类型可以赋值给它们。一个“”表示只有在strictNullChecks关闭时才兼容的组合。

any unknown object void undefined null never
any →
unknown →
object →
void →
undefined →
null →
never →

重申基础知识

  • 所有类型都可以赋值给自己。
  • anyunknown在可赋值性方面相同,不同之处在于unknown不能赋值给任何类型,除了any
  • unknownnever就像彼此的倒数。所有类型都可以赋值给unknownnever可以赋值给所有类型。没有任何类型可以赋值给neverunknown不能赋值给任何类型(除了any)。
  • void不能赋值给任何类型,也不能从任何类型赋值,以下情况除外:anyunknownneverundefinednull(如果strictNullChecks关闭,请参见表中的详细信息)。
  • strictNullChecks关闭时,nullundefined类似于never:可以赋值给大多数类型,大多数类型不能赋值给它们。它们可以相互赋值。
  • strictNullChecks打开时,nullundefined的行为更像void:不能赋值给任何类型,也不能从任何类型赋值,除了anyunknownnevervoidundefined始终可以赋值给void)。

TypeScript 文档是一个开源项目。帮助我们改进这些页面通过发送拉取请求

此页面的贡献者
RCRyan Cavanaugh (51)
DRDaniel Rosenwasser (19)
OTOrta Therox (18)
MHMohamed Hegazy (4)
JBJack Bates (3)
25+

上次更新时间:2024 年 3 月 21 日