类型收窄
想象我们有一个名为 padLeft 的函数。
function padLeft(padding: number | string, input: string): string {
throw new Error("Not implemented yet!");
}Try如果 padding 是一个 number,它会将其视为我们想要前置到 input 的空格数。 如果 padding 是一个 string,它应该直接将 padding 前置到 input。 让我们尝试实现当 padLeft 传入 number 作为 padding 时的逻辑。
function padLeft(padding: number | string, input: string): string {
return " ".repeat(padding) + input;}Try哎呀,我们在 padding 上得到了一个错误。 TypeScript 警告我们,我们将一个类型为 number | string 的值传递给了 repeat 函数,而 repeat 只接受 number,这是正确的。 换句话说,我们还没有显式检查 padding 是否首先是 number,也没有处理它是 string 的情况,所以让我们来这样做。
function padLeft(padding: number | string, input: string): string {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}Try如果这看起来基本上就是普通的 JavaScript 代码,那正是重点所在。 除了我们添加的注解之外,这段 TypeScript 代码看起来就像 JavaScript。 其理念是 TypeScript 的类型系统旨在让编写典型的 JavaScript 代码尽可能容易,而无需为了类型安全而费尽周折。
虽然看起来可能不多,但幕后其实发生了很多事情。 就像 TypeScript 如何使用静态类型分析运行时值一样,它在 JavaScript 的运行时控制流结构(如 if/else、条件三元运算符、循环、真值检查等)上叠加了类型分析,所有这些都可能影响那些类型。
在我们的 if 检查中,TypeScript 看到 typeof padding === "number" 并将其理解为一种特殊形式的代码,称为 类型守卫。 TypeScript 遵循程序可能执行的路径,以分析在给定位置可能的最具体类型。 它查看这些特殊检查(称为 类型守卫)和赋值,将类型细化为比声明的更具体类型的过程称为 收窄。 在许多编辑器中,我们可以在类型变化时观察到它们,我们甚至会在示例中这样做。
function padLeft(padding: number | string, input: string): string {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}TryTypeScript 理解几种不同的用于收窄的结构。
typeof 类型守卫
正如我们所见,JavaScript 支持 typeof 操作符,它可以提供关于我们在运行时拥有的值类型的非常基本信息。 TypeScript 期望它返回一组特定的字符串:
"string""number""bigint""boolean""symbol""undefined""object""function"
就像我们在 padLeft 中看到的那样,这个操作符在许多 JavaScript 库中经常出现,TypeScript 可以理解它,从而在不同的分支中收窄类型。
在 TypeScript 中,对照 typeof 返回的值进行检查是一种类型守卫。 因为 TypeScript 编码了 typeof 对不同值的操作方式,所以它知道 JavaScript 中的一些 quirks。 例如,注意在上面的列表中,typeof 不会返回字符串 null。 看看下面的例子:
function printAll(strs: string | string[] | null) {
if (typeof strs === "object") {
for (const s of strs) { console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
} else {
// do nothing
}
}Try在 printAll 函数中,我们尝试检查 strs 是否为对象,以判断它是否为数组类型(现在可能是重申数组在 JavaScript 中是对象类型的好时机)。 但事实证明,在 JavaScript 中,typeof null 实际上是 "object"! 这是历史遗留的遗憾之一。
经验丰富的用户可能不会感到惊讶,但并不是每个人都遇到过这种情况;幸运的是,TypeScript 让我们知道 strs 被收窄为 string[] | null,而不仅仅是 string[]。
这可能是我们接下来要讲的“真值”收窄的一个很好的过渡。
真值收窄
真值(Truthiness)可能不是你在字典里能找到的词,但你在 JavaScript 中肯定会经常听到它。
在 JavaScript 中,我们可以在条件语句、&&、||、if 语句、布尔取反(!)等中使用任何表达式。 例如,if 语句并不要求它们的条件总是具有 boolean 类型。
function getUsersOnlineMessage(numUsersOnline: number) {
if (numUsersOnline) {
return `There are ${numUsersOnline} online now!`;
}
return "Nobody's here. :(";
}Try在 JavaScript 中,像 if 这样的结构首先将其条件“强制转换”为 boolean 以理解它们,然后根据结果是 true 还是 false 来选择分支。 像
0NaN""(空字符串)0n(bigint版本的零)nullundefined
这些值都会被强制转换为 false,而其他值则被强制转换为 true。 你总是可以通过 Boolean 函数或使用更短的双重布尔取反来将值强制转换为 boolean。(后者有一个优点:TypeScript 推断出一个狭窄的字面量布尔类型 true,而推断前者为类型 boolean。)
利用这种行为相当流行,特别是用于防范像 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);
}
}Try你会注意到,通过检查 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);
}
}
}Try我们将整个函数体包装在一个真值检查中,但这有一个微妙的缺点:我们可能不再正确处理空字符串的情况。
TypeScript 在这里对我们没有任何伤害,但如果你不太熟悉 JavaScript,这种行为值得注意。 TypeScript 通常可以帮助你尽早捕获错误,但如果你选择对值不做任何处理,它所能做的也就有限了,无法过于严格。 如果你愿意,你可以使用 linter 来确保处理这些情况。
关于通过真值进行收窄的最后一句话:使用 ! 的布尔取反会从被取反的分支中过滤掉。
function multiplyAll(
values: number[] | undefined,
factor: number
): number[] | undefined {
if (!values) {
return values;
} else {
return values.map((x) => x * factor);
}
}Try相等性收窄
TypeScript 也使用 switch 语句和相等性检查(如 ===、!==、== 和 !=)来收窄类型。 例如:
function example(x: string | number, y: string | boolean) {
if (x === y) {
// 现在我们可以对 'x' 或 'y' 调用任何 'string' 方法。
x.toUpperCase();
y.toLowerCase();
} else {
console.log(x);
console.log(y);
}
}Try当我们在上面的例子中检查 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) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
}
}
}TryJavaScript 更宽松的相等性检查 == 和 != 也会被正确收窄。 如果你不熟悉,检查某个东西 == 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);
// 现在我们可以安全地乘以 'container.value'。
container.value *= factor;
}
}Tryin 操作符收窄
JavaScript 有一个操作符,用于确定对象或其原型链是否具有某个名称的属性:in 操作符。 TypeScript 将其视为一种收窄潜在类型的方式。
例如,对于代码:"value" in x,其中 "value" 是一个字符串字面量,x 是一个联合类型。 “true” 分支收窄 x 的类型,使其具有可选或必需的属性 value,而 “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();
}Try重申一下,可选属性在收窄时两侧都会存在。例如,人类既可以游泳也可以飞行(使用合适的装备),因此应该出现在 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;
} else {
animal;
}
}Tryinstanceof 收窄
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());
} else {
console.log(x.toUpperCase());
}
}Try赋值
正如我们之前提到的,当我们给任何变量赋值时,TypeScript 会查看赋值的右侧,并相应地收窄左侧。
let x = Math.random() < 0.5 ? 10 : "hello world!";
x = 1;
console.log(x);
x = "goodbye!";
console.log(x);
Try请注意,这些赋值中的每一个都是有效的。 尽管在第一次赋值后,x 的观察类型变为 number,但我们仍然能够将 string 赋值给 x。 这是因为 x 的声明类型——x 最初具有的类型——是 string | number,并且可赋值性总是根据声明类型进行检查。
如果我们给 x 赋一个 boolean,我们会看到一个错误,因为它不是声明类型的一部分。
let x = Math.random() < 0.5 ? 10 : "hello world!";
x = 1;
console.log(x);
x = true;
console.log(x);
Try控制流分析
到目前为止,我们已经通过一些基本示例了解了 TypeScript 如何在特定分支内进行收窄。 但发生的事情不仅仅是遍历每个变量并在 if、while、条件等中查找类型守卫。 例如
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}TrypadLeft 从其第一个 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);
if (Math.random() < 0.5) {
x = "hello";
console.log(x);
} else {
x = 100;
console.log(x);
}
return x;
}Try使用类型谓词
到目前为止,我们一直使用现有的 JavaScript 结构来处理收窄,但有时你想要更直接地控制类型在代码中如何变化。
要定义用户定义的类型守卫,我们只需要定义一个返回类型为类型谓词的函数:
pet is Fish 是这个例子中的类型谓词。 谓词的形式是 parameterName is Type,其中 parameterName 必须是当前函数签名中某个参数的名称。
每当使用某个变量调用 isFish 时,如果原始类型兼容,TypeScript 就会将该变量收窄为那个特定类型。
// 现在调用 'swim' 和 'fly' 都是可以的。
let pet = getSmallPet();
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}Try请注意,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);
});Try此外,类可以使用 this is Type 来收窄其类型。
断言函数
类型也可以通过断言函数进行收窄。
可辨识联合
到目前为止,我们看过的大多数示例都集中在使用像 string、boolean 和 number 这样的简单类型来收窄单个变量。 虽然这很常见,但在 JavaScript 中,我们大多数时候处理的是稍微复杂一些的结构。
为了说明动机,让我们想象我们试图对圆形和正方形等形状进行编码。 圆跟踪其半径,正方形跟踪其边长。 我们将使用一个名为 kind 的字段来告诉我们正在处理哪种形状。 这是定义 Shape 的第一次尝试。
注意我们使用了字符串字面量类型的联合:"circle" 和 "square" 来告诉我们分别将形状视为圆形还是正方形。 通过使用 "circle" | "square" 而不是 string,我们可以避免拼写错误的问题。
我们可以编写一个 getArea 函数,根据处理的是圆形还是正方形来应用正确的逻辑。 我们先尝试处理圆形。
在启用 strictNullChecks 的情况下,这给了我们一个错误——这是合理的,因为 radius 可能未定义。 但如果我们对 kind 属性进行适当的检查呢?
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2; }
}Try嗯,TypeScript 仍然不知道在这里该怎么做。 我们到了一个我们比类型检查器更了解值的情况。 我们可以尝试使用非空断言(shape.radius 后面的 !)来表示 radius 肯定存在。
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius! ** 2;
}
}Try但这并不理想。 我们不得不用那些非空断言(!)对类型检查器大喊大叫,以说服它 shape.radius 已定义,但如果我们开始移动代码,这些断言就容易出错。 此外,在 strictNullChecks 之外,我们仍然可能意外地访问这些字段中的任何一个(因为可选属性在读取时被假定为始终存在)。 我们肯定可以做得更好。
这种 Shape 编码的问题在于,类型检查器无法根据 kind 属性知道 radius 或 sideLength 是否存在。 我们需要将我们知道的传达给类型检查器。 考虑到这一点,让我们再次尝试定义 Shape。
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;Try在这里,我们将 Shape 正确地分成了两种类型,它们具有不同的 kind 属性值,但 radius 和 sideLength 在各自的类型中被声明为必需属性。
让我们看看当我们尝试访问 Shape 的 radius 时会发生什么。
与我们第一次定义 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;
}
}Try错误消失了! 当联合中的每个类型都包含一个具有字面量类型的公共属性时,TypeScript 认为这是一个可辨识联合,并且可以收窄联合的成员。
在这种情况下,kind 就是那个公共属性(被认为是 Shape 的判别属性)。 检查 kind 属性是否为 "circle" 会移除 Shape 中所有没有 kind 属性且类型为 "circle" 的类型。 这将 shape 收窄为类型 Circle。
同样的检查也适用于 switch 语句。 现在我们可以尝试编写完整的 getArea,而无需任何讨厌的 ! 非空断言。
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
}
}Try这里的关键是 Shape 的编码。 将正确的信息传达给 TypeScript——即 Circle 和 Square 实际上是具有特定 kind 字段的两个独立类型——至关重要。 这样做可以让我们编写类型安全的 TypeScript 代码,看起来与我们原本会编写的 JavaScript 别无二致。 然后,类型系统就能够做出“正确”的事情,并找出我们 switch 语句每个分支中的类型。
顺便说一句,试着玩一下上面的例子,删除一些
return关键字。 你会看到类型检查可以帮助避免在switch语句中意外落入不同子句时的错误。
可辨识联合不仅适用于讨论圆形和正方形。 它们适用于表示 JavaScript 中的任何类型的消息传递方案,例如通过网络发送消息(客户端/服务器通信),或在状态管理框架中对变更进行编码。
never 类型
在进行收窄时,你可以将联合的选项减少到移除所有可能性并且什么也没有剩下的程度。 在这些情况下,TypeScript 将使用 never 类型来表示不应该存在的状态。
穷尽性检查
never 类型可以赋值给任何类型;然而,没有类型可以赋值给 never(除了 never 本身)。这意味着你可以使用收窄并依靠 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;
}
}Try向 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; return _exhaustiveCheck;
}
}Try