四、类型缩小

假设我们有一个名为padLeft的函数:

function padLeft(padding: number | string, input: string): string {
throw new Error("尚未实现!");
}

我们来扩充一下功能:如果paddingnumber,它会将其视为我们想要添加到input的空格数;如果paddingstring,它只在input上做padding 。让我们尝试实现:

function padLeft(padding: number | string, input: string) {
return new Array(padding + 1).join(" ") + input;
}

image-20211114161343249

呃-哦,在padding + 1处我们遇到错误。TypeScript 警告我们,运算符 + 不能应用于类型 string | numbernumber,这是对的。换句话说,我们没有明确检查 padding是否为 number,也没有处理它是 string 的情况,所以我们这样做:

function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return new Array(padding + 1).join(" ") + input;
}
return padding + input;
}

如果这大部分看起来像无趣的JavaScript代码,这也算是重点吧。除了我们设置的注解之外,这段TypeScript代码看起来就像JavaScript。我们的想法是,TypeScript的类型系统旨在使编写典型的JavaScript代码变得尽可能容易,而不需要弯腰去获得类型安全。

虽然看起来不多,但实际上有很多东西在这里。就像TypeScript使用静态类型分析运行时的值一样,它在JavaScript的运行时控制流构造上叠加了类型分析,如if/else、条件三元组、循环、真实性检查等,这些都会影响到这些类型。

在我们的if检查中,TypeScript看到 typeof padding ==="number",并将其理解为一种特殊形式的代码,称为类型保护。TypeScript遵循我们的程序可能采取的执行路径,以分析一个值在特定位置的最具体的可能类型。它查看这些特殊的检查(称为类型防护)和赋值,将类型细化为比声明的更具体的类型的过程被称为缩小。在许多编辑器中,我们可以观察这些类型的变化,我们甚至会在我们的例子中这样做。

TypeScript 可以理解几种不同的缩小结构。

4.1 typeof类型守卫

正如我们所见,JavaScript 支持一个typeof运算符,它可以提供有关我们在运行时拥有的值类型的非常基本的信息。TypeScript 期望它返回一组特定的字符串:

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

就像我们在 padLeft 中看到的那样,这个运算符经常出现在许多 JavaScript 库中,TypeScript 可以理解为,它缩小在不同分支中的类型。

在 TypeScript 中,检查typeof的返回值是一种类型保护。因为 TypeScript 对typeof操作进行编码,从而返回不同的值,所以它知道对 JavaScript 做了什么。例如,请注意在上面的列表中,typeof不返回 string 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 {
// 做点事
}
}
image-20211114164143361

printAll函数中,我们尝试检查strs是否为对象,来代替检查它是否为数组类型(现在可能是强调数组是 JavaScript 中的对象类型的好时机)。但事实证明,在 JavaScript 中,typeof null实际上也是"object"! 这是历史上的不幸事故之一。

有足够经验的用户可能不会感到惊讶,但并不是每个人都在 JavaScript 中遇到过这种情况;幸运的是,typescript 让我们知道,strs只缩小到string[] | null,而不仅仅是string[]

这可能是我们所谓的“真实性”检查的一个很好的过渡。

4.2 真值缩小

真值检查是我们在 JavaScript 中经常做的一件事。在 JavaScript 中,我们可以在条件、&&||if语句、布尔否定 ( !) 等中使用任何表达式。例如,if语句不希望它们的条件总是具有类型boolean

function getUsersOnlineMessage(numUsersOnline: number) {
if (numUsersOnline) {
return `现在共有 ${numUsersOnline} 人在线!`;
}
return "现在没有人在线. :(";
}

在 JavaScript 中,像这样的if条件语句,首先将它们的条件“强制”转化为boolean 以使其有意义,然后根据结果是true还是false来选择它们的分支。像这面这些值:

  • 0
  • NaN
  • "" (空字符串)
  • 0nbigint零的版本)
  • null
  • undefined

