免责声明: 本文为翻译自 Typescript 官方手册内容,非原创。版权归原作者所有。
原文来源: Narrowing
翻译者注: 本人在翻译过程中已尽最大努力确保内容的准确性和完整性,但翻译工作难免有疏漏。如读者在阅读中发现任何问题,欢迎提出建议或直接查阅原文以获取最准确的信息。
假设我们有一个名为 padLeft
的函数
function padLeft(padding: number | string, input: string): string {
throw new Error("尚未实现!");
}
如果 padding
是 number
类型,它表示我们想要在 input
前面添加的空格数。如果 padding
是 string
,表示要在 input
前面添加 padding
。让我们尝试实现当 padLeft
被传递一个用于 padding
的 number
时的逻辑。
function padLeft(padding: number | string, input: string) {
return " ".repeat(padding) + input;
// 类型 “string | number” 的参数不能赋给类型 “number” 的参数。
// 不能将类型 “string” 分配给类型 “number”。
}
啊哦,我们在 padding
上遇到了一个错误。TypeScript 警告我们,在 repeat
函数中传递的值是 number | string
类型,而该函数只接受 number
类型,这是正确的。换句话说,我们没有明确检查 padding
是否为 number
,也没有处理它为 string
的情况,所以让我们正好做这个。
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
如果这看起来像是无趣的 JavaScript 代码,那么这就是我们想要表达的观点。除了我们放置的类型注释之外,这段 TypeScript 代码看起来就像 JavaScript 一样。其想法是,TypeScript 的类型系统旨在使编写典型的 JavaScript 代码变得尽可能容易,而无需过分费力就能获得类型安全。
虽然看起来可能不怎么样,但实际上在幕后发生了很多事情。就像 TypeScript 使用静态类型来分析运行时值一样,它在 JavaScript 的运行时控制流构造上叠加了类型分析,比如 if/else
、条件三元运算符、循环、真值检查等,这些都可以影响那些类型。
在我们的 if
检查中,TypeScript 看到 typeof padding === "number"
并将其理解为一种特殊形式的代码,称为类型保护。TypeScript 跟踪程序可能采取的执行路径,以分析给定位置上一个值的最具体可能类型。它查看这些特殊检查(称为类型保护)和赋值,并且将类型细化为比声明更具体的类型的过程被称为收窄(narrowing)。在许多编辑器中,我们可以观察到这些变化的类型,并且我们甚至会在示例中进行观察。
TypeScript 可以理解几种用于类型收窄(narrowing)的不同的结构。
typeof
类型保护
正如我们所见,JavaScript 支持 typeof
运算符,它可以在运行时提供关于值类型的基本信息。TypeScript 期望此运算符返回一组特定的字符串:
"string"
"number"
"bigint"
"boolean"
"symbol"
"undefined"
"object"
"function"
就像我们在 padLeft
中看到的,这个运算符在许多 JavaScript 库中经常出现,并且 TypeScript 可以理解它,以在不同分支中收窄类型。
在 TypeScript 中,对 typeof
返回的值进行检查是一种类型保护。因为 TypeScript 编码了 typeof
如何对不同的值进行操作,它知道 JavaScript 中一些奇怪的行为。例如,请注意在上面的列表中,typeof
并没有返回字符串 null
。请看下面的示例:
function printAll(strs: string | string[] | null) {
if (typeof strs === "object") {
for (const s of strs) {
// “strs”可能为 “null”。
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
} else {
// do nothing
}
}
在 printAll
函数中,我们尝试检查 strs
是否是一个对象,以确定它是否是数组类型(现在可能是强化数组是 JavaScript 中的对象类型的好时机)。但事实证明,在 JavaScript 中,typeof null
实际上是 "object"
!这是历史上不幸的意外之一。
有足够经验的用户可能不会感到惊讶,但并非每个人都在 JavaScript 中遇到过这种情况;幸运的是,TypeScript 让我们知道 strs
只被收窄为 string[] | null
而不仅仅是 string[]
。
这可能是一个很好的过渡,引入我们所说的“真值性”检查。
真值性收窄 (Truthiness narrowing)
你可能在词典里找不到“真值性”这个词,但在 JavaScript 中你绝对会听到它。
在 JavaScript 中,我们可以在条件语句、&&
、||
、if
语句、布尔取反(!
)等地方使用任何表达式。举个例子,if
语句并不要求它的条件一定要是 boolean
类型。
function getUsersOnlineMessage(numUsersOnline: number) {
if (numUsersOnline) {
return `There are ${numUsersOnline} online now!`;
}
return "Nobody's here. :(";
}
在 JavaScript 中,像 if
这样的结构首先会将它们的条件“强制转换”为 boolean
以便理解它们,然后根据结果是 true
还是 false
来选择它们的分支。像这样的值:
0
NaN
""
(空字符串)0n
(0 的bigint
版本)null
undefined
都会被强制转换为 false
,而其他值会被强制转换为 true
。你可以通过使用 Boolean
函数,或者使用更简短的双重布尔取反来将值强制转换为 boolean
值。(后者的优势在于 TypeScript 会推断出一个精确的字面量布尔类型 true
,而第一个则会被推断为 boolean
类型。)
// 这两个都会得到 "true"
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true, value: true
利用这种行为是相当流行的,尤其是用于防范像 null
或 undefined
这样的值。作为一个例子,让我们尝试将其用于我们的 printAll
函数。
function printAll(strs: string | string[] | null) {
if (strs && typeof strs === "object") {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
}
}
你会注意到,我们通过检查 strs
是否为真值来消除了上面的错误。这至少可以防止我们在运行代码时遇到可怕的错误,比如:
TypeError: null is not iterable
请记住,对原始数据进行真值性检查往往容易出错。举个例子,考虑另一种尝试编写 printAll
的方法。
function printAll(strs: string | string[] | null) {
// !!!!!!!!!!!!!!!!
// 不要这样做!
// 继续阅读
// !!!!!!!!!!!!!!!!
if (strs) {
if (typeof strs === "object") {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
}
}
}
我们将函数的整个主体包裹在一个真值检查中,但这有一个微妙的缺点:我们可能不再正确处理空字符串情况。
在这里,TypeScript 并没有给我们带来任何困扰,但如果你对 JavaScript 不太熟悉,那么这种行为是值得注意的。TypeScript 通常可以帮助你在早期捕捉到 bug,但如果你选择不对一个值做任何处理,它能做的事情就有限了,而且也不能过分规定。如果你愿意,你可以确保通过使用一个代码检查工具(linter)来处理像这样的情况。
关于通过真值性进行收窄的最后一点是,布尔否定与 !
过滤掉了被否定的分支。
function multiplyAll(
values: number[] | undefined,
factor: number
): number[] | undefined {
if (!values) {
return values;
} else {
return values.map((x) => x * factor);
}
}
等值性收窄 (Equality narrowing)
TypeScript 也使用 switch
语句和等式检查,如 ===
!==
==
和 !=
来收窄类型。例如:
function example(x: string | number, y: string | boolean) {
if (x === y) {
// 我们现在可以在 'x' 或 'y' 上调用任何 'string' 方法
x.toUpperCase();
// (method) String.toUpperCase(): string
y.toLowerCase();
// (method) String.toLowerCase(): string
} else {
console.log(x);
// (parameter) x: string | number
console.log(y);
// (parameter) y: string | boolean
}
}
当我们在上述示例中检查 x
和 y
是否相等时,TypeScript 知道它们的类型也必须相等。由于 string
是 x
和 y
都可能取得的唯一公共类型,所以 TypeScript 知道在第一个分支中,x
和 y
必须是 string
。
对特定的字面量值进行检查(而不是变量)也是可行的。在我们关于真值性收窄的部分,我们编写了一个 printAll
函数,这个函数容易出错,因为它没有正确处理空字符串。相反,我们可以进行特定检查以阻止 null
,并且 TypeScript 仍然正确地从 strs
类型中移除了 null
。
function printAll(strs: string | string[] | null) {
if (strs !== null) {
if (typeof strs === "object") {
for (const s of strs) {
// (parameter) strs: string[]
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
// (parameter) strs: string
}
}
}
JavaScript 的宽松等式检查,如 ==
和 !=
也得到了正确的收窄。如果你不熟悉,检查某物是否 == null
实际上不仅要检查它是否为 null
值 - 还要检查它是否可能为 undefined
。同样适用于 == undefined
:它检查一个值是否为 null
或 undefined
。
interface Container {
value: number | null | undefined;
}
function multiplyValue(container: Container, factor: number) {
// 从类型中移除'null'和'undefined'
if (container.value != null) {
console.log(container.value);
// (property) Container.value: number
// 现在我们可以安全地乘以'container.value'
container.value *= factor;
}
}
in
操作符收窄 (The in operator narrowing)
JavaScript 有一个运算符用于确定对象或其原型链是否具有某个名称的属性:即 in
运算符。TypeScript 将此视为一种收窄潜在类型范围的方式。
例如,使用代码:"value" in x
。其中 "value"
是一个字符串字面量,x
是联合类型。“true” 分支收窄了具有可选或必需属性 value
的 x
的类型,而 “false” 分支收窄到具有可选或缺失属性 value
的类型。
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
return animal.swim();
}
return animal.fly();
}
再次强调,可选属性将存在于收窄范围的双方。例如,人类既可以游泳也可以飞行(有了正确的设备),因此应该在 in
检查的两个分支都出现:
type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };
function move(animal: Fish | Bird | Human) {
if ("swim" in animal) {
animal;
// (parameter) animal: Fish | Human
} else {
animal;
// (parameter) animal: Bird | Human
}
}
instanceof
收窄 (instanceof narrowing)
JavaScript 有一个运算符用于检查一个值是否是另一个值的“实例”。更具体地说,在 JavaScript 中,x instanceof Foo
会检查 x
的原型链是否包含 Foo.prototype
。虽然我们在这里不会深入探讨,但当我们进入类时,你将看到更多关于此的内容,它们对于可以用 new
构造的大多数值仍然很有用。你可能已经猜到了,instanceof
也是一种类型保护,并且 TypeScript 在由 instanceof
保护的分支中进行收窄。
function logValue(x: Date | string) {
if (x instanceof Date) {
console.log(x.toUTCString());
// (parameter) x: Date
} else {
console.log(x.toUpperCase());
// (parameter) x: string
}
}
赋值 (Assignments)
正如我们之前提到的,当我们为任何变量赋值时,TypeScript会查看赋值的右侧,并适当地收窄左侧类型。
let x = Math.random() < 0.5 ? 10 : "hello world!";
// let x: string | number
x = 1;
console.log(x);
// let x: number
x = "goodbye!";
console.log(x);
// let x: string
请注意,这些赋值都是有效的。尽管在我们第一次赋值后,观察到 x
类型变成了 number
,但我们仍然能够将一个 string
赋给 x
。这是因为 x
的声明类型 - x
开始时的类型 - 是 string | number
,并且可分配性始终根据声明类型进行检查。
如果我们把布尔值赋给 x
,由于它不属于声明类型的一部分,所以会看到错误。
let x = Math.random() < 0.5 ? 10 : "hello world!";
// let x: string | number
x = 1;
console.log(x);
// let x: number
x = true;
// Type 'boolean' is not assignable to type 'string | number'.
console.log(x);
// let x: string | number
控制流分析 (Control flow analysis)
直到现在,我们已经通过一些基本的示例来了解 TypeScript 如何在特定分支中进行收窄类型。但是,除了从每个变量开始向上查找并在 if
、while
、条件等中寻找类型保护之外,还有更多的事情正在发生。例如
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
padLeft
在其第一个 if
块内返回。TypeScript 能够分析这段代码,并看到在 padding
是 number
的情况下,函数体的其余部分(return padding + input;
)是无法触达的。因此,它能够从 padding
的类型中移除 number
(从 string | number
收窄为 string
)。
基于可达性对代码进行分析被称为控制流分析,TypeScript 使用这种流程分析来收窄它遇到的类型保护和赋值时的类型。当变量被分析时,控制流可以反复地拆开和重新合并,并且可以观察到该变量在每个点上都有不同的类型。
function example() {
let x: string | number | boolean;
x = Math.random() < 0.5;
console.log(x);
// let x: boolean
if (Math.random() < 0.5) {
x = "hello";
console.log(x);
// let x: string
} else {
x = 100;
console.log(x);
// let x: number
}
return x;
// let x: string | number
}
使用类型断言 (Using type predicates)
我们迄今为止一直在使用现有的 JavaScript 构造来处理类型收窄,但是有时候你可能希望对代码中类型的变化有更直接的控制。
要定义一个用户自定义类型保护,我们只需要定义一个其返回类型是类型断言的函数。
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
在这个例子中,pet is Fish
是我们的类型断言。断言采用 parameterName is Type
的形式,其中 parameterName
必须是当前函数签名中的一个参数名。
无论何时,只要用某个变量调用 isFish
,如果原始类型兼容,TypeScript 就会将该变量收窄到特定的类型。
// 现在调用 'swim' 和 'fly' 都没问题了。
let pet = getSmallPet();
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}
注意,TypeScript 不仅知道在 if
分支中 pet
是 Fish
;它还知道在 else
分支中,你没有 Fish
,所以你必须有 Bird
。
你可以使用类型保护 isFish
来过滤一个由 Fish | Bird
组成的数组,并得到一个由 Fish
组成的数组:
const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// 或者, 等同地
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
// 对于更复杂的示例,断言可能需要重复
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
if (pet.name === "sharkey") return false;
return isFish(pet);
});
此外,类可以 使用 this is Type 来收窄它们的类型。
断言函数 (Assertion functions)
类型也可以通过使用断言函数来收窄。
可辨识联合类型 (Discriminated unions)
我们迄今为止看到的大多数示例都集中在收窄像 string
、boolean
和 number
这样的简单类型的单个变量。虽然这很常见,但在 JavaScript 中,我们大部分时间将处理稍微复杂一些的结构。
出于某种动机,让我们想象一下我们正在尝试对圆形和正方形这样的形状进行编码。圆形记录其半径,而正方形记录其边长。我们将使用一个名为 kind
的字段来标识我们正在处理哪种形状。以下是定义 Shape
的初步尝试。
interface Shape {
kind: "circle" | "square";
radius?: number;
sideLength?: number;
}
注意,我们使用了字符串字面量类型的联合类型:"circle"
和 "square"
告诉我们应该将形状视为圆形还是方形。通过使用 "circle" | "square"
而不是 string
,我们可以避免拼写错误的问题。
function handleShape(shape: Shape) {
// oops!
if (shape.kind === "rect") {
// This comparison appears to be unintentional
// because the types '"circle" | "square"' and '"rect"' have no overlap.
// ...
}
}
我们可以编写一个 getArea
函数,根据处理的是圆形还是正方形来应用正确的逻辑。我们首先尝试处理圆形。
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
// 'shape.radius' is possibly 'undefined'.
}
在 strictNullChecks 下,这会给我们一个错误 - 这是合适的,因为 radius
可能没有被定义。但是,如果我们对 kind
属性进行了适当的检查呢?
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
// 'shape.radius' is possibly 'undefined'.
}
}
呃... TypeScript 在这里仍然不知道该怎么做。我们已经达到了一个点,我们对我们的值的了解比类型检查器更多。我们可以尝试使用非空断言(在 shape.radius
后面加一个 !
)来表明 radius
肯定存在。
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius! ** 2;
}
}
但是这种方式并不理想。我们不得不在类型检查器中使用非空断言 (!
) 来强制它相信 shape.radius
是定义过的,但是如果我们开始移动代码,这些断言很容易出错。此外,在 strictNullChecks 之外,我们仍然可以无意中访问任何这些字段(因为可选属性在读取时被默认为始终存在)。我们肯定可以做得更好。
这种 Shape
的编码方式的问题在于,类型检查器无法根据 kind
属性判断 radius
或 sideLength
是否存在。我们需要将我们所了解的信息传达给类型检查器。考虑到这一点,让我们再次尝试定义 Shape
。
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
在这里,我们已经将 Shape
适当地分离成两种类型,并为 kind
属性赋予了不同的值,但在各自的类型中,radius
和 sideLength
被声明为必需的属性。
让我们看一下,当我们试图访问一个 Shape
的 radius
时会发生什么。
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
// Property 'radius' does not exist on type 'Shape'.
// Property 'radius' does not exist on type 'Square'.
}
就像我们首次定义 Shape
一样,这仍然是一个错误。当 radius
是可选的,我们得到了一个错误(启用了 strictNullChecks),因为 TypeScript 无法确定该属性是否存在。现在 Shape
是一个联合类型,TypeScript 告诉我们 shape
可能是一个 Square
,而 Square
上并没有定义 radius
!两种解释都是正确的,但只有 Shape
的联合编码方式会导致无论如何配置 strictNullChecks 都会引发错误。
那么,如果我们再次尝试检查 kind
属性会怎么样呢?
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
// (parameter) shape: Circle
}
}
这消除了错误!当联合类型中的每个类型都包含一个具有字面类型的共同属性时,TypeScript 会将其视为可辨识的联合,并能够过滤出联合成员。
在这个示例下,kind
是共有属性(也被认为是 Shape
的区分属性)。检查 kind
属性是否为 "circle"
会排除 Shape
中所有没有类型为 "circle"
的 kind
属性的类型。这使得 shape
被收窄到 Circle
类型。
同样的检查也适用于 switch
语句。现在我们可以尝试编写完整的 getArea
函数,而无需烦人的非空断言(!
)。
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
// (parameter) shape: Circle
case "square":
return shape.sideLength ** 2;
// (parameter) shape: Square
}
}
这里面重要的是对 Shape
的编码。向 TypeScript 传递正确的信息 - Circle
和 Square
实际上是两种具有特定 kind
字段的不同类型 - 是至关重要的。做到这一点,我们就可以编写看起来与我们原本会写的 JavaScript 代码无异的类型安全的 TypeScript 代码。从那里开始,类型系统能够做出"正确的"事情,找出我们 switch 语句中每个分支的类型。
顺便说一下,尝试修改上述示例并删除一些return关键字。你会看到,类型检查可以帮助避免在一个 switch 语句中不小心跨越不同子句时产生的错误。
可辨识联合类型不仅仅用于描述圆形和方形。它们非常适合在 JavaScript 中表示任何类型的消息方案,例如,在网络上发送消息(客户端/服务器通信),或者在状态管理框架中编码状态变更(mutations)。
never
类型
在类型收窄时,你可以减少联合类型的可能性,直到排除了所有可能性,没有任何选项剩下。在这些情况下,TypeScript 会使用 never
类型来表示一个不应该存在的状态。
穷尽性检查 (Exhaustiveness checking)
never
类型可以被赋值给任何类型;但是,除了 never
自身之外,没有任何类型可以被赋值给 never
。这意味着你可以通过收窄(narrowing)变量类型,然后依赖 never
的出现来在 switch
语句中实行穷尽性检查。
例如,当我们处理了所有可能的情况时,在我们的 getArea
函数中添加一个 default
分支,尝试将 shape 赋值为 never
类型,这不会引发错误。
type Shape = Circle | Square;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
向 Shape
联合类型添加新成员,将会导致TypeScript错误:
interface Triangle {
kind: "triangle";
sideLength: number;
}
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
// Type 'Triangle' is not assignable to type 'never'.
return _exhaustiveCheck;
}
}