第 3 章 联合和字面量

第 3 章 联合和字面量

Flying
2022-10-29 / 0 评论 / 117 阅读 / 正在检测是否收录...

没有什么是永恒不变的
值可能会随时间改变
(当然,常量除外)

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

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

联合类型和类型缩小的结合是强大的概念,使得 TypeScript 能够对你的代码进行明智的推断,而其他许多主流编程语言则无法做到。

联合类型

看这个 mathematician 变量:

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

mathematician 是什么类型?

它的类型既不是仅仅 undefined ,也不是仅仅 string,尽管这两者都是可能的类型。mathematician 可以是 undefinedstring 中的任意一种。这种“要么...要么...”的类型称为联合类型。联合类型是一个很棒的概念,它让我们能够处理一些情况下我们不确定一个值的确切类型,但知道它是两个或更多选项中的一个。

TypeScript使用|(管道)操作符来表示联合类型的可能值,或称为成员(constituents)。前面的 mathematician 类型可以被看作是string | undefined。鼠标悬停在mathematician变量上时,它的类型会显示为string | undefined(图 3-1)。

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

声明联合类型

联合类型是一个情况的例子,即使变量有一个初始值,给它一个明确的类型注解可能也是有用的。在这个例子中,thinker 的初始值为 null,但可能包含一个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() 不能使用,因为 .toUpperCase()number 类型中不存在,.toFixed()string 类型中不存在:

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(缩小类型范围)。

缩小类型范围

缩小类型范围(Narrowing)是指 TypeScript 从你的代码中推断出一个值的类型比之前定义、声明或推断的类型更为具体。一旦 TypeScript 确定一个值的类型比之前已知的更为具体,它将允许你将该值视为更特定的类型。用于缩小类型范围的逻辑检查被称为类型保护(type guard)。

让我们来介绍 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”。

条件检查

通过编写一个检查变量是否等于已知值的 if 语句,可以常见地使 TypeScript 缩小变量的值。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 会将该变量推断为该字面量值类型。这就是为什么当你将鼠标悬停在具有初始字面量值的 const 变量上时,像 VS Code 这样的 IDE 会将变量的类型显示为该字面量值(图 3-2),而不是更常见的基本类型(图 3-3)。

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

figure-3-3.png
图 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 年中造成了数十亿美元的痛苦和损失。
–Tony Hoare, 2009

“十亿美元的错误”是一个行业术语,用于描述许多类型系统允许在需要不同类型的地方使用空值的情况。在没有严格的空值检查的语言中,允许像这个例子一样将 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();
// 错误:对象可能为“未定义”。

如果不启用严格的空值检查,很难确定代码是否安全,是否存在意外的 nullundefined 值。

TypeScript的最佳实践通常是启用严格的空值检查。这样做有助于预防崩溃,并消除了“十亿美元的错误”。

真值缩小

回顾一下 JavaScript 中的真值性(truthiness),或者被称为真值(truthy),指的是在布尔上下文中对一个值进行求值时是否被认为是 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.toUpperCase(); // Ok: string | undefined
geneticist?.toUpperCase(); // Ok: string | undefined
注解1:浏览器中已弃用的 document.all 对象。为了兼容旧版浏览器,浏览器中的所有对象都被定义为假。为了本书的目的,以及你自己作为开发人员的幸福感,不要担心 document.all。

不幸的是,真值检查不能反向操作。如果我们只知道 string | undefined 值是假值,并不能告诉我们它是空字符串还是 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 关键字开头,然后是一个新的名称、等号 =,以及任何类型。按照约定,类型别名使用帕斯卡命名法(PascalCase):

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;

类型别名不需要按照使用顺序进行声明。你可以在文件中先声明一个类型别名,然后在后面引用在该文件中稍后声明的类型别名。

前面的代码片段可以重写,让 IdMaybeId 之前声明:

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

总结

在本章中,你学习了 TypeScript 中的联合类型和字面量类型,以及它的类型系统如何从我们代码的结构中推断出更具体的类型:

  • 联合类型表示值可以是两种或多种类型之一
  • 使用类型注解显式地指定联合类型
  • 类型缩小将值的可能类型减少为更具体的类型
  • 字面量类型 const 变量与基本类型 let 变量之间的区别
  • “十亿美元的错误”以及 TypeScript 如何处理严格的空值检查
  • 使用显式的 | undefined 表示可能不存在的值
  • 隐式的 | undefined 用于未赋值的变量
  • 使用类型别名避免重复输入长的类型联合
现在你已经读完了这一章,你最好练习一下学到的东西 https://learningtypescript.com/unions-and-literals

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

1

评论 (0)

取消