本文翻译自:TypeScript-New-Handbook # Assignability
基础
可赋值性是决定一个变量是否可以赋值给另一个变量的函数。编译器不仅会检查赋值和函数调用,还会检查返回语句。例如:
ts
var x: number;
var y: number;
x = y;
function f(s: string): number {
return s.length;
}
f(false);
这段代码有三个可赋值性检查:
x = y
,检查y
是否可以赋值给x
。return s.length
,检查s.length
是否可以从f
返回。f(false)
,检查false
是否可以赋值给f
的第一个参数。
为了实现这一点,编译器会找出 x
、y
和 s.length
的类型,以及 f
的参数和返回类型。
这样我们就能做出以下判断:
y: number
可赋值给x: number
。s.length: number
可赋值给f
的返回:number
。false: boolean
不可赋值给s: string
。
对于仅由原始类型组成的简单类型系统来说,可赋值性就是相等性:
ts
function isAssignableTo(source: Type, target: Type): boolean {
return source === target;
}
为简洁起见,本文使用运算符 ⟹ 来表示 isAssignableTo
,如下所示:
number ⟹ number
事实上,编译器代码中的一个常见 bug 是应该用 ⟹
做正确校验的地方使用了 ===
。之所以说这是一个 bug,是因为 Typescript 除了支持原始类型外,还支持更多类型。很好的例子包括类和接口、联合和交叉、字面类型和枚举。这 3 对类型也就是本文其余部分的三个类别的示例:
- 结构类型
- 使用代数运算符创建的类型
- 特例类型(字面和枚举)
注意,泛型是一个复杂的主题,既涉及结构类型,也涉及代数类型。
结构可赋值性
结构可赋值性是 Typescript 的一大特色,大多数语言都不具备这一功能。但它的代价很高:它是最后一种可赋值性检查,因为它非常缓慢和麻烦。当所有其他类型的可分配性都无法返回 true 时,结构可赋值性就会作为后备功能。
结构可赋性适用于任何具有属性或签名的东西:基本上包括类、接口和对象字面类型。如果没有其他比较方法,交叉类型也会尝试结构可赋值性。
比较本身并不复杂。它首先会检查目标中的每个属性是否都存在于源类型中。下面是一个失败的例子:
ts
var source: { a, b };
var target: { a, b, c};
target = source;
{ a, b } ⟹ { a, b, c }
不为真,因为源没有名为 c
的属性。但是源类型可以有多余的属性,所以 { a, b, c } ⟹ { a, b }
为 true。
如果每个源属性都有一个匹配的目标属性,那么匹配属性会递归检查其类型是否可从源属性分配到目标属性:
ts
var source: { a: number, b: string };
var target: { a: number };
target = source;
在这里,当检查 a
时,可赋值性会进行递归调用,以检查 number
是否可赋值给 number
:
{ a: number, b: string } ⟹ { a: number }
number ⟹ number
当然,第二个检查一旦进入 ===
快速通道,就会返回 true
。但其他类型在成功或失败前可能会重复多次:
ts
var source: { a: { b: { c: null } } };
var target: { a: { b: { c: string } } };
target = source;
调用、构造和索引签名的工作原理都类似:
ts
var source: (a: number, b: unknown) => boolean;
var target: (a: never, b: string) => void;
target = source;
只不过签名检查的是正确的参数数量,而不是缺失的属性,并且遍历的是参数而不是属性。此外,参数以逆变的方式进行比较,也就是源和目标的方向是相反的。这是因为当一个回调被赋值给另一个回调变量时,参数 实际上并没有被赋值。相反,是一个可调用变量被赋值给另一个可调用变量。下面是一个例子,对于 (a: unknown) => void ⟹ (a: string) => void
实际为 string ⟹ unknown
。例如:
ts
var source: string;
var target: unknown;
target = source; // fine, target can be anything, including a string
var source: (a: string) => void;
var target: (a: unknown) => void;
target = source; // should be an error, because:
target(1); // oops, can't pass numbers to source
继承
有了结构可赋值性,就不需要特殊规则来处理继承问题。可赋值性只需检查派生类是否可赋值给基类即可。接口和实现的检查也一样。换句话说,对于这样的类:
ts
class Base {
constructor(public b: number) {
}
}
class Derived extends Base {
b = 12
constructor(public d: string) {
super(this.b);
}
}
声明使用这些类型的变量:
ts
var derived: Derived;
var base: Base;
base = derived;
然后像这样比较:
ts
var derived: { b: number, d: string };
var base: { b: number };
base = derived;
Derived
和 Base
之间没有继承联系。当然,可赋值性检查会缓存其结果,因此一旦编译器知道 Derived
可赋值给 Base
,就不必再运行昂贵的检查了。事实上,只要编译器看到 class Derived extends Base
,这种检查就会立即运行,因此结构化方法的成本最终与基于声明的方法差不多。
代数可赋值性
具有代数类型运算符的类型可以代数地进行检查。有时这种检查只是比结构检查快,但如果类型包含类型变量,代数检查可以在结构检查失败的地方成功。例如:
ts
function f<T>(source: T | never, target: T) {
target = source;
}
检查器知道 never
不会改变它所在的联合,所以它可以消除它并递归地使用一个更小的类型:
T | never ⟹ T
T ⟹ T
由于 T === T
,所以 T | never ⟹ T
为 true。
有些代数关系非常明显。例如,当一个联合为目标时,当源可赋值给联合的任何元素时,它就是可赋值的:
B ⟹ A | B | C
B ⟹ B
但是,当一个联合是源时,每个元素都需要可赋值给目标。所以一般来说,A | B | C ⟹ B
并不成立。只有当目标是源的所有元素的超类型时,它才是正确的。一个例子是字面量类型:'x' | 'y' | 'z' ⟹ string
。
还有更复杂的关系。例如,交叉类型和联合类型混合:
A & (B | C) ⟹ (A & B) | (A & C)
(A & B) | (A & C) ⟹ (A & B) | (A & C)
映射类型、索引访问类型和 keyof
类型也以多种方式相互作用。首先,如果 B
可赋值给 A
,那么 keyof A
也可赋值给 keyof B
:
keyof A ⟹ keyof B
B ⟹ A
给出 A
和 B
的示例类型来说明为什么方向会颠倒:
A = { a, b }
B = { a, b, c }
在这里,B
多了一个属性 c
,因此可以将 B
赋值给属性较少的 A
。但这并不意味着可以将 B
的键赋值给 A
的键。也就是 "a" | "b" | "c" ⟹ "a" | "b"
,对 "c"
来说是失败的。然而,"a" | "b" ⟹ "a" | "b" | "c"
是正确的,这与 keyof A ⟹ keyof B
完全相同。
对于映射类型,任何类型 T
都可以分配给恒等映射:
T ⟹ { [P in X]: T[P] }
这是从映射类型的定义中得出的。请注意,反之则不成立,因为映射类型会丢失一些信息。
最后,有些复杂关系涉及所有三种类型。例如,对于某个键类型 K
和重写类型 X
,如果 T
的键可赋值给 K
且 X
可赋值给 T
的所有属性:{ [P in K]:X }
是可赋值给 T
的,前提是 T
的键可赋值给 K
,且 X
可赋值给 T
的所有属性:
{ [P in K]: X } ⟹ T
keyof T ⟹ K
andX ⟹ T[K]
反之亦然:
T ⟹ { [P in K]: X }
K ⟹ keyof T
andT[K] ⟹ X
举例说明这一规则的合理性:
T = { a: C, b: D, c: C | D }
K = "a" | "b"
X = C | D
T ⟹ { [P in K]: X }
{ a: C, b: D, c: C | D } ⟹ { [P in "a" | "b"]: C | D }
{ a: C, b: D, c: C | D } ⟹ { "a": C | D, "b": C | D }
当以下两个条件都成立时,上述关系就是真的:
K ⟹ keyof T
"a" | "b" ⟹ "a" | "b" | "c"
且
T[K] ⟹ X
T["a" | "b"] ⟹ C | D
C | D ⟹ C | D
特殊情况
在简单的相等和结构比较之间,Typescript 有许多特殊情况可以快速处理特定类型。
- 单位类型(Unit types)和原始类型
- 过量属性和弱类型
- 递归截断
译者注:单位类型,Unit types,表示只能分配一个值的类型,比如
tstype Name = "John"; type Zero = 0; type True = true; enum Color {Red, Green, Blue} type Red = Color.Red;
简单类型
- 单位和原始类型:原始类型和单位类型都通过一长串特定规则进行比较。因为所有这些类型都用位标志进行标记所以比较非常快,典型的代码行看起来像这样:
ts
if (source.flags & TypeFlags.StringLike && target.flags & TypeFlags.String) return true;
有一些规则因为历史限制而显得有些奇怪。例如,任何数字都可以赋值给数字枚举,但这对于字符串枚举来说并不成立。只有已知是字符串枚举的一部分的字符串才可以赋值给它。这是因为数字枚举在联合类型和字面量类型之前就存在了,所以它们的规则最初是比较宽松的。
以下是用简单规则进行比较的类型:
- string
- number
- boolean
- symbol
- object
- any
- void
- null
- undefined
- never
- string enums
- number enums
- string literals
- number literals
- boolean literals
要注意,源和目标类型都必须是简单的,简单比较才能成功。如果源是一个字符串,而目标是某种对象类型,那么编译器就不得不使用结构比较法,并且必须 首先 获取字符串的所有方法和属性。
- 多余属性和弱类型检查
多余属性检查和弱类型检查紧随简单类型检查之后运行。它们之所以运行得这么早,是因为虽然它们的成本很高,但如果它们能在早期阶段就发现不符合可赋值的情况,它就比进行完整的结构比较节省了时间。
多余属性检查仅适用于对象字面量类型。其唯一目的是使其他情况合法的赋值失败。在结构可赋值中,有多余的属性是没有问题的,所以 { a, b } ⟹ { a }
。然而,当作者将一个对象字面量赋值给一个类型为 { a }
的变量时,他可能不太想包含除了 a
之外的其他属性。因此多余属性检查检查会禁止这种赋值。
弱类型检查只有在源只包含可选属性的情况下才适用。在结构赋值性中,任何类型都可以赋值给弱类型。所有这些赋值在结构上都是合理的:
{ a } ⟹ { a?, b? }
{ a, b, e } ⟹ { a?, b? }
{ c, d } ⟹ { a?, b? }
{ } ⟹ { a?, b? }
然而,只有前两个赋值在某种程度上是有意义的,因此弱类型检查要求源与弱目标至少共享一个属性。
结构截断
结构可赋性在处理递归类型时会遇到麻烦。具体来说,对于这样的类:
ts
declare class Box<T> {
t: T
m(b: Box<T>): void
}
var source = new Box<string>();
var target = new Box<unknown>();
target = source;
要知道目标是否可分配给源:
Box<string> ⟹ Box<unknown>
{ t: string, m(b: Box<string>): void } ⟹ { t: unknown, m(b: Box<unknown>): void }
现在,结构可赋值性必须证明 t
和 m
的类型是可赋值的,其中 t
很简单:
string ⟹ unknown
但 m
遇到了问题:
(b: Box<string>) => void ⟹ (b: Box<unknown>) => void
Box<string> ⟹ Box<unknown>
{ t: string, m(b: Box<string>): void } ⟹ { t: unknown, m(b: Box<unknown>): void }
这看起来就像一个无限循环。
实际上,对于这个问题,Typescript 有两种解决方案。最简单的是注意到 Box === Box
将类型参数视为代数关系。然后把 Box<T> ⟹ Box<U>
简化为 T ⟹ U
。这比使用结构赋值能力来决定 Box<T>.t
是否可赋值给 Box<U>.t
要快得多。
Box<string> ⟹ Box<unknown>
string ⟹ unknown
这种方法一般可以奏效,因为大多数情况下,即使是在结构类型系统中,人们也会编写名义代码(nominal code)。但是,如果有人带着一个类似的类出现,而他们刚刚更新了这个类,以便与 Box
一起使用,那该怎么办呢?
译者注
在编程语言中,类型系统通常被分为两种:名义类型(Nominal Typing)和结构类型(Structural Typing)。
Nominal Typing,也被称为名义类型系统或标签类型系统,是一种类型检查机制,它要求两个变量或对象必须显式地声明为相同的类型,才能被认为是相同的类型。即使两个对象的结构完全相同,如果它们的类型标签(或称为类型名)不同,那么它们就被认为是不同的类型。
例如,在 Java(一种使用名义类型系统的语言)中,即使两个类的字段和方法完全相同,如果它们的类名不同,那么它们就被认为是不同的类型。
Nominal Code,即名义代码,通常指的是在名义类型系统中编写的代码。
相对的,Structural Typing 是一种类型检查机制,它只关注对象的结构,只要两个对象的结构相同(即它们有相同的属性和方法),那么它们就被认为是相同的类型,无论它们的类型标签是否相同。TypeScript 就是一种使用结构类型系统的语言。
ts
declare class Ref<T> {
private item: T
deref(): T
// 为了兼容 Box:
readonly t: T
m(b: Ref<T>): void
}
现在,如果源是 Ref<string>
,目标是 Box<unknown>
,编译器就必须从结构上对整个代码进行比较。一切都很顺利,直到编译器再次尝试检查 m
:
Ref<string> ⟹ Box<unknown>
...
(b: Ref<string>) => void ⟹ (b: Box<unknown>) => void
Ref<string> ⟹ Box<unknown>
尽管 Ref !== Box
,但这是 Ref<string> ⟹ Box<unknown>
的第二次出现,这意味着第二次递归检查不会提供任何新信息来证明或反驳 Ref<string> ⟹ Box<unknown>
。然而,结构可赋值性返回的不是 True,而是三元值 Maybe(真、假和未确定)。如果所有非 Maybe 的结果都为真,则 Maybe 结果变为真。否则,它将保持 Maybe。
这就使得 Ref<string> ⟹ Box<unknown>
成功,因为 Ref.t
事实上可赋值给 Box.t
,而 item
和 deref
对于赋值并不重要。
不过,仍然 有一些类型是这两种检查无法捕捉到的。具体来说
ts
declare class Functor<T> {
fmap<U>(f: (t: T) => U): Functor<U>;
}
declare class Mappable<T> {
fmap<U>(f: (t: T) => U): Mappable<U>;
}
在这种情况下,对于 Functor<string> ⟹ Mappable<unknown>
,可赋值性在检查 fmap
的返回类型时必须检查 Functor<U> ⟹ Mappable<U>
。一旦它开始检查 Functor<U> ⟹ Mappable<U>
,它就会陷入困境:对 fmap
的每次递归检查都会创建一个新的 U
,因此它必须为这个新的 U
检查 Functor<U> ⟹ Mappable<U>
。
为了防止出现这种情况,结构可赋值性在比较相同的类型对(即使它们的参数是不同的)时有一个任意的 5 深度截断。也就是说,如果可赋值性检查遇到 (Functor, Mappable)
类型对 5 次,那么该特定比较将返回 Maybe。
总结
为了按照从最简单到最复杂的顺序介绍概念,本文介绍的可赋值性检查部分的顺序与实际出现的顺序不同。实际算法如下:
- 如果源类型等于目标类型,返回真。
- 如果源类型和目标类型都是简单的并且可以赋值,返回真。
- 如果源类型是对象类型并且目标类型有多余的属性,返回假。
- 如果源类型是弱类型并且目标类型没有共享的属性,返回假。
- 如果源类型或目标类型是代数类型,尽可能简化类型并递归。
- 如果源类型可以结构化地赋值给目标类型,返回真。
- 否则,返回假。
如前一节所解释的,第6步可能返回"Maybe",这被视为真。
这份文档也跳过了一些小细节,比如如何处理私有属性。要查看实际的代码,可以在 GitHub 上的 TypeScript 仓库中的 src/compiler/checker.ts 文件中查看 checkTypeRelatedTo
函数。
最后,它没有涵盖其他关系,如子类型或可比较性,因为它们都是赋值性的变体。