Skip to content
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待

类型兼容性

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

ts
interface Pet {
  name: string;
}

class Dog {
  name: string;
}

let pet: Pet;
// 可以,因为结构类型
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 的推断类型是 { 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); // 可以

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

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

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

比较两个函数

虽然比较原始类型和对象类型相对简单,但什么样的函数应该被认为是兼容的这个问题稍微复杂一些。 让我们从一个仅参数列表不同的两个函数的基本示例开始:

ts
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

y = x; // 可以
x = y; // 错误

要检查 x 是否可以赋值给 y,我们首先查看参数列表。 x 中的每个参数都必须在 y 中有一个具有兼容类型的对应参数。 注意不考虑参数的名称,只考虑它们的类型。 在这种情况下,x 的每个参数在 y 中都有一个对应的兼容参数,因此允许赋值。

第二个赋值是错误,因为 y 有一个 x 没有的必需的第二参数,因此不允许赋值。

你可能想知道为什么我们允许像 y = x 示例中那样“丢弃”参数。 允许此赋值的原因是,忽略额外的函数参数在 JavaScript 中实际上很常见。 例如,Array#forEach 向回调函数提供三个参数:数组元素、其索引和包含的数组。 然而,提供一个仅使用第一个参数的回调非常有用:

ts
let items = [1, 2, 3];

// 不要强制使用这些额外参数
items.forEach((item, index, array) => console.log(item));

// 应该没问题!
items.forEach((item) => console.log(item));

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

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

x = y; // 可以
y = x; // 错误,因为 x() 缺少 location 属性

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

函数参数的双向协变

比较函数参数的类型时,如果源参数可赋值给目标参数,或者反之,赋值成功。 这是不可靠的,因为调用者最终可能会得到一个接受更专用类型的函数,却使用更通用的类型调用该函数。 在实践中,这种错误很少见,并且允许这种错误可以实现许多常见的 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) {
  /* ... */
}

// 不可靠,但有用且常见
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));

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

可选参数和剩余参数

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

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

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

动机示例是一个常见的模式,即一个函数接受一个回调,并用一些对程序员可预测但对类型系统未知数量的参数调用它:

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

具有重载的函数

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

枚举

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

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

let status = Status.Ready;
status = Color.Green; // 错误

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

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; // 可以
s = a; // 可以

类中的私有成员和受保护成员

类中的私有成员和受保护成员会影响它们的兼容性。 当检查一个类的实例是否兼容时,如果目标类型包含一个私有成员,那么源类型也必须包含一个源自同一类的私有成员。 同样,对于具有受保护成员的实例也是如此。 这允许一个类与其超类赋值兼容,但不能与来自不同继承层次结构但形状相同的类赋值兼容。

泛型

由于 TypeScript 是一个结构类型系统,类型参数仅在作为成员类型的一部分被消费时才影响结果类型。例如,

ts
interface Empty<T> {}
let x: Empty<number>;
let y: Empty<string>;

x = y; // 可以,因为 y 匹配 x 的结构

在上面的例子中,xy 是兼容的,因为它们的结构没有以区分的方式使用类型参数。 通过向 Empty<T> 添加一个成员来改变这个示例,可以看到这是如何工作的:

ts
interface NotEmpty<T> {
  data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;

x = y; // 错误,因为 x 和 y 不兼容

这样,指定了类型参数的泛型类型的行为就像非泛型类型一样。

对于未指定类型参数的泛型类型,通过将所有未指定的类型参数替换为 any 来检查兼容性。 然后检查结果类型是否兼容,就像在非泛型情况下一样。

例如,

ts
let identity = function <T>(x: T): T {
  // ...
};

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

identity = reverse; // 可以,因为 (x: any) => any 匹配 (y: any) => any

高级主题

子类型 vs 赋值

到目前为止,我们使用了“兼容”一词,这不是语言规范中定义的术语。 在 TypeScript 中,有两种兼容性:子类型和赋值。 它们的不同之处仅在于赋值通过允许与 any 之间的赋值以及与具有相应数值的 enum 之间的赋值来扩展子类型兼容性。

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

anyunknownobjectvoidundefinednullnever 的可赋值性

下表总结了某些抽象类型之间的可赋值性。 行表示每个类型可以赋值给什么,列表示什么可以赋值给它们。 "" 表示仅在 strictNullChecks 关闭时才兼容的组合。

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

重申基础类型

  • 一切都可以赋值给自己。
  • anyunknown 在可赋值给它们的内容方面相同,不同之处在于 unknown 除了 any 之外不能赋值给任何东西。
  • unknownnever 就像彼此的反面。 一切都可以赋值给 unknownnever 可以赋值给一切。 没有东西可以赋值给 neverunknown 不能赋值给任何东西(除了 any)。
  • void 不能赋值给任何东西,也不能从任何东西赋值给它,除了以下例外:anyunknownneverundefinednull(如果 strictNullChecks 关闭,详见表格)。
  • strictNullChecks 关闭时,nullundefined 类似于 never:可赋值给大多数类型,大多数类型不能赋值给它们。 它们可以互相赋值。
  • strictNullChecks 开启时,nullundefined 的行为更像 void:不能赋值给任何东西,也不能从任何东西赋值给它,除了 anyunknownvoidundefined 总是可以赋值给 void)。