TypeScript 判别联合类型

Avatar of Adam Rackis
Adam Rackis

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

TypeScript 是一个用于编写可扩展 JavaScript 的绝佳工具。在大型 JavaScript 项目中,它或多或少成为了网络的事实标准。尽管它非常出色,但对于不熟悉它的人来说,它也有一些 棘手的地方。其中一个领域就是 TypeScript 的判别联合类型。

具体来说,给定以下代码

interface Cat {
  weight: number;
  whiskers: number;
}
interface Dog {
  weight: number;
  friendly: boolean;
}
let animal: Dog | Cat;

…许多开发人员惊讶地(甚至可能生气地)发现,当他们执行 animal. 时,只有 weight 属性有效,而 whiskersfriendly 属性无效。在本文结束时,这将变得非常有意义。

在我们深入探讨之前,让我们快速(且必要地)回顾一下结构化类型以及它与名义类型有何不同。这将很好地为我们关于 TypeScript 判别联合类型的讨论做好准备。

结构化类型

介绍结构化类型最好的方法是将其与它不是什么进行比较。您可能使用过的大多数类型化语言都是名义类型化的。考虑以下 C# 代码(Java 或 C++ 的外观类似)

class Foo {
  public int x;
}
class Blah {
  public int x;
}

即使 FooBlah 的结构完全相同,它们也无法相互赋值。以下代码

Blah b = new Foo();

…会生成以下错误

Cannot implicitly convert type 'Foo' to 'Blah'

这些类的结构无关紧要。类型为 Foo 的变量只能分配给 Foo 类(或其子类)的实例。

TypeScript 的工作方式与此相反。如果 TypeScript 类型具有相同的结构,则认为它们是兼容的——因此得名结构化类型。明白了吗?

因此,以下代码可以在没有错误的情况下运行

class Foo {
  x: number = 0;
}
class Blah {
  x: number = 0;
}
let f: Foo = new Blah();
let b: Blah = new Foo();

类型作为匹配值的集合

让我们深入理解这一点。给定以下代码

class Foo {
  x: number = 0;
}

let f: Foo;

f 是一个变量,它保存任何与 Foo 类创建的实例结构匹配的对象,在本例中,这意味着一个表示数字的 x 属性。这意味着即使是普通的 JavaScript 对象也会被接受。

let f: Foo;
f = {
  x: 0
}

联合类型

感谢您坚持看到这里。让我们回到开头的那段代码

interface Cat {
  weight: number;
  whiskers: number;
}
interface Dog {
  weight: number;
  friendly: boolean;
}

我们知道这个

let animal: Dog;

…使 animal 成为任何与 Dog 接口具有相同结构的对象。那么以下代码是什么意思?

let animal: Dog | Cat;

这将 animal 类型化为任何与 Dog 接口匹配的对象,或任何与 Cat 接口匹配的对象

那么,为什么 animal(以其当前形式)只允许我们访问 weight 属性呢?简单来说,这是因为 TypeScript 不知道它是什么类型。TypeScript 知道 animal 必须是 DogCat,但它可能是两者中的任何一个(或同时是两者,但让我们保持简单)。如果我们被允许访问 friendly 属性,但实例最终是 Cat 而不是 Dog,那么我们可能会遇到运行时错误。同样,如果对象最终是 Dog 而不是 Cat,那么 whiskers 属性也会出现同样的问题。

联合类型是有效值的联合,而不是属性的联合。开发人员经常编写类似以下的代码

let animal: Dog | Cat;

…并期望 animal 具有 DogCat 属性的联合。但同样,这是一个错误。这将 animal 指定为具有与有效 Dog 值和有效 Cat 值的联合匹配的。但是 TypeScript 仅允许您访问它知道存在的属性。目前,这意味着联合中所有类型的属性。

类型收窄

现在,我们有以下代码

let animal: Dog | Cat;

