类型缩小
将类型精炼为比声明的更具体的类型的过程称为类型缩小。
示例一,想要实现一个方法,在输入内容前面添加空白符或者前缀内容。当 padding 为 number 时就在 input 前面加上 padding 数量的空格,当 padding 是 string 时,直接将 padding 内容添加在 input 前面。这里通过 typeof 去缩小联合类型。
function padLeft(padding: number | string, input: string): string {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
Typescript 中有以下几种方式去实现类型缩小。
typeof
在 TypeScript 中,检查 typeof 返回的值是一种类型保护,可能返回以下值。
- string
- number
- bigint
- boolean
- symbol
- undefined
- object
- function
TypeScript 编码了 typeof 如何对不同的值进行操作,所以它知道它在 JavaScript 中的一些怪癖。比如 JavaScript 中 typeof null 为 object,但 Typescript 中对此做了处理,如果没有开启 strictNullChecks,typeof 会将对象验证为 object | null,避免了对 null 的单独验证。
function printAll(strs: string | string[] | null) {
if (typeof strs === "object") {
for (const s of strs) {
// 开启 strictNullChecks 验证时,报错 'strs' is possibly 'null'.
// 关闭 strictNullChecks 验证时,不报错 strs 缩小为 string[] | null
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
} else {
// do nothing
}
}
真值缩小
真值缩小不是一个标准的说法,我们不太熟悉这种说法,但 Javascript 随处可见真值缩小的情况。
在 JavaScript 中,我们可以在条件、&&、||、if 语句、布尔否定 (!) 等中使用任何表达式。
function getUsersOnlineMessage(numUsersOnline: number) {
if (numUsersOnline) {
return `There are ${numUsersOnline} online now!`;
}
return "Nobody's here. :(";
}
在 JavaScript 中,像 if 这样的构造首先将它们的条件 “强制转换” 到 boolean 来理解它们,然后根据结果是 true 还是 false 来选择它们的分支。以下 6 种值全部强制转换为 false,其他值强制转换为 true。
- 0
- 0n(bigint 版本零)
- “”(空字符串)
- null
- undefined
- NaN
你始终可以通过 Boolean 函数运行值或使用较短的双布尔否定来将值强制为 boolean。(后者的优点是 TypeScript 推断出一个缩小的字面布尔类型 true,而将第一个推断为类型 boolean。)
// both of these result in 'true'
Boolean("hello"); // 开启 strictNullChecks 验证时,type: boolean, value: true
!!"world"; // 开启 strictNullChecks 验证时,type: true, value: true
结合 if,&&,对上面的例子在 typeof 基础上进一步进行真值缩小。
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 整个放在 if 中进行比较,会丢失空字符串的情况,所以不建议。
function printAll(strs: string | string[] | null) {
// !!!!!!!!!!!!!!!!
// DON'T DO THIS!
// KEEP READING
// !!!!!!!!!!!!!!!!
if (strs) {
if (typeof strs === "object") {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
}
}
}
还有其它 if 常用的比较 !。不符合的将直接过滤掉整个分支。
function multiplyAll(
values: number[] | undefined,
factor: number
): number[] | undefined {
if (!values) {
return values;
} else {
return values.map((x) => x * factor);
}
}
相等性缩小
TypeScript 还使用 switch 语句和 ===、!==、== 和 != 等相等性检查来缩小类型。
当我们在下面的示例中检查 x 和 y 是否相等时,TypeScript 知道它们的类型也必须相等。由于 string 是 x 和 y 都可以采用的唯一通用类型,因此 TypeScript 知道第一个分支中的 x 和 y 必须是 string,所以调用 string 类型的方法不会报错。这里就用了相等性缩小。
function example(x: string | number, y: string | boolean) {
if (x === y) {
// We can now call any 'string' method on 'x' or 'y'.
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
}
}
上面 typeof 示例 printAll 函数中,通过 typeof strs === “object” 的 if 分支判断,关闭 strictNullChecks 验证时会将 string[] | null 两种类型归为其中,但最好的还是区分 null 类型,除了用 if(strs && typeof strs === “object”) 这种真值缩小的方式将 null 剔除出来,还可以用相等性缩小的方式单独处理 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
}
}
}
===、!== 和 == 、 != 的区别是,== null、!= null 除了判断 null 还会判断 undefined,同样的, == null、!= null 除了判断 undefined 还会判断 null。
下面例子通过 != 去除 null 情况,但是不严格相等判断,会将 undefined 情况一起过滤掉,所以判断里面完全对齐进行 number 操作不会报错。
interface Container {
value: number | null | undefined;
}
function multiplyValue(container: Container, factor: number) {
// Remove both 'null' and 'undefined' from the type.
if (container.value != null) {
console.log(container.value); // (property) Container.value: number
// Now we can safely multiply 'container.value'.
container.value *= factor;
}
}
in 运算符缩小
JavaScript 有一个运算符来确定对象或其原型链是否具有名称属性:in 运算符。TypeScript 将这一点视为缩小潜在类型的一种方式。
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
return animal.swim();
}
return animal.fly();
}
可选属性
如果对象有可选属性满足条件,那么该对象会存在于两种情况,如果有这个属性就符合 true 情况,如果没有该属性就符合 false 情况。
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 缩小
JavaScript 有一个运算符用于检查一个值是否是另一个值的 “instance”。更具体地说,在 JavaScript 中,x instanceof Foo 检查 x 的原型链是否包含 Foo.prototype。
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
}
}
赋值缩小
当我们为任何变量赋值时,TypeScript 会查看赋值的右侧并适当地缩小左侧。
❗❗❗请注意,这些分配中的每一个都是有效的。即使在我们第一次分配后观察到的 x 类型更改为 number,我们仍然能够将 string 分配给 x。这是因为 x 的声明类型 (x 开头的类型) 是 string | number,并且始终根据声明的类型检查可赋值性。只是后期会根据右侧赋值缩小左侧类型。
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
控制流分析
到目前为止,我们已经通过一些基本示例来了解 TypeScript 如何在特定分支中缩小类型。但是,除了从每个变量中查找 if、while、条件等中的类型保护之外,还有更多的事情要做。
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
还是通过 padLeft 的例子讲解,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
}
使用类型谓词
到目前为止,我们已经使用现有的 JavaScript 结构来处理类型缩小,但是有时你希望更直接地控制类型在整个代码中的变化方式。
要定义用户定义的类型保护,我们只需要定义一个返回类型为类型谓词的函数:
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
pet is Fish 是本例中的类型谓词。谓词采用 parameterName is Type 的形式,其中 parameterName 必须是当前函数签名中的参数名称。
任何时候使用某个变量调用 isFish 时,如果基础类型兼容,TypeScript 就会将该变量缩小到该特定类型。
// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}
请注意,TypeScript 不仅知道 pet 是 if 分支中的 Fish;它还知道在 else 分支中,你没有 Fish,所以你必须有 Bird。
你可以使用类型保护 isFish 过滤 Fish | Bird 的数组并获得 Fish 的数组:
const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// or, equivalently
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
// The predicate may need repeating for more complex examples
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
if (pet.name === "sharkey") return false;
return isFish(pet);
});
断言函数
到目前为止,我们看到的大多数示例都集中在使用简单类型(如 string、boolean 和 number)来缩小单个变量的作用域。虽然这很常见,但大多数时候在 JavaScript 中我们将处理稍微复杂的结构。
出于某种动机,假设我们正在尝试对圆形和正方形等形状进行编码。圆记录它们的半径,正方形记录它们的边长。我们将使用一个名为 kind 的字段来判断我们正在处理的形状。
interface Shape {
kind: "circle" | "square";
radius?: number;
sideLength?: number;
}
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2; // 报错 'shape.radius' is possibly 'undefined'.
}
}
判别联合
以上示例中哪怕进行了相等性缩小,但 Typescript 还是不能识别这种业务型的逻辑类型判断,Typesccript 不知道 kind 为 circle 时就有 radius 属性,所以可以使用非空判断进行断言。
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius! ** 2;
}
}
但这种方式不友好,有其他更好的方案。
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2; // (parameter) shape: Circle
}
}
上面这种方式解决了报错问题,因为当联合中的每个类型都包含具有字面类型的公共属性时,TypeScript 认为这是一个可区分的联合,并且可以缩小联合的成员。
同样的,该联合类型缩小逻辑也适用于 switch。
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
}
}
never 类型
缩小类型时,你可以将联合的选项减少到你已消除所有可能性并且一无所有的程度。在这些情况下,TypeScript 将使用 never 类型来表示不应该存在的状态。
// 返回 never 的函数必须存在无法达到的终点
function error(message: string): never {
throw new Error(message);
}
// 返回 never 的函数必须存在无法达到的终点
function infiniteLoop(): never {
while (true) {
}
}
穷举检查
never 类型可分配给每个类型;但是,没有类型可分配给 never(never 本身除外)。这意味着你可以使用缩小范围并依靠出现的 never 在 switch 语句中进行详尽检查。
例如,将 default 添加到我们的 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; // Type 'Triangle' is not assignable to type 'never'.
return _exhaustiveCheck;
}
}