以上所有值强制都转换为false,其他值被强制转化为true。你始终可以在Boolean函数中运行值获得boolean,或使用较短的双布尔否定将值强制转换为boolean。(后者的优点是 TypeScript 推断出一个狭窄的文字布尔类型true,而将第一个推断为 boolean类型。)

// 这两个结果都返回 true
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true, value: true

利用这种行为是相当流行的,尤其是在防范诸如nullundefined之类的值时。例如,让我们尝试将它用于我们的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 通常可以帮助你及早发现错误,但是如果你选择对某个值不做任何处理,那么它可以做的就只有这么多,而不会考虑过多的逻辑方面的问题。如果需要,你可以确保使用 linter(程序规范性) 处理此类情况。

关于通过真实性缩小范围的最后一点,是通过布尔否定!把逻辑从否定分支中过滤掉。

function multiplyAll(
values: number[] | undefined,
factor: number,
): number[] | undefined {
if (!values) {
return values;
} else {
return values.map((x) => x * factor);
}
}

4.3 等值缩小

typescript 也使用分支语句做===!====,和!=等值检查,来实现类型缩小。例如:

function example(x: string | number, y: string | boolean) {
if (x === y) {
// 现在可以在x,y上调用字符串类型的方法了
x.toUpperCase();
y.toLowerCase();
} else {
console.log(x);
console.log(y);
}
}

当我们在上面的示例中检查xy是否相等时,TypeScript 知道它们的类型也必须相等。由于stringxy都可以采用的唯一常见类型,因此TypeScript 知道xy如果都是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);
}
}
}

JavaScript 更宽松的相等性检查==!=,也能被正确缩小。如果你不熟悉,如何检查某个变量是否== null,因为有时不仅要检查它是否是特定的值null,还要检查它是否可能是undefined。这同样适用于== undefined:它检查一个值是否为nullundefined。现在你只需要这个 ==!=就可以搞定了。

interface Container {
value: number | null | undefined;
}

function multiplyValue(container: Container, factor: number) {
// 从类型中排除了undefined 和 null
if (container.value != null) {
console.log(container.value);
// 现在我们可以安全地乘以“container.value”了
container.value *= factor;
}
}
image-20211115154532180

前三个打印是通过的,第四个有问题了。

4.4 in操作符缩小

JavaScript 有一个运算符,用于确定对象是否具有某个名称的属性:in运算符。TypeScript 考虑到了这一点,以此来缩小潜在类型的范围。

例如,使用代码:"value" in x。这里的 "value"是字符串文字,x是联合类型。值为“true”的分支缩小,需要x具有可选或必需属性的类型的值;值为 “false” 的分支缩小,需要具有可选或缺失属性的类型的值。

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: Fish | Human
animal;
} else {
// animal: Bird | Human
animal;
}
}

4.5 instanceof操作符缩小

JavaScript 有一个运算符来 instanceof 检查一个值是否是另一个值的“实例”。更具体地,在JavaScript中x instanceof Foo 检查x原型链是否含有Foo.prototype。虽然我们不会在这里深入探讨,当我们进入 类(class)学习时,你会看到更多这样的内容,它们大多数可以使用new关键字实例化。 正如你可能已经猜到的那样,instanceof也是一个类型保护,TypeScript 在由instanceof保护的分支中实现缩小。

function logValue(x: Date | string) {
if (x instanceof Date) {
console.log(x.toUTCString());
} else {
console.log(x.toUpperCase());
}
}

logValue(new Date()); // Mon, 15 Nov 2021 22:34:37 GMT
logValue("hello ts"); // HELLO TS

4.6 分配缩小

正如我们之前提到的,当我们为任何变量赋值时,TypeScript 会查看赋值的右侧并适当缩小左侧。

// let x: string | number
let x = Math.random() < 0.5 ? 10 : "hello world!";

x = 1;
// let x: number
console.log(x);

x = "goodbye!";
// let x: string
console.log(x);

