# 类型兼容性
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
相同的成员,则 x
与 y
兼容。例如,考虑以下代码,其中涉及一个名为 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
在上面,x
和 y
是兼容的,因为它们的结构不以区分方式使用类型参数。通过向 Empty<T>
添加成员来更改此示例显示了它是如何工作的:
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
分配相应的数值。
语言中不同的地方使用两种兼容机制中的一种,视情况而定。出于实际目的,类型兼容性由赋值兼容性决定,即使在 implements
和 extends
子句的情况下也是如此。
# Any、unknown、object、void、undefined、null 和 never 可分配性
下表总结了一些抽象类型之间的可分配性。行表示每个可分配的内容,列表示可分配给他们的内容。"✓" 表示仅在 strictNullChecks
关闭时兼容的组合。
any | unknown | object | void | undefined | null | never |
---|---|---|---|---|---|---|
任何 → | ✓ | ✓ | ✓ | ✓ | ✓ | |
未知 → | ✓ | ✕ | ✕ | ✕ | ✕ | |
对象 → | ✓ | ✓ | ✕ | ✕ | ✕ | |
无效→ | ✓ | ✓ | ✕ | ✕ | ✕ | |
未定义 → | ✓ | ✓ | ✓ | ✓ | ✓ | |
空→ | ✓ | ✓ | ✓ | ✓ | ✓ | |
从不 → | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
重申基础知识:
- 一切都可以分配给它自己。
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
)之外,不能分配给任何东西或从任何东西分配。