TypeScript 是一个用于编写可扩展 JavaScript 的绝佳工具。在大型 JavaScript 项目中,它或多或少成为了网络的事实标准。尽管它非常出色,但对于不熟悉它的人来说,它也有一些 棘手的地方。其中一个领域就是 TypeScript 的判别联合类型。
具体来说,给定以下代码
interface Cat {
weight: number;
whiskers: number;
}
interface Dog {
weight: number;
friendly: boolean;
}
let animal: Dog | Cat;
…许多开发人员惊讶地(甚至可能生气地)发现,当他们执行 animal.
时,只有 weight
属性有效,而 whiskers
或 friendly
属性无效。在本文结束时,这将变得非常有意义。
在我们深入探讨之前,让我们快速(且必要地)回顾一下结构化类型以及它与名义类型有何不同。这将很好地为我们关于 TypeScript 判别联合类型的讨论做好准备。
结构化类型
介绍结构化类型最好的方法是将其与它不是什么进行比较。您可能使用过的大多数类型化语言都是名义类型化的。考虑以下 C# 代码(Java 或 C++ 的外观类似)
class Foo {
public int x;
}
class Blah {
public int x;
}
即使 Foo
和 Blah
的结构完全相同,它们也无法相互赋值。以下代码
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
必须是 Dog
或 Cat
,但它可能是两者中的任何一个(或同时是两者,但让我们保持简单)。如果我们被允许访问 friendly
属性,但实例最终是 Cat
而不是 Dog
,那么我们可能会遇到运行时错误。同样,如果对象最终是 Dog
而不是 Cat
,那么 whiskers
属性也会出现同样的问题。
联合类型是有效值的联合,而不是属性的联合。开发人员经常编写类似以下的代码
let animal: Dog | Cat;
…并期望 animal
具有 Dog
和 Cat
属性的联合。但同样,这是一个错误。这将 animal
指定为具有与有效 Dog
值和有效 Cat
值的联合匹配的值。但是 TypeScript 仅允许您访问它知道存在的属性。目前,这意味着联合中所有类型的属性。
类型收窄
现在,我们有以下代码
let animal: Dog | Cat;
当 animal
是 Dog
时,我们如何将其正确地视为 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
对象上,您甚至可以看到这一点


判别联合类型
您可能期望博文到此结束,但不幸的是,通过检查属性是否存在来收窄联合类型的方法非常有限。它对我们简单的 Dog
和 Cat
类型非常有效,但是当我们有更多类型以及这些类型之间有更多重叠时,事情很容易变得更加复杂和脆弱。
这就是判别联合类型派上用场的地方。我们将保留之前的所有内容,只是为每种类型添加一个属性,其唯一作用是区分(或“判别”)这些类型
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
属性的不同值,则此检查将万无一失。
唯一的缺点是您现在需要处理一个新属性。每次创建 Dog
或 Cat
的实例时,您都必须为 ANIMAL_TYPE
提供唯一正确的 value。但是不用担心忘记,因为 TypeScript 会提醒您。🙂


进一步阅读
如果您想了解更多信息,我建议您阅读 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 的原因。
看起来“联合”一词用错了。我认为应该应用的集合论术语是“交集”。也就是说,“weight”是“Dog”和“Cat”之间共有的东西。在集合论中,我们称之为交集。
请记住,我们关注的是给定类型可以包含的值集。
当我们说
let a: Dog | Cat
时,我们声明a
可以保存有效 Dog 值和有效 Cat 值的集合联合中的任何值。但是,当涉及到 TS 允许我们访问
a
上的属性时,它仅限于 TS 肯定存在的属性,这无论多么令人困惑,最终都是 Dog 属性和 Cat 属性的集合交集。这可能值得提一下:对
ANIMAL_TYPE
使用枚举。除了创建类(或类型)之外,您最终还需要记住向枚举中添加更多动物,但这可以避免打字错误并在属性上获得类型检查,在我看来,这是一个巨大的优势。说得对!我同意。
不需要,因为TypeScript会自动强制字符串必须是完全正确的的值。因此,拼写错误也变得不可能。
枚举和联合类型之间的一个区别是,枚举编译为运行时对象,而类型联合则被去除。这不是性能问题,但需要注意。
在这种情况下,字符串联合可以很好地完成工作,因为TypeScript将提供智能感知,并且如果将来出现类型不匹配,也会报错。但是枚举可以让你免去手动更新各个用法的麻烦。
我敢肯定,TypeScript的旧版本存在一个问题,即带有枚举的区分联合没有正确地缩小类型,但现在似乎工作正常了。
非常感谢你写这篇文章。我开始后悔使用TS设置我的新副项目,并且在我的文章出现在我的信息流中时,我正对着完全相同的问题撞墙。你把一个复杂的话题分解成易于理解的块和可操作的解决方案,做得非常好。
感谢你的信息,Adam。
你提到
区分联合的概念是TypeScript区分这些对象的方式,并且这种方式可以很好地扩展,即使对于更大的对象集也是如此。
需要明确的是,区分联合是一个可以通过联合类型、鉴别器属性、检查鉴别器的逻辑和静态控制流分析在TypeScript中表达的概念。TypeScript并没有完全支持区分联合作为语言特性,而是允许用户将其作为编程模式来支持。
我只是想明确TS能做什么和不能做什么。对于读者来说,了解作为语言特性完全支持带来的好处以及缺乏支持带来的风险是有用的。
你必须确保在扩展联合类型时也扩展处理这些联合类型的逻辑。在原生支持不相交联合的语言中,你通常也会获得穷举模式匹配作为特性。这可以让你确信,当你扩展联合类型时,也会扩展处理这些联合类型的逻辑。可以在TypeScript中表达类似的模式。
看起来仅仅拥有一个类似于TypeScript接口的typeof和instanceof的运行时运算符,会比手动维护一个硬编码字段要好得多。
你好!非常感谢你写这篇文章 :)
在结论中提到
这是错误的,因为animal可以是Cat或Dog或两者。例如,以下代码不会产生任何错误