请注意,这些分配中的每一个都是有效的。即使在我们第一次赋值后观察到的类型x更改为 number,我们仍然可以将string赋值给x。这是因为声明类型x-该类型x开始是string | number

如果我们分配了一个booleanx,我们就会看到一个错误,因为它不是声明类型的一部分。

let x = Math.random() < 0.5 ? 10 : "hello world!";

// let x: string | number
x = 1;

// let x: number
console.log(x);

// 出错了!
x = true;

// let x: string | number
console.log(x);
image-20211116065026159

4.7 控制流分析

到目前为止,我们已经通过一些基本示例来说明 TypeScript 如何在特定分支中缩小范围。但是除了从每个变量中走出来,并在ifwhile、条件等中寻找类型保护之外,还有更多的事情要做。例如:

function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return new Array(padding + 1).join(" ") + input;
}
return padding + input;
}

padLeft从其第一个 if块中返回。TypeScript 能够分析这段代码,并看到在 padding 是数字的情况下,主体的其余部分(return padding + input;)是不可达的。因此,它能够将数字从 padding 的类型中移除(从字符串|数字缩小到字符串),用于该函数的其余部分。

这种基于可达性的代码分析被称为控制流分析,TypeScript使用这种流分析来缩小类型,因为它遇到了类型守卫和赋值。当一个变量被分析时,控制流可以一次又一次地分裂和重新合并,该变量可以被观察到在每个点上有不同的类型。

function example() {
let x: string | number | boolean;

x = Math.random() < 0.5;

// let x: boolean
console.log(x);

if (Math.random() < 0.5) {
x = "hello";
// let x: string
console.log(x);
} else {
x = 100;
// let x: number
console.log(x);
}

// let x: string | number
return x;
}

let x = example();
x = "hello";
x = 100;
x = true; // error
image-20211117203742503

4.8 使用类型谓词

到目前为止,我们已经用现有的JavaScript结构来处理窄化问题,然而有时你想更直接地控制整个代码中的类型变化。

为了定义一个用户定义的类型保护,我们只需要定义一个函数,其返回类型是一个类型谓词。

type Fish = {
name: string;
swim: () => void;
};

type Bird = {
name: string;
fly: () => void;
};

function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}

在这个例子中,pet is Fish 是我们的类型谓词。谓词的形式是 parameterName is Type,其中 parameterName 必须是当前函数签名中的参数名称。

任何时候 isFish 被调用时,如果原始类型是兼容的,TypeScript将把该变量缩小到该特定类型。

function getSmallPet(): Fish | Bird {
let fish: Fish = {
name: "gold fish",
swim: () => {},
};

let bird: Bird = {
name: "sparrow",
fly: () => {},
};

return true ? bird : fish;
}

// 这里 pet 的 swim 和 fly 都可以访问了
let pet = getSmallPet();
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}

注意,TypeScript不仅知道 petif 分支中是一条鱼;它还知道在 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 underWatch3: Fish[] = zoo.filter((pet): pet is Fish => {
if (pet.name === "frog") {
return false;
}
return isFish(pet);
});

4.9 受歧视的 unions

到目前为止,我们所看的大多数例子都是围绕着用简单的类型(如stringbooleannumber)来缩小单个变量。虽然这很常见,但在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") {
// ...
}
}
image-20211118090404099

我们可以编写一个 getArea 函数,根据它处理的是圆形还是方形来应用正确的逻辑。我们首先尝试处理圆形。

function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
}
image-20211118091637002

strictNullChecks 下,这给了我们一个错误——这是很恰当的,因为 radius 可能没有被定义。但是如果我们对 kind 属性进行适当的检查呢?

function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
}
}
image-20211118093134890

嗯,TypeScript 仍然不知道该怎么做。我们遇到了一个问题,即我们对我们的值比类型检查器知道的更多。我们可以尝试使用一个非空的断言 (radius 后面的那个叹号! ) 来说明 radius 肯定存在。

function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius! ** 2;
}
}

