揭开TypeScript的神秘面纱 Discriminated Unions

430 阅读6分钟

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 结构相匹配的对象,在本例中,意味着一个代表数字的 属性。这意味着即使是一个普通的JavaScript对象也会被接受:x

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 ,我们可能会得到运行时错误。同样,如果对象最终是一个Dogwhiskers 属性也是如此。

类型联盟是有效值的联盟,而不是属性的联盟。开发者经常写这样的东西:

let animal: Dog | Cat;

...并期望animal 拥有DogCat 属性的联合。但是,这又是一个错误。这指定了animal ,它的与有效的Dog 值和有效的Cat 值的联合相匹配。但是TypeScript只允许你访问它所知道的属性。现在,这意味着在联盟中的所有类型的属性。

缩小范围

现在,我们有这个:

let animal: Dog | Cat;

当它是一个Dog ,我们如何正确地将animal 作为一个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 。如果你在代码编辑器中把鼠标移到这些块内的动物对象上,你甚至可以看到这一点。

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 提供唯一正确的值。但不用担心忘记,因为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 可以是Dog ,也可以是Cat ,但不能同时是。因此,我们得到的是weight ,这是两者之间唯一的共同属性。

鉴别性联合的概念是TypeScript如何区分这些对象的,并且以一种非常好的方式进行扩展,即使是较大的对象集。因此,我们不得不在这两种类型上创建一个新的ANIMAL_TYPE 属性,持有一个我们可以用来检查的单一字面值。当然,这是另一件需要追踪的事情,但它也产生了更可靠的结果--这正是我们首先希望从TypeScript中得到的。