animalDog 时,我们如何将其正确地视为 Dog 并访问 Dog 接口上的属性,以及当它是 Cat 时如何访问它的属性?目前,我们可以使用 in 运算符。这是一个您可能不太常看到的旧式 JavaScript 运算符,但它基本上允许我们 测试某个属性是否在对象中。如下所示

let o = { a: 12 };

"a" in o; // true
"x" in o; // false

事实证明,TypeScript 与 in 运算符深度集成。让我们看看它是如何工作的

let animal: Dog | Cat = {} as any;

if ("friendly" in animal) {
  console.log(animal.friendly);
} else {
  console.log(animal.whiskers);
}

此代码不会产生任何错误。在 if 块内,TypeScript 知道存在 friendly 属性,因此将 animal 转换为 Dog。在 else 块内,TypeScript 同样将 animal 视为 Cat。如果您在代码编辑器中将鼠标悬停在这些块中的 animal 对象上,您甚至可以看到这一点

Showing a tooltip open on top of a a TypeScript discriminated unions example that shows `let animal: Dog`.
Showing a tooltip open on top of a a TypeScript discriminated union example that shows `let animal: Cat`.

判别联合类型

您可能期望博文到此结束,但不幸的是,通过检查属性是否存在来收窄联合类型的方法非常有限。它对我们简单的 DogCat 类型非常有效,但是当我们有更多类型以及这些类型之间有更多重叠时,事情很容易变得更加复杂和脆弱。

这就是判别联合类型派上用场的地方。我们将保留之前的所有内容,只是为每种类型添加一个属性,其唯一作用是区分(或“判别”)这些类型

interface Cat {
  weight: number;
  whiskers: number;
  ANIMAL_TYPE: "CAT";
}
interface Dog {
  weight: number;
  friendly: boolean;
  ANIMAL_TYPE: "DOG";
}

请注意两种类型上的 ANIMAL_TYPE 属性。不要将其误认为是具有两个不同值的字符串;这是一个字面量类型。ANIMAL_TYPE: "CAT"; 表示一个正好保存字符串 "CAT" 的类型,仅此而已。

现在我们的检查变得更加可靠了

let animal: Dog | Cat = {} as any;

if (animal.ANIMAL_TYPE === "DOG") {
  console.log(animal.friendly);
} else {
  console.log(animal.whiskers);
}

假设联合中参与的每种类型都具有 ANIMAL_TYPE 属性的不同值,则此检查将万无一失。

唯一的缺点是您现在需要处理一个新属性。每次创建 DogCat 的实例时,您都必须为 ANIMAL_TYPE 提供唯一正确的 value。但是不用担心忘记,因为 TypeScript 会提醒您。🙂

Showing the TypeScript discriminated union for a createDog function that returns weight and friendly properties.
Screenshot of TypeScript displaying a warning in the code editor as a result of not providing a single value for the ANIMAL_TYPE property.


进一步阅读

如果您想了解更多信息,我建议您阅读 TypeScript 的 关于类型收窄的文档。这将提供我们在此处介绍内容的更深入的介绍。在该链接中,有一个关于 类型谓词 的部分。这些允许您定义自己的自定义检查来收窄类型,而无需使用类型判别器,也无需依赖 in 关键字。

结论

在本文开头,我说过以下示例中为什么 weight 是唯一可访问的属性将变得有意义

interface Cat {
  weight: number;
  whiskers: number;
}
interface Dog {
  weight: number;
  friendly: boolean;
}
let animal: Dog | Cat;

我们学到的是,TypeScript 只知道 animal 可以是 DogCat,但不能同时是两者。因此,我们只能获得 weight,它是两者之间唯一的公共属性。

判别联合类型的概念是 TypeScript 区分这些对象的方式,并且这种方式可以很好地扩展,即使对象集更大也是如此。因此,我们必须在两种类型上都创建一个新的 ANIMAL_TYPE 属性,该属性保存我们可以用来进行检查的单个字面量值。当然,这是需要跟踪的另一件事,但它也产生了更可靠的结果——这正是我们首先使用 TypeScript 的原因。