[译]TypeScript 的可赋值性

本文翻译自:TypeScript-New-Handbook # Assignability

基础

可赋值性是决定一个变量是否可以赋值给另一个变量的函数。编译器不仅会检查赋值和函数调用,还会检查返回语句。例如:

ts 复制代码
var x: number;
var y: number;
x = y;

function f(s: string): number {
  return s.length;
}
f(false);

这段代码有三个可赋值性检查:

  1. x = y,检查 y 是否可以赋值给 x
  2. return s.length,检查 s.length 是否可以从 f 返回。
  3. f(false),检查 false 是否可以赋值给 f 的第一个参数。

为了实现这一点,编译器会找出 xys.length 的类型,以及 f 的参数和返回类型。

这样我们就能做出以下判断:

  1. y: number 可赋值给 x: number
  2. s.length: number 可赋值给 f 的返回 :number
  3. 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;

DerivedBase 之间没有继承联系。当然,可赋值性检查会缓存其结果,因此一旦编译器知道 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

给出 AB 的示例类型来说明为什么方向会颠倒:

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 的键可赋值给 KX 可赋值给 T 的所有属性:{ [P in K]:X } 是可赋值给 T 的,前提是 T 的键可赋值给 K,且 X 可赋值给 T 的所有属性:

{ [P in K]: X } ⟹ T
keyof T ⟹ K and X ⟹ T[K]

反之亦然:

T ⟹ { [P in K]: X }
K ⟹ keyof T and T[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 有许多特殊情况可以快速处理特定类型。

  1. 单位类型(Unit types)和原始类型
  2. 过量属性和弱类型
  3. 递归截断

译者注:单位类型,Unit types,表示只能分配一个值的类型,比如

ts 复制代码
type Name = "John";
type Zero = 0;
type True = true;
enum Color {Red, Green, Blue}
type Red = Color.Red;

简单类型

  1. 单位和原始类型:原始类型和单位类型都通过一长串特定规则进行比较。因为所有这些类型都用位标志进行标记所以比较非常快,典型的代码行看起来像这样:
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

要注意,源和目标类型都必须是简单的,简单比较才能成功。如果源是一个字符串,而目标是某种对象类型,那么编译器就不得不使用结构比较法,并且必须 首先 获取字符串的所有方法和属性。

  1. 多余属性和弱类型检查

多余属性检查和弱类型检查紧随简单类型检查之后运行。它们之所以运行得这么早,是因为虽然它们的成本很高,但如果它们能在早期阶段就发现不符合可赋值的情况,它就比进行完整的结构比较节省了时间。

多余属性检查仅适用于对象字面量类型。其唯一目的是使其他情况合法的赋值失败。在结构可赋值中,有多余的属性是没有问题的,所以 { 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 }

现在,结构可赋值性必须证明 tm 的类型是可赋值的,其中 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,而 itemderef 对于赋值并不重要。

不过,仍然 有一些类型是这两种检查无法捕捉到的。具体来说

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。

总结

为了按照从最简单到最复杂的顺序介绍概念,本文介绍的可赋值性检查部分的顺序与实际出现的顺序不同。实际算法如下:

  1. 如果源类型等于目标类型,返回真。
  2. 如果源类型和目标类型都是简单的并且可以赋值,返回真。
  3. 如果源类型是对象类型并且目标类型有多余的属性,返回假。
  4. 如果源类型是弱类型并且目标类型没有共享的属性,返回假。
  5. 如果源类型或目标类型是代数类型,尽可能简化类型并递归。
  6. 如果源类型可以结构化地赋值给目标类型,返回真。
  7. 否则,返回假。

如前一节所解释的,第6步可能返回"Maybe",这被视为真。

这份文档也跳过了一些小细节,比如如何处理私有属性。要查看实际的代码,可以在 GitHub 上的 TypeScript 仓库中的 src/compiler/checker.ts 文件中查看 checkTypeRelatedTo 函数。

最后,它没有涵盖其他关系,如子类型或可比较性,因为它们都是赋值性的变体。

相关推荐
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端
爱敲代码的小鱼10 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax