likes
comments
collection
share

TypeScript 学习指南——联合和字面量

作者站长头像
站长
· 阅读数 15

本文选自我在翻译的《Learning TypeScript》,比较长,读完需耐心。


没有什么是不变的 值可能会随时间变化 (常量除外)

第 2 章“类型系统”介绍了“类型系统”的概念以及它如何读取值来理解变量的类型。现在,我想介绍 TypeScript 用来在这些值之上进行推理的两个关键概念:

  • 联合 将值的允许类型扩展为两个或多个可能的类型
  • Narrowing(缩小) 将值的允许类型减少到不是一个或多个可能的类型

综上所述,联合和缩小是强大的概念,它允许 TypeScript 对代码做出许多其他主流语言无法做出的明智推断。

联合类型

看这个 mathematician 变量:

let mathematician = Math.random() > 0.5
  ? undefined
  : "Mark Goldberg";

mathematician 是什么类型?

它既不是 undefined 也不是 string,即使它们都是潜在的类型。mathematician 可以是 undefinedstring。这种“非此即彼”类型称为联合。联合类型是一个很棒的概念,它让我们可以处理不确切知道值是哪种类型,但确切知道它是两个或多个选项之一的代码情况。

TypeScript 在可能的值或成分之间使用 |(竖线)运算符表示联合类型。上述 mathematician 的类型被认为是 string |undefined。将鼠标悬停在该变量上将显示其类型为 string |undefined(图 3-1)。

TypeScript 学习指南——联合和字面量

图 3-1。TypeScript 将变量 mathematician 报告为 string | undefined 类型

声明联合类型

联合类型是这种情况的示例:即使变量具有初始值,为变量提供显式类型注解也可能很有用。在下述示例中,thinkernull 开始,但已知可能包含 string。为其显式 string | null 类型注解意味着 TypeScript 将允许为其分配 string 类型的值:

let thinker: string | null = null;

if (Math.random() > 0.5) {
    thinker = "Susanne Langer"; // Ok
}

联合类型声明可以放置在可能使用类型注解声明类型的任何位置。

联合类型声明的顺序无关紧要。您可以写 boolean | number 或 number| boolean,TypeScript 将视为完全相同。

联合属性

当已知值为联合类型时,TypeScript 将只允许您访问联合中所有可能类型上存在的成员属性。如果您尝试访问并非在所有可能类型上都存在的类型,则它将为您提供类型检查错误。

在以下代码段中,physicist 的类型为 number | string。虽然 .toString() 在这两种类型中都存在并允许使用,但 .toUpperCase().toFixed() 不是。因为 number 类型缺少 .toUpperCase()string 类型缺少 .toFixed()

let physicist = Math.random() > 0.5
  ? "Marie Curie"
  : 84;

physicist.toString(); // Ok

physicist.toUpperCase();
// ~~~~~~~~
// 错误:类型“string | number”上不存在属性“toUpperCase”。
//    类型“number”上不存在属性“toUpperCase”。

physicist.toFixed();
// ~~~~~~~~
// 错误:类型“string | number”上不存在属性“toFixed”。
//    类型“string”上不存在属性“toFixed”。

限制对并非所有联合类型上都存在的属性的访问是一项安全措施。如果不知道某个对象肯定是包含属性的类型,则 TypeScript 会认为尝试使用该属性是不安全的。该物业可能不存在!

若要使用仅存在于潜在类型子集上的联合类型化值的属性,代码需要向 TypeScript 指示代码中该位置的值是这些更具体的类型之一:称为 Narrowing 的过程。

缩小

缩小是指 TypeScript 从代码中推断出一个值的类型比它之前定义、声明或推断的值更具体。只要 TypeScript 知道值的类型范围比以前知道的更小,它将允许您像更具体的类型一样对待该值。可用于缩小类型的逻辑检查称为类型守护

让我们介绍 TypeScript 可以用来从代码中推断类型缩小的两个常见类型保护。

赋值缩小

如果直接为变量赋值,TypeScript 会将变量的类型缩小到该值的类型。

在这里,admiral 变量最初声明为 number |string,但是在被赋值 "Grace Hopper" 之后,TypeScript 知道它一定是 string

let admiral: number | string; admiral = "Grace Hopper"; 

admiral.toUpperCase(); // Ok: string

admiral.toFixed();
// ~~~~~~~~
// 错误:类型“string”上不存在属性“toFixed”。

当为变量提供显式联合类型注解和初始值时,赋值缩小就会发挥作用。TypeScript 将会理解,虽然变量稍后可能会收到任何联合类型值的值,但它开始时仅作为其初始值的类型。

在以下代码段中,inventor 声明为类型 number |string,但 TypeScript 知道它立即从其初始值缩小到 string

let inventor: number | string = "Hedy Lamarr"; 

inventor.toUpperCase(); // Ok: string

inventor.toFixed();
// ~~~~~~~~
// 错误:类型“string”上不存在属性“toFixed”。

条件检查

让 TypeScript 缩小变量值的常用方法是编写 if 语句,检查变量是否等于已知值。TypeScript 足够聪明,可以理解在该 if 语句的主体中,变量必须与已知值的类型相同:

// scientist 的类型:number | string
let scientist = Math.random() > 0.5
  ? "Rosalind Franklin"
  : 51;

if (scientist === "Rosalind Franklin") {
  // scientist 的类型:string
  scientist.toUpperCase(); // Ok
}

// scientist 的类型: number | string
scientist.toUpperCase();
// ~~~~~~~~
// 错误:类型“string | number”上不存在属性“toUpperCase”。
//    类型“number”上不存在属性“toUpperCase”。

通过条件逻辑缩小范围表明 TypeScript 的类型检查逻辑反映了良好的 JavaScript 编码模式。如果变量可能是几种类型之一,则通常需要检查其类型是否为所需类型。TypeScript 迫使我们安全地使用我们的代码。谢谢,TypeScript!

Typeof 检查

除了直接值检查之外,TypeScript 还可以识别缩小变量类型范围的 typeof 运算符。

scientist 示例类似,检查 typeof researcher 是否为 "string" 向 TypeScript 表明 researcher 的类型必须为 string

let researcher = Math.random() > 0.5
  ? "Rosalind Franklin"
  : 51;

if (typeof researcher === "string") {
  researcher.toUpperCase(); // Ok: string
}

来自 !else 语句的逻辑否定也有效:

if (!(typeof researcher === "string")) {
  researcher.toFixed(); // Ok: number
} else {
  researcher.toUpperCase(); // Ok: string
}

这些代码片段可以用三元运算符来重写,该语句也支持用于类型缩小:

typeof researcher === "string"
  ? researcher.toUpperCase() // Ok: string
  : researcher.toFixed(); // Ok: number

无论以哪种方式编写它们,typeof 检查都是缩小类型范围的实用且常用的方法。

TypeScript 的类型检查器可以识别更多形式的类型缩小,我们将在后面的章节中看到这些形式。

字面量类型

既然我已经展示了联合类型和缩小处理可能是两个或更多潜在类型的值的方法,我想反过来介绍字面量类型:基本类型的更具体版本。

看这个 philosopher 变量:

const philosopher = "Hypatia";

philosopher是什么类型?

乍一看,您可能会说是 string——您是对的。philosopher 确实是 string

philosopher 不是普通 string。具体而言,它是值 "Hypatia"。因此,从技术上讲,philosopher 变量的类型是更具体的 "Hypatia"

这就是字面量类型的概念:已知是基本类型的特定值的值类型,而不是这些基本类型的任何值。基本类型 string 表示可能存在的所有可能字符串的集合;字面量类型 "Hypatia" 仅表示一个字符串。

如果将变量声明为 const 并直接为其指定字面量值,则 TypeScript 会将该变量推断为该字面量值类型。这就是为什么当您将鼠标悬停在 IDE 中具有初始字面量值的 const 变量(如 VS Code)上时,它会将变量的类型显示为该字面量(图 3-2),而不是更通用的基本类型(图 3-3)。

TypeScript 学习指南——联合和字面量

图 3-2。TypeScript 报告 const 变量为其字面量类型

TypeScript 学习指南——联合和字面量

图 3-3。TypeScript 报告 let 变量通常是其基本类型

可以将每个基本类型视为每个可能匹配字面量值的联合。换句话说,基本类型是该类型所有可能的字面量值的集合。

除了 booleannullundefined 类型之外,所有其他基本类型(如 numberstring)都有无限数量的字面量类型。您将在典型的 TypeScript 代码中找到的常见类型就是这些类型:

  • boolean: 只有 true |false
  • nullundefined:它们本身只有一个字面量值
  • number: 0 |1 |2 |… |0.1 |0,2 |…
  • string: "" | "a" | "b" | "C" | … | "AA" |"AB" | "ac" |…

联合类型注解可以混合匹配字面量和基本类型。例如,使用期可能由任何 number 一些已知的边界情况之一来表示:

let lifespan: number | "ongoing" | "uncertain";

lifespan = 89; // Ok
lifespan = "ongoing"; // Ok

lifespan = true;
// 错误:不能将类型“true”分配给类型“number | "ongoing" | "uncertain"”

字面量可分配性

您已经看到不同的基本类型(如 numberstring)如何不能相互分配。同样,同一基本类型中的不同字面量类型(例如 01)也不能相互分配。

