# 类型兼容性

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 的类型系统允许某些在编译时不知道的操作是安全的。当一个类型系统有这个属性时,就说它不是"sound"。仔细考虑了 TypeScript 允许不合理行为的地方,并且在整个文档中,我们将解释这些情况发生的位置以及它们背后的激励场景。

# 开始

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

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的成员,它是一个字符串。确实如此,因此允许分配。

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

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)。

这个比较过程递归地进行,探索每个成员和子成员的类型。

# 比较两个函数

虽然比较原始类型和对象类型相对简单,但应该将哪些类型的函数视为兼容的问题涉及更多。让我们从一个仅在参数列表上有所不同的两个函数的基本示例开始:

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中的 'discarding' 参数。允许这种赋值的原因是忽略额外的函数参数实际上在 JavaScript 中很常见。例如,Array#forEach为回调函数提供了三个参数:数组元素、其索引和包含数组。尽管如此,提供只使用第一个参数的回调非常有用:

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

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

let x = () => ({ name: "Alice" });
let y = () => ({ name: "Alice", location: "Seattle" });

x = y; // OK
y = x; // Error, because x() lacks a location property

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

# 函数参数双方差

比较函数参数的类型时,如果源参数可分配给目标参数,则分配成功,反之亦然。这是不合理的,因为调用者最终可能会得到一个采用更专业类型的函数,但调用具有较少专业类型的函数。在实践中,这种错误很少见,并且允许这样做会启用许多常见的 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) {
  /* ... */
}

// 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对于大多数函数来说是等效的。

激励示例是一个函数的常见模式,它接受一个回调并使用一些可预测的(对于程序员)但未知数量的参数(对于类型系统)来调用它:

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

# 具有重载的函数

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

# 枚举

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

enum Status {
  Ready,
  Waiting,
}
enum Color {
  Red,
  Blue,
  Green,
}

let status = Status.Ready;
status = Color.Green; // Error

#

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

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

x = y; // OK, because y matches structure of x

在上面,xy是兼容的,因为它们的结构不以区分方式使用类型参数。通过向 Empty<T&gt;添加成员来更改此示例显示了它是如何工作的:

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 {
  // ...
};

let reverse = function <U>(y: U): U {
  // ...
};

identity = reverse; // OK, because (x: any) => any matches (y: any) => any

# 高级主题

# 子类型与赋值

到目前为止,我们使用的是 "compatible",这不是语言规范中定义的术语。在 TypeScript 中,有两种兼容性:子类型和赋值。这些不同之处仅在于分配扩展了与规则的子类型兼容性,以允许分配到 any和从 any分配,以及从 enum分配相应的数值。

语言中不同的地方使用两种兼容机制中的一种,视情况而定。出于实际目的,类型兼容性由赋值兼容性决定,即使在 implementsextends子句的情况下也是如此。

# Any、unknown、object、void、undefined、null 和 never 可分配性

下表总结了一些抽象类型之间的可分配性。行表示每个可分配的内容,列表示可分配给他们的内容。"✓" 表示仅在 strictNullChecks 关闭时兼容的组合。

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)之外,不能分配给任何东西或从任何东西分配。
Last Updated: 5/25/2023, 2:35:11 PM