TypeScript 4.4
别名条件和可辨识属性的控制流分析
在 JavaScript 中,我们经常需要以不同的方式探查一个值,一旦我们对其类型有了更多了解,就会执行不同的操作。 TypeScript 理解这些检查并将其称为类型守卫。 类型检查器无需在我们每次使用变量时都试图说服它相信变量的类型,而是利用所谓的控制流分析来查看我们在给定代码片段之前是否使用了类型守卫。
例如,我们可以编写如下代码:
function foo(arg: unknown) {
if (typeof arg === "string") {
console.log(arg.toUpperCase());
}
}Try在这个例子中,我们检查了 arg 是否为 string。 TypeScript 识别出 typeof arg === "string" 检查,并将其视为类型守卫,从而知道在 if 块内部 arg 是 string。 这让我们可以访问像 toUpperCase() 这样的 string 方法而不会出错。
然而,如果我们将条件移到一个名为 argIsString 的常量中,会发生什么?
// 在 TS 4.3 及更低版本中
function foo(arg: unknown) {
const argIsString = typeof arg === "string";
if (argIsString) {
console.log(arg.toUpperCase());
// ~~~~~~~~~~~
// 错误!类型 'unknown' 上不存在属性 'toUpperCase'。
}
}在以前的 TypeScript 版本中,这会是一个错误——即使 argIsString 被赋值为一个类型守卫的值,TypeScript 也简单地丢失了该信息。 这很遗憾,因为我们可能想在多个地方重用同一个检查。 为了解决这个问题,用户常常不得不重复自己或使用类型断言(即强制类型转换)。
在 TypeScript 4.4 中,情况不再如此。 上面的示例现在可以工作了,没有错误! 当 TypeScript 看到我们在测试一个常量值时,它会做一些额外的工作,以查看它是否包含一个类型守卫。 如果该类型守卫作用于一个 const、一个 readonly 属性或一个未修改的参数,那么 TypeScript 就能够适当地收窄该值。
不同类型的类型守卫条件都会被保留——不仅仅是 typeof 检查。 例如,对可辨识联合的检查就像魔法一样有效。
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; sideLength: number };
function area(shape: Shape): number {
const isCircle = shape.kind === "circle";
if (isCircle) {
// 我们知道这里是一个圆!
return Math.PI * shape.radius ** 2;
} else {
// 我们知道这里剩下的是一个正方形!
return shape.sideLength ** 2;
}
}Try在 4.4 中对可辨识属性的分析也更深入一些——我们现在可以提取可辨识属性,而 TypeScript 可以收窄原始对象。
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; sideLength: number };
function area(shape: Shape): number {
// 首先提取 'kind' 字段。
const { kind } = shape;
if (kind === "circle") {
// 我们知道这里是一个圆!
return Math.PI * shape.radius ** 2;
} else {
// 我们知道这里剩下的是一个正方形!
return shape.sideLength ** 2;
}
}Try另一个例子,这是一个检查两个输入是否都有内容的函数。
function doSomeChecks(
inputA: string | undefined,
inputB: string | undefined,
shouldDoExtraWork: boolean
) {
const mustDoWork = inputA && inputB && shouldDoExtraWork;
if (mustDoWork) {
// 我们可以访问 'inputA' 和 'inputB' 上的 'string' 属性!
const upperA = inputA.toUpperCase();
const upperB = inputB.toUpperCase();
// ...
}
}TryTypeScript 可以理解,如果 mustDoWork 为 true,那么 inputA 和 inputB 都存在。 这意味着我们不必编写像 inputA! 这样的非空断言来说服 TypeScript inputA 不是 undefined。
这里的一个巧妙特性是这种分析是传递的。 TypeScript 会通过常量跳转,以了解你已经执行了哪些类型的检查。
function f(x: string | number | boolean) {
const isString = typeof x === "string";
const isNumber = typeof x === "number";
const isStringOrNumber = isString || isNumber;
if (isStringOrNumber) {
x;
} else {
x;
}
}Try注意这里有一个截止点——TypeScript 在检查这些条件时不会任意深入,但其分析深度足以应对大多数检查。
此功能应该使许多直观的 JavaScript 代码在 TypeScript 中“正常工作”,而不会妨碍你。 有关更多详细信息,请查看 GitHub 上的实现!
Symbol 和模板字符串模式索引签名
TypeScript 允许我们使用索引签名来描述每个属性都必须具有特定类型的对象。 这使我们能够将这些对象用作类似字典的类型,我们可以使用字符串键通过方括号索引它们。
例如,我们可以编写一个带有索引签名的类型,它接受 string 键并映射到 boolean 值。 如果我们试图赋值除 boolean 值之外的任何东西,就会收到错误。
interface BooleanDictionary {
[key: string]: boolean;
}
declare let myDict: BooleanDictionary;
// 赋值 boolean 值有效
myDict["foo"] = true;
myDict["bar"] = false;
// 错误,“oops” 不是 boolean
myDict["baz"] = "oops";Try虽然这里 Map 可能是更好的数据结构(具体来说,Map<string, boolean>),但 JavaScript 对象通常更方便使用,或者恰好是我们需要处理的数据。
类似地,Array<T> 已经定义了一个 number 索引签名,允许我们插入/检索 T 类型的值。
// @errors: 2322 2375
// 这是 TypeScript 内置 Array 类型定义的一部分。
interface Array<T> {
[index: number]: T;
// ...
}
let arr = new Array<string>();
// 有效
arr[0] = "hello!";
// 错误,这里期望一个 'string' 值
arr[1] = 123;索引签名对于表达大量代码非常有用; 然而,到目前为止,它们仅限于 string 和 number 键(并且 string 索引签名有一个故意的怪癖,即它们可以接受 number 键,因为无论如何它们会被强制转换为字符串)。 这意味着 TypeScript 不允许使用 symbol 键索引对象。 TypeScript 也无法建模 string 键的某个子集的索引签名——例如,描述名称以文本 data- 开头的属性的索引签名。
TypeScript 4.4 解决了这些限制,并允许使用 symbol 和模板字符串模式的索引签名。
例如,TypeScript 现在允许我们声明一个可以用任意 symbol 作为键的类型。
interface Colors {
[sym: symbol]: number;
}
const red = Symbol("red");
const green = Symbol("green");
const blue = Symbol("blue");
let colors: Colors = {};
// 可以赋值 number
colors[red] = 255;
let redVal = colors[red];
colors[blue] = "da ba dee";Try类似地,我们可以编写一个带有模板字符串模式类型的索引签名。 这的一个用途可能是将以 data- 开头的属性排除在 TypeScript 的多余属性检查之外。 当我们把一个对象字面量传递给某个具有预期类型的东西时,TypeScript 会查找预期类型中未声明的多余属性。
// @errors: 2322 2375
interface Options {
width?: number;
height?: number;
}
let a: Options = {
width: 100,
height: 100,
"data-blah": true,
};
interface OptionsWithDataProps extends Options {
// 允许任何以 'data-' 开头的属性。
[optName: `data-${string}`]: unknown;
}
let b: OptionsWithDataProps = {
width: 100,
height: 100,
"data-blah": true,
// 对于未知且不以 'data-' 开头的属性会失败
"unknown-property": true,
};关于索引签名的最后一点说明是,现在它们允许联合类型,只要它们是无限域原始类型的联合——具体来说:
stringnumbersymbol- 模板字符串模式(例如
`hello-${string}`)
参数是这些类型联合的索引签名将被分解为几个不同的索引签名。
interface Data {
[optName: string | symbol]: any;
}
// 等价于
interface Data {
[optName: string]: any;
[optName: symbol]: any;
}有关更多详细信息,请阅读拉取请求
catch 变量默认为 unknown 类型 (--useUnknownInCatchVariables)
在 JavaScript 中,任何类型的值都可以用 throw 抛出,并在 catch 子句中捕获。 因此,TypeScript 历史上将 catch 子句变量类型化为 any,并且不允许任何其他类型注解:
try {
// 谁知道这可能会抛出什么...
executeSomeThirdPartyCode();
} catch (err) {
// err: any
console.error(err.message); // 允许,因为 'any'
err.thisWillProbablyFail(); // 允许,因为 'any' :(
}一旦 TypeScript 添加了 unknown 类型,就变得很明显,对于希望获得最高正确性和类型安全性的用户来说,unknown 是比 any 更好的 catch 子句变量选择,因为它可以更好地收窄,并迫使我们测试任意值。 最终,TypeScript 4.0 允许用户在每个 catch 子句变量上指定显式的 unknown(或 any)类型注解,以便我们可以在个案基础上选择更严格的类型; 然而,对某些人来说,在每个 catch 子句上手动指定 : unknown 是一件苦差事。
这就是为什么 TypeScript 4.4 引入了一个名为 useUnknownInCatchVariables 的新标志。 此标志将 catch 子句变量的默认类型从 any 更改为 unknown。
try {
executeSomeThirdPartyCode();
} catch (err) {
// err: unknown
// 错误!类型 'unknown' 上不存在属性 'message'。
console.error(err.message);
// 有效!我们可以将 'err' 从 'unknown' 收窄为 'Error'。
if (err instanceof Error) {
console.error(err.message);
}
}Try此标志在 strict 系列选项下启用。 这意味着如果你使用 strict 检查代码,此选项将自动打开。 你可能会在 TypeScript 4.4 中遇到错误,例如:
Property 'message' does not exist on type 'unknown'.
Property 'name' does not exist on type 'unknown'.
Property 'stack' does not exist on type 'unknown'.在我们不想处理 catch 子句中 unknown 变量的情况下,我们总是可以添加一个显式的 : any 注解,以便选择退出更严格的类型。
有关更多信息,请查看实现拉取请求。
精确的可选属性类型 (--exactOptionalPropertyTypes)
在 JavaScript 中,读取对象上缺失的属性会产生值 undefined。 也可以拥有一个值为 undefined 的实际属性。 JavaScript 中的许多代码倾向于以相同的方式处理这些情况,因此最初 TypeScript 只是将每个可选属性解释为就好像用户在类型中写入了 undefined。 例如,
interface Person {
name: string;
age?: number;
}被认为等价于
interface Person {
name: string;
age?: number | undefined;
}这意味着用户可以显式地在 age 的位置写入 undefined。
const p: Person = {
name: "Daniel",
age: undefined, // 默认情况下这是可以的。
};因此,默认情况下,TypeScript 不区分值为 undefined 的现有属性和缺失的属性。 虽然这在大多数情况下有效,但并非 JavaScript 中的所有代码都做出相同的假设。 像 Object.assign、Object.keys、对象展开({ ...obj })和 for-in 循环等函数和运算符的行为取决于属性是否实际存在于对象上。 以我们的 Person 示例为例,如果在某个上下文中观察到 age 属性且其存在很重要,则可能导致运行时错误。
在 TypeScript 4.4 中,新标志 exactOptionalPropertyTypes 指定可选属性类型应完全按照编写的方式解释,这意味着 | undefined 不会添加到类型中:
// 启用 'exactOptionalPropertyTypes' 后:
const p: Person = { name: "Daniel",
age: undefined, // 错误!undefined 不是 number
};Try此标志不属于 strict 系列,如果你需要此行为,需要显式打开它。 它还需要启用 strictNullChecks。 我们一直在更新 DefinitelyTyped 和其他定义,以尽可能简化过渡,但根据你的代码结构,你可能会遇到一些摩擦。
有关更多信息,你可以在此处查看实现拉取请求。
类中的 static 块
TypeScript 4.4 支持类中的 static 块,这是一个即将推出的 ECMAScript 特性,可以帮助你为静态成员编写更复杂的初始化代码。
这些静态块允许你编写一系列具有自己作用域的语句,这些语句可以访问包含类中的私有字段。 这意味着我们可以用编写语句的所有能力编写初始化代码,不会泄露变量,并且可以完全访问类的内部。
class Foo {
static #count = 0;
get count() {
return Foo.#count;
}
static {
try {
const lastInstances = loadLastInstances();
Foo.#count += lastInstances.length;
}
catch {}
}
}Try如果没有 static 块,编写上述代码是可能的,但通常涉及几种不同形式的 hack,这些 hack 必须在某种程度上做出妥协。
注意,一个类可以有多个 static 块,它们按照编写的顺序运行。
// 输出:
// 1
// 2
// 3
class Foo {
static prop = 1
static {
console.log(Foo.prop++);
}
static {
console.log(Foo.prop++);
}
static {
console.log(Foo.prop++);
}
}Try我们要感谢 Wenlu Wang 为 TypeScript 实现了此功能。 有关更多详细信息,你可以在此处查看该拉取请求。
tsc --help 更新和改进
TypeScript 的 --help 选项焕然一新! 得益于 Song Gao 的部分工作,我们引入了更改,更新了编译器选项的描述并重新设计了 --help 菜单,增加了颜色和其他视觉分隔。