在此示例中,specificallyAda 声明为字面量类型 "Ada"。虽然可以为其指定值 "Ada",但不能为其分配类型 "Byron"string

let specificallyAda: "Ada"; 

specificallyAda = "Ada"; // Ok

specificallyAda = "Byron";
// 错误:不能将类型“"Byron"”分配给类型“"Ada"”

let someString = ""; // 类型:string

specificallyAda = someString;
// 错误:不能将类型“string”分配给类型“"Ada"”

但是,允许将字面量类型分配给其相应的基本类型。任何特定的字面量字符串仍然是 string

以下代码示例中,值 ":)",类型为 ":)",分配给先前推断为 string 类型的 someString 变量:

someString = ":)";

谁能想到一个简单的变量赋值操作在理论上会如此复杂?

严格的空值检查

在处理可能未定义的值时,使用字面量缩小联合的威力尤其明显,TypeScript 将类型系统的一个区域称为严格的空值检查。TypeScript 是解决可怕的“十亿美元错误”的现代编程语言的一部分。

十亿美元的错误

我称之为我的十亿美元错误。1965 年 null 引用被发明出来…… 这导致了无数的错误、漏洞和系统崩溃,在过去 40 年中,这些错误、漏洞和系统崩溃可能造成了 2009 亿美元的痛苦和损害。 ——托尼·霍尔,2009

“十亿美元的错误”是一个吸引人的行业术语,用于许多类型系统,允许在需要不同类型的地方使用空值。在没有严格 null 检查的语言中,允许使用类似以下示例的代码,将 null 分配给 string

const firstName: string = null;

如果您以前使用过像 C++ 或 Java 这样的类型语言,遭受了数十亿美元的错误,那么有些语言不允许这样的事情可能会让您感到惊讶。如果您以前从未使用过具有严格空值检查的语言,可能会惊讶地发现有些语言竟然允许数十亿美元的错误!

TypeScript 编译器包含许多选项,允许更改其运行方式。第 13 章 “配置选项”将深入介绍 TypeScript 编译器选项。最有用的选择是加入选项 strictNullChecks 用于切换是否启用严格空值检查。简单地说,禁用 strictNullChecks 将为代码中的所有类型添加 null | undefined,从而允许任何变量接收 nullundefined

strictNullChecks 选项设置为 false 时,以下代码完全是类型安全的。不过这是错误的; 当通过 nameMaybe 访问 .toLowerCase 时, 它可能是 undefined

let nameMaybe = Math.random() > 0.5
  ? "Tony Hoare"
  : undefined;

nameMaybe.toLowerCase();
// 潜在的运行时错误: 无法读取未定义的属性“toSmallCase”.

启用严格的空值检查后,TypeScript 会在代码片段中看到潜在的崩溃:

let nameMaybe = Math.random() > 0.5
  ? "Tony Hoare"
  : undefined;

nameMaybe.toLowerCase();
// 错误:“nameMaybe”可能为“未定义”。

如果不启用严格的空值检查,就很难知道您的代码是否不会因意外的 nullundefined 值而导致错误。

TypeScript 最佳实践通常是启用严格的空值检查。这样做有助于防止崩溃并避免数十亿美元的错误。

真实性缩小

回想一下 JavaScript 中的真实性或者说是 true 是指在 Boolean 上下文(例如 && 运算符或 if 语句)中计算值时是否被看作 true。JavaScript 中的所有值都为真,除了那些定义为 falsy 的值: false0-00n""nullundefinedNaN

TypeScript 还可以从真实性检查中缩小变量的类型,如果只有某些潜在值可能是真实的。在以下代码段中,geneticist 的类型为 string |undefined,并且由于 undefined 始终为假,因此 TypeScript 可以推断出它在 if 语句体中一定是 string 类型:

let geneticist = Math.random() > 0.5
  ? "Barbara McClintock"
  : undefined;

if (geneticist) {
    geneticist.toUpperCase(); // Ok: string
}

geneticist.toUpperCase();
// 错误:“geneticist”可能为“未定义”。

&&?. 逻辑运算符也执行真实性检查工作:

geneticist && geneticist.toUpperCase(); // Ok: string | undefined
geneticist?.toUpperCase(); // Ok: string | undefined

注解1:浏览器中已弃用的 document.all 对象。为了兼容旧版浏览器,浏览器中的所有对象都被定义为假。为了本书的目的,以及您自己作为开发人员的幸福感,不要担心 document.all。

不幸的是,真实性检查不会反过来执行。如果我们只知道 string | undefined 的值是 false ,这并不能告诉我们它是空字符串还是 undefined

