创建了一个 “重学TypeScript” 的微信群,想加群的小伙伴,加我微信 “semlinker”,备注重学TS。 本文是 ”重学TS系列“ 第 30 篇文章,感谢您的阅读!
之前的文章,我们已经介绍了 TypeScript 的类型收窄,本文我们将介绍 TypeScript 的类型拓宽。在一些情况下,TypeScript 从上下文推断类型,减少了程序员显式指定明显类型的需要。例如:
let name = "semlinker";
此时变量 name 的类型会被推断为 string 基本类型,因为这是用于初始化它的值的类型。从表达式推断变量、属性或函数结果的类型时,源类型的拓宽形式用作目标的推断类型。类型的拓宽是所有出现的空类型和未定义类型都被类型 any 替换。
以下示例显示了拓宽类型以产生推断的变量类型的结果。
let a = null; // let a: any
let b = undefined; // let b: any
let c = { x: 0, y: null }; // let c: { x: number, y: null }
let d = [ null, undefined ]; // let d: (null | undefined)[]
在运行时,每个变量都有一个值。但是在静态分析时,当 TypeScript 检查你的代码时,变量含有一组可能的值和类型。当你使用常量初始化变量但不提供类型时,类型检查器需要确定一个。换句话说,它需要根据你指定的单个值来确定一组可能的值。在 TypeScript 中,此过程称为拓宽。理解它可以帮助你理解错误并更有效地使用类型注释。
假设你正在编写一个向量库,你首先定义了一个 Vector3 接口,然后定义了 getComponent 函数用于获取指定坐标轴的值:
interface Vector3 {
x: number;
y: number;
z: number;
}
function getComponent(vector: Vector3, axis: "x" | "y" | "z") {
return vector[axis];
}
但是,当你尝试使用 getComponent 函数时,TypeScript 会提示以下错误信息:
let x = "x";
let vec = { x: 10, y: 20, z: 30 };
// Argument of type 'string' is not assignable to parameter of type
// '"x" | "y" | "z"'.(2345)
getComponent(vec, x); // Error
为什么会出现上述错误呢?通过 TypeScript 的错误提示消息,我们知道是因为变量 x 的类型被推断为 string 类型,而 getComponent 函数期望它的第二个参数有一个更具体的类型。这在实际场合中被拓宽了,所以导致了一个错误。
这个过程是复杂的,因为对于任何给定的值都有许多可能的类型。例如:
const mixed = ['x', 1];
上述 mixed 变量的类型应该是什么?这里有一些可能性:
没有更多的上下文,TypeScript 无法知道哪种类型是 “正确的”,它必须猜测你的意图。尽管 TypeScript 很聪明,但它无法读懂你的心思。它不能保证 100% 正确,正如我们刚才看到的那样的疏忽性错误。
在最初的例子中,变量 x 的类型被推断为字符串,因为 TypeScript 允许这样的代码:
let x = 'semlinker';
x = 'kakuqo';
x = 'lolo';
对于 JavaScript 来说,以下代码也是合法的:
let x = 'x';
x = /x|y|z/;
x = ['x', 'y', 'z'];
在推断 x 的类型为字符串时,TypeScript 试图在特殊性和灵活性之间取得平衡。一般规则是,变量的类型在声明之后不应该改变,因此 string 比 string|RegExp 或 string|string[] 或任何字符串更有意义。
TypeScript 提供了一些控制拓宽过程的方法。其中一种方法是使用 const
。如果用 const 而不是 let 声明一个变量,那么它的类型会更窄。事实上,使用 const 可以帮助我们修复前面例子中的错误:
const x = "x"; // type is "x"
let vec = { x: 10, y: 20, z: 30 };
getComponent(vec, x); // OK
因为 x 不能重新赋值,所以 TypeScript 可以推断更窄的类型,就不会在后续赋值中出现错误。因为字符串字面量型 “x” 可以赋值给 “x”|”y”|”z”,所以代码会通过类型检查器的检查。
然而,const 并不是万灵药。对于对象和数组,仍然会存在问题。前面的 mixed 示例说明了数组的问题:TypeScript 应该推断 mixed 类型为元组类型吗?它应该为 mixed 推断出什么类型?对象也会出现类似的问题。
以下这段代码在 JavaScript 中是没有问题的:
const obj = {
x: 1,
};
obj.x = 6;
obj.x = '6';
obj.y = 8;
obj.name = 'semlinker';
而在 TypeScript 中,对于 obj 的类型来说,它可以是 {readonly x:1}
类型,或者是更通用的 {x:number}
类型。当然也可能是 {[key: string]: number}
或 object 类型。对于对象,TypeScript 的拓宽算法会将其内部属性视为将其赋值给 let 关键字声明的变量,进而来推断其属性的类型。因此 obj 的类型为 {x:number}
。这使得你可以将 obj.x 赋值给其他 number 类型的变量,而不是 string 类型的变量,并且它还会阻止你添加其他属性。
因此最后三行的语句会出现错误:
const obj = {
x: 1,
};
obj.x = 6; // OK
// Type '"6"' is not assignable to type 'number'.
obj.x = '6'; // Error
// Property 'y' does not exist on type '{ x: number; }'.
obj.y = 8; // Error
// Property 'name' does not exist on type '{ x: number; }'.
obj.name = 'semlinker'; // Error
TypeScript 试图在具体性和灵活性之间取得平衡。它需要推断一个足够具体的类型来捕获错误,但又不能推断出错误的类型。它通过属性的初始化值来推断属性的类型,当然有几种方法可以覆盖 TypeScript 的默认行为。一种是提供显式类型注释:
// Type is { x: 1 | 3 | 5; }
const obj: { x: 1 | 3 | 5 } = {
x: 1
};
另一种方法是使用 const 断言。不要将其与 let 和 const 混淆,后者在值空间中引入符号。这是一个纯粹的类型级构造。让我们来看看以下变量的不同推断类型:
// Type is { x: number; y: number; }
const obj1 = {
x: 1,
y: 2
};
// Type is { x: 1; y: number; }
const obj2 = {
x: 1 as const,
y: 2,
};
// Type is { readonly x: 1; readonly y: 2; }
const obj3 = {
x: 1,
y: 2
} as const;
当你在一个值之后使用 const 断言时,TypeScript 将为它推断出最窄的类型,没有拓宽。对于真正的常量,这通常是你想要的。当然你也可以对数组使用 const 断言:
// Type is number[]
const arr1 = [1, 2, 3];
// Type is readonly [1, 2, 3]
const arr2 = [1, 2, 3] as const;
如果你认为类型拓宽导致了错误,那么可以考虑添加一些显式类型注释或使用 const 断言。接下来我们来简单介绍一下字面量类型的拓宽。
你可以通过显式地将变量标注为字面量类型来创建非拓宽字面量类型的变量:
// Type "https" (non-widening)
const stringLiteral: "https" = "https";
// Type 10 (non-widening)
const numericLiteral: 10 = 10;
将含有非拓宽字面量类型的变量赋给另一个变量时,比如以下示例中的 widenedStringLiteral 变量,该变量的类型不会被拓宽:
// Type "https" (non-widening)
const stringLiteral: "https" = "https";
// Type 10 (non-widening)
const numericLiteral: 10 = 10;
// Type "https" (non-widening)
let widenedStringLiteral = stringLiteral;
// Type 10 (non-widening)
let widenedNumericLiteral = numericLiteral;
注意,此时 widenedStringLiteral 和 widenedNumericLiteral 变量的类型仍然是 “https” 和 10。
为了理解为什么非拓宽的字面量是有用的,让我们再来看一下拓宽的字面量类型。在下面的例子中,我们通过两个拓宽的字符串字面量类型来创建数组:
// Type "http" (widening)
const http = "http";
// Type "https" (widening)
const https = "https";
// Type string[]
const protocols = [http, https];
const first = protocols[0]; // Type string
const second = protocols[1]; // Type string
TypeScript 推断出 protocols 的类型是 string[]
。因此数组元素 first
和 second
的类型被认为是 string 类型。字面量类型 “http” 和 “https” 的概念在拓宽过程中丢失了。
如果你显式地把两个常量的类型分别设置为 http 和 https 的类型,那么protocols
常量的类型将被推断为 ("http" | "https")[]
:
// Type "http" (non-widening)
const http: "http" = "http";
// Type "https" (non-widening)
const https: "https" = "https";
// Type ("http" | "https")[]
const protocols = [http, https];
// Type "http" | "https"
const first = protocols[0];
// Type "http" | "https"
const second = protocols[1];
现在 first 和 second 的类型将是 "http" | "https"
。这是因为我们并没有显式声明数组索引 0 和索引 1 处值的类型分别为 http
和 https
。它只是声明该数组只包含两个字面量类型的值,不管在哪个位置,也没有说明数组的长度。
假设出于某种原因,我们希望保留数组中字符串字面量类型的位置信息,这时我们可以显式地将 protocols 的类型设置为元组类型:
// Type "http" (widening)
const http = "http";
// Type "https" (widening)
const https = "https";
// Type ["http", "https"]
const protocols: ["http", "https"] = [http, https];
// Type "http" (non-widening)
const first = protocols[0];
// Type "https" (non-widening)
const second = protocols[1];
现在,first 和 second 变量的类型被推断为各自的非拓宽字符串字面量类型。