你可以在原始提案线程中阅读更多信息。
性能改进
更快的声明输出
TypeScript 现在缓存了内部符号在不同上下文中是否可访问,以及特定类型应如何打印。 这些更改可以提高 TypeScript 在处理相当复杂类型的代码时的整体性能,并且在 declaration 标志下输出 .d.ts 文件时尤其明显。
更快的路径规范化
TypeScript 经常需要对文件路径进行几种类型的“规范化”,以使它们成为编译器可以在任何地方使用的一致格式。 这包括将反斜杠替换为正斜杠,或删除路径中间的 /./ 和 /../ 段。 当 TypeScript 必须处理数百万个这样的路径时,这些操作最终会变得有点慢。 在 TypeScript 4.4 中,路径首先进行快速检查,以确定它们是否首先需要任何规范化。 这些改进共同将较大项目的项目加载时间减少了 5-10%,在我们内部测试的大型项目中,减少幅度更为显著。
有关更多详细信息,你可以查看路径段规范化的 PR 以及斜杠规范化的 PR。
更快的路径映射
TypeScript 现在缓存了它构建路径映射的方式(使用 tsconfig.json 中的 paths 选项)。 对于具有数百个映射的项目,减少是显著的。 你可以在更改本身中看到更多信息。
使用 --strict 时更快的增量构建
实际上,这是一个 bug:如果启用了 strict,TypeScript 会在 incremental 编译中重新进行类型检查工作。 这导致许多构建与关闭 incremental 时一样慢。 TypeScript 4.4 修复了此问题,但此更改也已反向移植到 TypeScript 4.3。
在此处查看更多信息 这里。
大输出的更快源映射生成
TypeScript 4.4 为非常大的输出文件添加了源映射生成优化。 在构建旧版本的 TypeScript 编译器时,这使输出时间减少了约 8%。
我们要感谢 David Michon,他提供了一个简单而干净的更改来实现这一性能提升。
更快的 --force 构建
在项目引用中使用 --build 模式时,TypeScript 必须执行最新检查以确定需要重新构建哪些文件。 然而,在执行 --force 构建时,该信息无关紧要,因为每个项目依赖项都将从头开始重新构建。 在 TypeScript 4.4 中,--force 构建避免了那些不必要的步骤,并开始完整构建。 在此处查看更多关于更改的信息 这里。
JavaScript 的拼写建议
TypeScript 为 Visual Studio 和 Visual Studio Code 等编辑器中的 JavaScript 编辑体验提供支持。 大多数情况下,TypeScript 试图在 JavaScript 文件中保持不干扰; 然而,TypeScript 通常有很多信息可以做出有信心的建议,并且有方法提出不会过于侵入的建议。
这就是为什么 TypeScript 现在在纯 JavaScript 文件中发出拼写建议——这些文件没有 // @ts-check 或者在一个关闭了 checkJs 的项目中。 这些是与 TypeScript 文件已有的相同的“你的意思是...?”建议,现在它们以某种形式在所有 JavaScript 文件中可用。
这些拼写建议可以提供微妙的线索,表明你的代码有问题。 我们在测试此功能时成功地在现有代码中发现了一些错误!
有关此新功能的更多详细信息,请查看拉取请求!
内联提示
TypeScript 4.4 支持内联提示,可以帮助在代码中显示有用信息,如参数名称和返回类型。 你可以将其视为一种友好的“幽灵文本”。