以下示例中,biologist 的类型为 false | string,虽然可以在 if 语句正文中将其缩小到仅为 string,但 else 语句体知道,如果它是 "",它仍然可以是一个字符串:

let biologist = Math.random() > 0.5 && "Rachel Carson";

if (biologist) {
  biologist; // 类型:string
} else {
  biologist; // 类型:false | string
}

没有初始值的变量

在 JavaScript 中,声明的没有初始值的变量默认为 undefined。这在类型系统中呈现了一种边缘情况:如果您将一个变量声明为不包含 undefined 的类型,然后在赋值之前尝试使用它怎么办?

TypeScript 足够聪明,可以理解变量是 undefined,直到分配一个值。如果在分配值之前尝试使用该变量(例如通过访问其属性之一),它将报告专门的错误消息:

let mathematician: string;

mathematician?.length;
// 错误:在赋值前使用了变量“mathematician”。

mathematician = "Mark Goldberg"; 
mathematician.length; // Ok

请注意,如果变量的类型包含 undefined,则此报告不适用。给变量类型添加 | undefined,向 TypeScript表明在使用前不需要定义它,因为 undefined 是该值的有效类型。

如果 mathematician 的类型为 string |undefined,那么上面的代码片段不会产生任何错误:

let mathematician: string | undefined; 

mathematician?.length; // Ok

mathematician = "Mark Goldberg";
mathematician.length; // Ok

类型别名

您将在代码中看到的大多数联合类型通常只有两个或三个组成部分。但是,有时您可能会发现使用较长的联合类型,这些类型不方便重复键入。

这些变量中的每一个都可以是四种可能的类型之一:

let rawDataFirst: boolean | number | string | null | undefined;
let rawDataSecond: boolean | number | string | null | undefined;
let rawDataThird: boolean | number | string | null | undefined;

TypeScript 包括类型别名,用于为重用类型分配更简单的名称。类型别名以 type 关键字、新名称 = 开头,然后是任何类型。按照惯例,类型别名使用 Pascal 式命名:

type MyName = ...;

类型别名在类型系统中充当复制和粘贴。当 TypeScript 看到类型别名时,它就像您键入了别名所引用的实际类型一样。可以重写前面变量的类型注解,以便对长联合类型使用类型别名:

type RawData = boolean | number | string | null | undefined;

let rawDataFirst: RawData;
 let rawDataSecond: RawData;
let rawDataThird: RawData;

这代码更容易阅读!

类型别名是一个方便的功能,每当类型开始变得复杂时,都可以在 TypeScript 中使用。目前,这仅包括长联合类型;稍后它将包括数组、函数和对象类型。

类型别名不是 JavaScript

类型别名(如类型注解)不会编译到输出 JavaScript。它们纯粹存在于 TypeScript 类型系统中。

前面的代码片段将编译为大致如下 JavaScript:

let rawDataFirst;
let rawDataSecond;
let rawDataThird;

由于类型别名纯粹位于类型系统中,因此不能在运行时代码中引用它们。如果您尝试访问运行时不存在的内容,TypeScript 会通过类型错误通知您:

type SomeType = string | undefined;

console.log(SomeType);
// ~~~~~~~~
// 错误:“SomeType”仅表示类型,但在此处却作为值使用。

类型别名纯粹作为开发时构造存在。

组合类型别名

类型别名可以引用其他类型别名。有时,让类型别名相互引用是很有用的,例如,当一个类型别名是类型的联合,其中包括(是联合类型的超集)另一个类型别名中的联合类型时

此代码中, IdMaybe 类型是 Id 以及 undefinednull 中的类型的并集:

type Id = number | string;

// 相当于: number | string | undefined | null
type IdMaybe = Id | undefined | null;

类型别名不必按使用顺序声明。可以在文件引用的前面声明类型别名,也可以在文件的后面声明。

可以重写前面的代码片段,使 IdMaybe 位于 Id 之前:

type IdMaybe = Id | undefined | null; // Ok
type Id = number | string;

总结

本章介绍了 TypeScript 中的联合类型和字面量类型,以及 TypeScript 的类型系统如何根据代码的结构推断出更具体的类型:

  • 联合类型如何表示可能是两种或多种类型之一的值
  • 使用类型注解显式指定联合类型
  • 类型缩小如何减少值的可能类型
  • 具有字面量类型的 const 变量与具有基本类型的 let 变量之间的区别
  • “十亿美元的错误”以及 TypeScript 如何处理严格的空值检查
  • 使用显式 | undefined 表示可能不存在的值
  • 隐式 | undefined 表示未赋值的变量
  • 使用类型别名以避免重复输入长联合类型

现在您已经读完了这一章,您最好练习一下学到的东西 https://learningtypescript.com/unions-and-literals


为什么常量如此严肃? 他们太较真了。