但这感觉并不理想。我们不得不用那些非空的断言对类型检查器声明一个叹号(),以说服它相信shape.radius 是被定义的,但是如果我们开始移动代码,这些断言就容易出错。此外,在 strictNullChecks 之外,我们也可以意外地访问这些字段(因为在读取这些字段时,可选属性被认为总是存在的)。我们绝对可以做得更好。

Shape 的这种编码的问题是,类型检查器没有办法根据种类属性知道 radiussideLength 是否存在。我们需要把我们知道的东西传达给类型检查器。考虑到这一点,让我们再来定义一下Shape。

interface Circle {
kind: "circle";
radius: number;
}

interface Square {
kind: "square";
sideLength: number;
}

type Shape = Circle | Square;

在这里,我们正确地将 Shape 分成了两种类型,为 kind 属性设置了不同的值,但是 radiussideLength 在它们各自的类型中被声明为必需的属性。

让我们看看当我们试图访问 Shape 的半径时会发生什么。

function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
}
image-20211118180452972

就像我们对 Shape 的第一个定义一样,这仍然是一个错误。当半径是可选的时候,我们得到了一个错误(仅在strictNullChecks 中),因为 TypeScript 无法判断该属性是否存在。现在 Shape 是一个联合体,TypeScript 告诉我们 shape 可能是一个 Square ,而Square并没有定义半径 radius。 这两种解释都是正确的,但只有我们对 Shape 的新编码仍然在 strictNullChecks 之外导致错误。

但是,如果我们再次尝试检查kind属性呢?

function getArea(shape: Shape) {
if (shape.kind === "circle") {
// shape: Circle
return Math.PI * shape.radius ** 2;
}
}

这就摆脱了错误! 当 union 中的每个类型都包含一个与字面类型相同的属性时,TypeScript 认为这是一个有区别的 union ,并且可以缩小 union 的成员。

在这种情况下,kind 就是那个共同属性(这就是 Shape 的判别属性)。检查 kind 属性是否为 "circle",就可以剔除 Shape 中所有没有"circle" 类型属性的类型。这就把 Shape 的范围缩小到了Circle这个类型。

同样的检查方法也适用于 switch 语句。现在我们可以试着编写完整的 getArea,而不需要任何讨厌的叹号 非空的断言。

function getArea(shape: Shape) {
switch (shape.kind) {
// shape: Circle
case "circle":
return Math.PI * shape.radius ** 2;

// shape: Square
case "square":
return shape.sideLength ** 2;
}
}

这里最重要的是 Shape 的编码。向 TypeScript 传达正确的信息是至关重要的,这个信息就是 CircleSquare实际上是具有特定种类字段的两个独立类型。这样做让我们写出类型安全的TypeScript代码,看起来与我们本来要写的JavaScript没有区别。从那里,类型系统能够做 “正确 “的事情,并找出我们 switch语句的每个分支中的类型。

作为一个旁观者,试着玩一玩上面的例子,去掉一些返回关键词。你会发现,类型检查可以帮助避免在switch语句中不小心落入不同子句的bug。

辨证的联合体不仅仅适用于谈论圆形和方形。它们适合于在JavaScript中表示任何类型的消息传递方案,比如在网络上发送消息(client/server通信),或者在状态管理框架中编码突变。

4.10 never类型与穷尽性检查

在缩小范围时,你可以将一个联合体的选项减少到你已经删除了所有的可能性并且什么都不剩的程度。在这些情况下,TypeScript将使用一个never类型来代表一个不应该存在的状态。

never 类型可以分配给每个类型;但是,没有任何类型可以分配给never(除了never本身)。这意味着你可以使用缩小并依靠never的出现在 switch 语句中做详尽的检查。

例如,在我们的getArea函数中添加一个默认值,试图将形状分配给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;
return _exhaustiveCheck;
}
}
image-20211118183410201

特别声明: 本文转自 古艺散人老师 ,如有需要可前往原文预览查看。