此功能由 Wenlu Wang 构建,其拉取请求包含更多详细信息。
Wenlu 还贡献了在 Visual Studio Code 中集成内联提示,该集成已作为 2021 年 7 月(1.59)版本的一部分发布。 如果你想尝试内联提示,请确保你使用的是编辑器的稳定版或内部版。 你还可以在 Visual Studio Code 的设置中修改何时何地显示内联提示。
自动导入在补全列表中显示真实路径
当 Visual Studio Code 等编辑器显示补全列表时,包含自动导入的补全会显示给定模块的路径; 然而,这个路径通常不是 TypeScript 最终放入模块说明符中的路径。 该路径通常是相对于工作区的,这意味着如果你从像 moment 这样的包导入,你通常会看到像 node_modules/moment 这样的路径。

这些路径最终变得笨重且常常具有误导性,尤其是考虑到实际插入到文件中的路径需要考虑 Node 的 node_modules 解析、路径映射、符号链接和重新导出。
这就是为什么在 TypeScript 4.4 中,补全项标签现在显示将用于导入的实际模块路径!

由于这种计算可能很昂贵,包含许多自动导入的补全列表可能会在你键入更多字符时分批填充最终的模块说明符。你可能有时仍然会看到旧的工作区相对路径标签;然而,随着你的编辑体验“预热”,它们应该在再键入一两个字符后被实际路径替换。
破坏性更改
TypeScript 4.4 的 lib.d.ts 更改
与每个 TypeScript 版本一样,lib.d.ts 的声明(尤其是为 Web 上下文生成的声明)已经更改。 你可以查阅我们已知的 lib.dom.d.ts 更改列表以了解哪些内容受到影响。
更符合规范的导入函数间接调用
在早期版本的 TypeScript 中,从 CommonJS、AMD 和其他非 ES 模块系统调用导入时,会设置被调用函数的 this 值。 具体来说,在以下示例中,当调用 fooModule.foo() 时,foo() 方法会将 fooModule 设置为 this 的值。
// 假设这是我们导入的模块,它有一个名为 'foo' 的导出。
let fooModule = {
foo() {
console.log(this);
},
};
fooModule.foo();当我们在 ECMAScript 中调用导出的函数时,这不应是它们的工作方式。 这就是为什么 TypeScript 4.4 在调用导入函数时有意丢弃 this 值,通过使用以下输出:
// 假设这是我们导入的模块,它有一个名为 'foo' 的导出。
let fooModule = {
foo() {
console.log(this);
},
};
// 注意我们现在实际上是在调用 '(0, fooModule.foo)',这有细微的不同。
(0, fooModule.foo)();你可以在此处阅读更多关于更改的信息。
在 catch 变量中使用 unknown
使用 strict 标志运行的用户可能会看到有关 catch 变量为 unknown 的新错误,尤其是当现有代码假设只捕获了 Error 值时。 这通常会导致如下错误消息:
Property 'message' does not exist on type 'unknown'.
Property 'name' does not exist on type 'unknown'.
Property 'stack' does not exist on type 'unknown'.要解决此问题,你可以专门添加运行时检查,以确保抛出的类型符合你期望的类型。 否则,你可以使用类型断言、为 catch 变量添加显式的 : any,或者关闭 useUnknownInCatchVariables。
更广泛的总是真值 Promise 检查
在以前的版本中,TypeScript 引入了“总是真值 Promise 检查”来捕获可能忘记 await 的代码; 然而,这些检查仅适用于命名声明。 这意味着,虽然这段代码会正确收到错误...
async function foo(): Promise<boolean> {
return false;
}
async function bar(): Promise<string> {
const fooResult = foo();
if (fooResult) {
// <- 错误!:D
return "true";
}
return "false";
}...下面的代码却不会。
async function foo(): Promise<boolean> {
return false;
}
async function bar(): Promise<string> {
if (foo()) {
// <- 没有错误 :(
return "true";
}
return "false";
}TypeScript 4.4 现在标记这两种情况。 有关更多信息,请阅读原始更改。
抽象属性不允许初始化器
以下代码现在是一个错误,因为抽象属性可能没有初始化器:
abstract class C {
abstract prop = 1;
// ~~~~
// 属性 'prop' 不能有初始化器,因为它被标记为抽象。
}相反,你只能为属性指定类型:
abstract class C {
abstract prop: number;
}