TypeScript 中的类型兼容性基于结构子类型。结构类型是一种根据类型成员来关联类型的方式。这与名义类型形成对比。考虑以下代码
ts
interface Pet {name: string;}class Dog {name: string;}let pet: Pet;// OK, because of structural typingpet = new Dog();
在像 C# 或 Java 这样的名义类型语言中,等效代码将是一个错误,因为 Dog
类没有明确地描述自己为 Pet
接口的实现者。
TypeScript 的结构类型系统是基于 JavaScript 代码的典型编写方式而设计的。由于 JavaScript 广泛使用匿名对象,例如函数表达式和对象字面量,因此用结构类型系统而不是名义类型系统来表示 JavaScript 库中发现的各种关系更加自然。
关于健壮性的说明
TypeScript 的类型系统允许某些在编译时无法确定的操作是安全的。当类型系统具有此属性时,它被称为“不健壮”。TypeScript 允许不健壮行为的地方经过仔细考虑,并且在本文件中,我们将解释这些行为发生的位置以及它们背后的动机场景。
入门
TypeScript 结构类型系统的基本规则是:如果 y
至少拥有与 x
相同的成员,则 x
与 y
兼容。例如,考虑以下代码,其中包含一个名为 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; // OKx = 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 parametersitems.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; // OKy = 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 commonlistenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y));// Undesirable alternatives in presence of soundnesslistenEvent(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 typeslistenEvent(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 argumentsinvokeLater([1, 2], (x, y) => console.log(x + ", " + y));// Confusing (x and y are actually required) and undiscoverableinvokeLater([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; // OKs = a; // OK
类中的私有和受保护成员
类中的私有和受保护成员会影响它们的兼容性。当检查类实例的兼容性时,如果目标类型包含私有成员,则源类型也必须包含来自同一类的私有成员。同样,对于具有受保护成员的实例也是如此。这允许类与它的超类进行赋值兼容,但 *不* 与来自不同继承层次结构的类进行赋值兼容,即使它们具有相同的形状。
泛型
因为 TypeScript 是一个结构化类型系统,所以类型参数只有在作为成员类型的一部分被使用时才会影响结果类型。例如,
ts
interface Empty<T> {}let x: Empty<number>;let y: Empty<string>;x = y; // OK, because y matches structure of x
在上面,x
和 y
是兼容的,因为它们的结构没有以区分的方式使用类型参数。通过向 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
进行赋值的规则。
语言中的不同位置使用两种兼容性机制之一,具体取决于情况。在实践中,类型兼容性由赋值兼容性决定,即使在implements
和extends
子句的情况下也是如此。
any
、unknown
、object
、void
、undefined
、null
和never
的可赋值性
下表总结了一些抽象类型之间的可赋值性。行表示每个类型可以赋值给哪些类型,列表示哪些类型可以赋值给它们。一个“✓”表示只有在strictNullChecks
关闭时才兼容的组合。
any | unknown | object | void | undefined | null | never | |
---|---|---|---|---|---|---|---|
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
)。