TypeScript 3.7
可选链
可选链是我们问题跟踪器上的第 16 号问题。背景是,从那时起,TypeScript 问题跟踪器上已有超过 23,000 个问题。
可选链的核心是,它让我们编写的代码在遇到 null 或 undefined 时,TypeScript 可以立即停止运行某些表达式。 可选链中的明星是用于可选属性访问的新 ?. 运算符。 当我们编写如下代码时:
let x = foo?.bar.baz();这是一种表达方式:当 foo 被定义时,计算 foo.bar.baz();但当 foo 为 null 或 undefined 时,停止正在做的事情并返回 undefined。
更直白地说,这段代码片段等同于编写以下内容:
let x = foo === null || foo === undefined ? undefined : foo.bar.baz();注意,如果 bar 为 null 或 undefined,我们的代码在访问 baz 时仍会报错。同样,如果 baz 为 null 或 undefined,我们在调用点会报错。?. 仅检查它左侧的值是否为 null 或 undefined,而不检查任何后续属性。
你可能会发现使用 ?. 可以替代许多使用 && 运算符进行重复空值检查的代码。
// 之前
if (foo && foo.bar && foo.bar.baz) {
// ...
}
// 之后
if (foo?.bar?.baz) {
// ...
}请记住,?. 的行为与那些 && 操作不同,因为 && 会特别对待“假值”(例如空字符串、0、NaN 以及 false),但这是该构造的预期特性。它不会在有效数据(如 0 或空字符串)上短路。
可选链还包括另外两个操作。首先是可选元素访问,其行为类似于可选属性访问,但允许我们访问非标识符属性(例如任意字符串、数字和符号):
/**
* 如果有数组,返回数组的第一个元素。
* 否则返回 undefined。
*/
function tryGetFirstElement<T>(arr?: T[]) {
return arr?.[0];
// 等同于
// return (arr === null || arr === undefined) ?
// undefined :
// arr[0];
}还有可选调用,它允许我们有条件地调用表达式,只要它们不是 null 或 undefined。
async function makeRequest(url: string, log?: (msg: string) => void) {
log?.(`Request started at ${new Date().toISOString()}`);
// 大致等同于
// if (log != null) {
// log(`Request started at ${new Date().toISOString()}`);
// }
const result = (await fetch(url)).json();
log?.(`Request finished at ${new Date().toISOString()}`);
return result;
}可选链的“短路”行为仅限于属性访问、调用、元素访问——它不会从这些表达式进一步向外扩展。 换句话说,
let result = foo?.bar / someComputation();不会阻止除法或 someComputation() 调用发生。它等同于:
let temp = foo === null || foo === undefined ? undefined : foo.bar;
let result = temp / someComputation();这可能导致除以 undefined,这就是为什么在 strictNullChecks 下,以下代码是错误。
function barPercentage(foo?: { bar: number }) {
return foo?.bar / 100;
// ~~~~~~~~
// 错误:对象可能为 undefined。
}空值合并
空值合并运算符是另一个即将推出的 ECMAScript 特性,它与可选链相辅相成,我们的团队一直参与在 TC39 中推动它。
你可以把这个特性——?? 运算符——看作是在处理 null 或 undefined 时“回退”到默认值的一种方式。 当我们编写如下代码时:
let x = foo ?? bar();这是一种新的表达方式:当 foo“存在”时使用它的值;但当它为 null 或 undefined 时,计算 bar() 来代替。
同样,上述代码等同于以下内容:
let x = foo !== null && foo !== undefined ? foo : bar();?? 运算符可以替代在尝试使用默认值时使用 || 的情况。例如,以下代码片段试图获取上次保存在 localStorage 中的音量(如果有的话);但它有一个 bug,因为它使用了 ||。
function initializeAudio() {
let volume = localStorage.volume || 0.5;
// ...
}当 localStorage.volume 设置为 0 时,页面会将音量设置为 0.5,这是非预期的。?? 避免了 0、NaN 和 "" 被当作假值处理而带来的一些意外行为。
我们非常感谢社区成员 Wenlu Wang 和 Titian Cernicova Dragomir 实现了此功能! 有关更多详细信息,请查看他们的拉取请求和空值合并提案仓库。
断言函数
有一类特殊的函数,当发生意外情况时会 throw 错误。它们被称为“断言”函数。例如,Node.js 有一个专门用于此的函数,称为 assert。
assert(someValue === 42);在此示例中,如果 someValue 不等于 42,则 assert 将抛出 AssertionError。
JavaScript 中的断言通常用于防止传入不正确的类型。例如,
function multiply(x, y) {
assert(typeof x === "number");
assert(typeof y === "number");
return x * y;
}不幸的是,在 TypeScript 中,这些检查永远无法正确编码。对于松散类型的代码,这意味着 TypeScript 检查较少,而对于稍微保守的代码,它通常迫使使用者使用类型断言。
function yell(str) {
assert(typeof str === "string");
return str.toUppercase();
// 哎呀!我们拼错了 'toUpperCase'。
// 如果 TypeScript 仍然能捕获这个就好了!
}替代方案是重写代码以便语言可以分析它,但这不方便。
function yell(str) {
if (typeof str !== "string") {
throw new TypeError("str should have been a string.");
}
// 错误被捕获!
return str.toUppercase();
}最终,TypeScript 的目标是以最小的破坏性方式对现有的 JavaScript 结构进行类型化。因此,TypeScript 3.7 引入了一个名为“断言签名”的新概念,用于对这些断言函数进行建模。
第一种断言签名模拟了 Node 的 assert 函数的工作方式。它确保被检查的条件在包含作用域的剩余部分中必须为真。
function assert(condition: any, msg?: string): asserts condition {
if (!condition) {
throw new AssertionError(msg);
}
}asserts condition 表示,如果 assert 返回(否则它会抛出错误),那么传递给 condition 参数的任何内容都必须为真。这意味着,在作用域的其余部分,该条件必须为真值。例如,使用此断言函数意味着我们确实捕获了原始的 yell 示例。
function yell(str) {
assert(typeof str === "string");
return str.toUppercase();
// ~~~~~~~~~~~
// 错误:类型 'string' 上不存在属性 'toUppercase'。
// 你是想用 'toUpperCase' 吗?
}
function assert(condition: any, msg?: string): asserts condition {
if (!condition) {
throw new AssertionError(msg);
}
}另一种断言签名不检查条件,而是告诉 TypeScript 特定变量或属性具有不同的类型。
function assertIsString(val: any): asserts val is string {
if (typeof val !== "string") {
throw new AssertionError("Not a string!");
}
}这里 asserts val is string 确保在调用 assertIsString 之后,传入的任何变量都将被知道是 string。
function yell(str: any) {
assertIsString(str);
// 现在 TypeScript 知道 'str' 是一个 'string'。
return str.toUppercase();
// ~~~~~~~~~~~
// 错误:类型 'string' 上不存在属性 'toUppercase'。
// 你是想用 'toUpperCase' 吗?
}这些断言签名与编写类型谓词签名非常相似:
function isString(val: any): val is string {
return typeof val === "string";
}
function yell(str: any) {
if (isString(str)) {
return str.toUppercase();
}
throw "Oops!";
}就像类型谓词签名一样,这些断言签名非常富有表现力。我们可以用它们表达一些相当复杂的思想。
function assertIsDefined<T>(val: T): asserts val is NonNullable<T> {
if (val === undefined || val === null) {
throw new AssertionError(
`Expected 'val' to be defined, but received ${val}`
);
}
}要了解更多关于断言签名的信息,请查看原始拉取请求。
对返回 never 的函数提供更好的支持
作为断言签名工作的一部分,TypeScript 需要更多地编码调用函数的位置和哪些函数被调用。这让我们有机会扩展对另一类函数的支持:返回 never 的函数。
任何返回 never 的函数的意图是它永远不会返回。它表示抛出了异常、发生了终止错误条件,或者程序退出。例如,@types/node 中的 process.exit(...) 被指定为返回 never。
为了确保函数永远不会潜在地返回 undefined 或有效地从所有代码路径返回,TypeScript 需要一些语法信号——要么在函数末尾有 return 或 throw。因此,用户发现他们在失败函数上写 return。
function dispatch(x: string | number): SomeType {
if (typeof x === "string") {
return doThingWithString(x);
} else if (typeof x === "number") {
return doThingWithNumber(x);
}
return process.exit(1);
}现在,当调用这些返回 never 的函数时,TypeScript 会认识到它们影响控制流图并考虑它们。
function dispatch(x: string | number): SomeType {
if (typeof x === "string") {
return doThingWithString(x);
} else if (typeof x === "number") {
return doThingWithNumber(x);
}
process.exit(1);
}与断言函数一样,你可以在同一个拉取请求中阅读更多信息。
(更多)递归类型别名
类型别名在如何“递归”引用方面一直存在限制。原因是任何类型别名的使用都需要能够用它所别名的事物替换自身。在某些情况下,这是不可能的,因此编译器拒绝某些递归别名,如下所示:
type Foo = Foo;这是一个合理的限制,因为任何对 Foo 的使用都需要被替换为 Foo,而 Foo 又需要被替换为 Foo,而 Foo 又需要被替换为 Foo,这……嗯,希望你能明白!最终,没有一个可以放在 Foo 位置的有意义的类型。
这与其他语言处理类型别名的方式相当一致,但它确实给用户利用该功能的方式带来了一些略微令人惊讶的场景。例如,在 TypeScript 3.6 及更早版本中,以下代码会导致错误。
type ValueOrArray<T> = T | Array<ValueOrArray<T>>;
// ~~~~~~~~~~~~
// 错误:类型别名 'ValueOrArray' 循环引用自身。这很奇怪,因为从技术上讲,用户始终可以通过引入接口来编写实际上相同的代码,这没有什么问题。
type ValueOrArray<T> = T | ArrayOfValueOrArray<T>;
interface ArrayOfValueOrArray<T> extends Array<ValueOrArray<T>> {}因为接口(和其他对象类型)引入了一层间接性,并且它们完整的结构不需要急切地构建出来,所以 TypeScript 处理这种结构没有问题。
但是引入接口的解决方法对用户来说并不直观。而且原则上,直接使用 Array 的 ValueOrArray 的原始版本确实没有任何问题。如果编译器稍微“懒惰”一点,只在必要时计算 Array 的类型参数,那么 TypeScript 就可以正确表达这些。
这正是 TypeScript 3.7 引入的。在类型别名的“顶层”,TypeScript 将推迟解析类型参数以允许这些模式。
这意味着以前试图表示 JSON 的代码...
type Json = string | number | boolean | null | JsonObject | JsonArray;
interface JsonObject {
[property: string]: Json;
}
interface JsonArray extends Array<Json> {}终于可以重写,无需辅助接口。
type Json =
| string
| number
| boolean
| null
| { [property: string]: Json }
| Json[];这种新的放宽也允许我们在元组中递归引用类型别名。以前会报错的以下代码现在是有效的 TypeScript 代码。
type VirtualNode = string | [string, { [key: string]: any }, ...VirtualNode[]];
const myNode: VirtualNode = [
"div",
{ id: "parent" },
["div", { id: "first-child" }, "I'm the first child"],
["div", { id: "second-child" }, "I'm the second child"],
];有关更多信息,你可以阅读原始拉取请求。
--declaration 和 --allowJs
TypeScript 中的 declaration 标志允许我们从 TypeScript 源文件(即 .ts 和 .tsx 文件)生成 .d.ts 文件(声明文件)。这些 .d.ts 文件非常重要,原因有几个。
首先,它们很重要,因为它们允许 TypeScript 在不重新检查原始源代码的情况下对其他项目进行类型检查。它们也很重要,因为它们允许 TypeScript 与未使用 TypeScript 构建的现有 JavaScript 库互操作。最后,一个常常被低估的好处是:在使用由 TypeScript 提供支持的编辑器时,TypeScript 和 JavaScript 用户都可以从这些文件中受益,以获得更好的自动补全等功能。
不幸的是,declaration 与 allowJs 标志(允许混合 TypeScript 和 JavaScript 输入文件)不能一起使用。这是一个令人沮丧的限制,因为这意味着用户在迁移代码库时无法使用 declaration 标志,即使他们使用了 JSDoc 注解。TypeScript 3.7 改变了这一点,允许这两个选项一起使用!
此功能最有影响力的结果可能有点微妙:使用 TypeScript 3.7,用户可以用 JSDoc 注解的 JavaScript 编写库,并支持 TypeScript 用户。
其工作原理是,当使用 allowJs 时,TypeScript 会进行一些尽力而为的分析以理解常见的 JavaScript 模式;然而,在 JavaScript 中表达的一些模式不一定看起来像它们在 TypeScript 中的等效形式。当启用 declaration 输出时,TypeScript 会找出将 JSDoc 注释和 CommonJS 导出转换为输出 .d.ts 文件中有效类型声明等的最佳方式。
例如,以下代码片段
const assert = require("assert");
module.exports.blurImage = blurImage;
/**
* 从输入缓冲区生成模糊图像。
*
* @param input {Uint8Array}
* @param width {number}
* @param height {number}
*/
function blurImage(input, width, height) {
const numPixels = width * height * 4;
assert(input.length === numPixels);
const result = new Uint8Array(numPixels);
// TODO
return result;
}将生成一个 .d.ts 文件,如
/**
* 从输入缓冲区生成模糊图像。
*
* @param input {Uint8Array}
* @param width {number}
* @param height {number}
*/
export function blurImage(
input: Uint8Array,
width: number,
height: number
): Uint8Array;这可以超越带有 @param 标签的基本函数,以下示例:
/**
* @callback Job
* @returns {void}
*/
/** 排队工作 */
export class Worker {
constructor(maxDepth = 10) {
this.started = false;
this.depthLimit = maxDepth;
/**
* 注意:排队的作业可能会向队列添加更多项目
* @type {Job[]}
*/
this.queue = [];
}
/**
* 将工作项添加到队列
* @param {Job} work
*/
push(work) {
if (this.queue.length + 1 > this.depthLimit) throw new Error("Queue full!");
this.queue.push(work);
}
/**
* 如果队列尚未启动,则启动队列
*/
start() {
if (this.started) return false;
this.started = true;
while (this.queue.length) {
/** @type {Job} */ (this.queue.shift())();
}
return true;
}
}将被转换为以下 .d.ts 文件:
/**
* @callback Job
* @returns {void}
*/
/** 排队工作 */
export class Worker {
constructor(maxDepth?: number);
started: boolean;
depthLimit: number;
/**
* 注意:排队的作业可能会向队列添加更多项目
* @type {Job[]}
*/
queue: Job[];
/**
* 将工作项添加到队列
* @param {Job} work
*/
push(work: Job): void;
/**
* 如果队列尚未启动,则启动队列
*/
start(): boolean;
}
export type Job = () => void;请注意,当一起使用这些标志时,TypeScript 不一定需要降级 .js 文件。如果你只是想让 TypeScript 创建 .d.ts 文件,你可以使用 emitDeclarationOnly 编译器选项。
有关更多详细信息,你可以查看原始拉取请求。
useDefineForClassFields 标志和 declare 属性修饰符
当初 TypeScript 实现公共类字段时,我们尽最大努力假设以下代码
class C {
foo = 100;
bar: string;
}等同于构造函数体内的类似赋值。
class C {
constructor() {
this.foo = 100;
}
}不幸的是,尽管这似乎是该提案早期的发展方向,但公共类字段极有可能以不同的方式标准化。相反,原始代码示例可能需要脱糖为更接近以下内容:
class C {
constructor() {
Object.defineProperty(this, "foo", {
enumerable: true,
configurable: true,
writable: true,
value: 100,
});
Object.defineProperty(this, "bar", {
enumerable: true,
configurable: true,
writable: true,
value: void 0,
});
}
}虽然 TypeScript 3.7 默认不会更改任何现有的输出,但我们一直在逐步推出更改,以帮助用户缓解潜在的未来破坏。我们提供了一个名为 useDefineForClassFields 的新标志,以启用此输出模式并附带一些新的检查逻辑。
两个最大的变化如下:
- 声明使用
Object.defineProperty初始化。 - 声明始终初始化为
undefined,即使它们没有初始化器。
这可能会对使用继承的现有代码造成相当大的影响。首先,基类中的 set 访问器不会被触发——它们会被完全覆盖。
class Base {
set data(value: string) {
console.log("data changed to " + value);
}
}
class Derived extends Base {
// 使用 'useDefineForClassFields' 时,
// 不再触发 'console.log'
data = 10;
}其次,使用类字段来特化基类的属性也将不起作用。
interface Animal {
animalStuff: any;
}
interface Dog extends Animal {
dogStuff: any;
}
class AnimalHouse {
resident: Animal;
constructor(animal: Animal) {
this.resident = animal;
}
}
class DogHouse extends AnimalHouse {
// 使用 'useDefineForClassFields' 时,
// 在调用 'super()' 后将 'resident' 初始化为 'undefined'!
resident: Dog;
constructor(dog: Dog) {
super(dog);
}
}这两点归结为:将属性与访问器混合会导致问题,并且重新声明没有初始化器的属性也会导致问题。
为了检测访问器的问题,TypeScript 3.7 现在会在 .d.ts 文件中输出 get/set 访问器,以便 TypeScript 可以检查被覆盖的访问器。
受类字段更改影响的代码可以通过将字段初始化器转换为构造函数体内的赋值来解决此问题。
class Base {
set data(value: string) {
console.log("data changed to " + value);
}
}
class Derived extends Base {
constructor() {
this.data = 10;
}
}为了帮助缓解第二个问题,你可以添加一个显式初始化器,或者添加一个 declare 修饰符来表示属性不应有任何输出。
interface Animal {
animalStuff: any;
}
interface Dog extends Animal {
dogStuff: any;
}
class AnimalHouse {
resident: Animal;
constructor(animal: Animal) {
this.resident = animal;
}
}
class DogHouse extends AnimalHouse {
declare resident: Dog;
// ^^^^^^^
// 'resident' 现在有一个 'declare' 修饰符,
// 并且不会产生任何输出代码。
constructor(dog: Dog) {
super(dog);
}
}目前,useDefineForClassFields 仅在面向 ES5 及以上版本时可用,因为 Object.defineProperty 在 ES3 中不存在。为了实现对问题的类似检查,你可以创建一个面向 ES5 并使用 noEmit 的单独项目,以避免完整构建。
有关更多信息,你可以查看这些更改的原始拉取请求。
我们强烈鼓励用户尝试 useDefineForClassFields 标志,并在我们的问题跟踪器或下面的评论中反馈。这包括关于采用该标志难度的反馈,以便我们可以理解如何使迁移更容易。
使用项目引用进行无构建编辑
TypeScript 的项目引用为我们提供了一种轻松拆分代码库的方法,以实现更快的编译。不幸的是,编辑一个其依赖项尚未构建(或其输出已过时)的项目意味着编辑体验不会很好。
在 TypeScript 3.7 中,当打开带有依赖项的项目时,TypeScript 将自动使用源 .ts/.tsx 文件。这意味着使用项目引用的项目现在将看到改进的编辑体验,其中语义操作是最新的并且“正常工作”。你可以使用编译器选项 disableSourceOfProjectReferenceRedirect 禁用此行为,这在处理非常大的项目时可能合适,因为此更改可能会影响编辑性能。
未调用函数检查
一个常见且危险的错误是忘记调用函数,尤其是当函数没有参数或以暗示它可能是一个属性而不是函数的方式命名时。
interface User {
isAdministrator(): boolean;
notify(): void;
doNotDisturb?(): boolean;
}
// 稍后...
// 错误的代码,不要使用!
function doAdminThing(user: User) {
// 哎呀!
if (user.isAdministrator) {
sudo();
editTheConfiguration();
} else {
throw new AccessDeniedError("User is not an admin");
}
}这里,我们忘记调用 isAdministrator,代码错误地允许非管理员用户编辑配置!
在 TypeScript 3.7 中,这被识别为可能的错误:
function doAdminThing(user: User) {
if (user.isAdministrator) {
// ~~~~~~~~~~~~~~~~~~~~
// 错误!此条件将始终返回 true,因为函数总是已定义。
// 你是想调用它吗?此检查是一个破坏性更改,但因此检查非常保守。此错误仅在 if 条件下发出,并且如果 strictNullChecks 关闭,或者如果函数在 if 的函数体内稍后被调用,则不会在可选属性上发出:
interface User {
isAdministrator(): boolean;
notify(): void;
doNotDisturb?(): boolean;
}
function issueNotification(user: User) {
if (user.doNotDisturb) {
// 可以,属性是可选的
}
if (user.notify) {
// 可以,调用了函数
user.notify();
}
}如果你打算在不调用函数的情况下测试它,你可以修改其定义以包含 undefined/null,或者使用 !! 编写类似 if (!!user.isAdministrator) 的内容,以指示强制转换是有意的。
我们非常感谢 GitHub 用户 @jwbay 主动创建了一个概念验证并反复迭代,为我们提供了当前版本。
TypeScript 文件中的 // @ts-nocheck
TypeScript 3.7 允许我们在 TypeScript 文件顶部添加 // @ts-nocheck 注释以禁用语义检查。历史上,此注释仅在存在 checkJs 的 JavaScript 源文件中被尊重,但我们已经将支持扩展到 TypeScript 文件,以使所有用户的迁移更容易。
分号格式化选项
TypeScript 内置的格式化程序现在支持在由于 JavaScript 自动分号插入(ASI)规则而分号可选的位置插入和删除分号。该设置现在可在 Visual Studio Code Insiders 中使用,并将于 Visual Studio 16.4 Preview 2 中在“工具选项”菜单中提供。

选择“insert”或“remove”值也会影响自动导入、提取类型和 TypeScript 服务提供的其他生成代码的格式。将该设置保留为默认值“ignore”会使生成代码匹配在当前文件中检测到的分号偏好。
3.7 破坏性更改
DOM 更改
lib.dom.d.ts 中的类型已更新。这些更改主要与可空性相关的正确性更改,但影响最终取决于你的代码库。
类字段缓解措施
如上所述,TypeScript 3.7 在 .d.ts 文件中输出 get/set 访问器,这可能导致使用旧版本 TypeScript(如 3.5 及更早版本)的消费者发生破坏性更改。TypeScript 3.6 用户不会受到影响,因为该版本已经为此功能做好了前瞻性准备。
虽然本身不是破坏,但选择 useDefineForClassFields 标志可能会在以下情况下导致破坏:
- 在派生类中使用属性声明覆盖访问器
- 重新声明没有初始化器的属性声明
要了解完整影响,请阅读上面关于 useDefineForClassFields 标志的部分。
函数真值检查
如上所述,TypeScript 现在在 if 语句条件中函数似乎未被调用时会报错。当在 if 条件中检查函数类型时,除非满足以下任一条件,否则会发出错误:
- 被检查的值来自可选属性
strictNullChecks被禁用- 该函数在
if的函数体内稍后被调用
本地和导入的类型声明现在冲突
由于一个 bug,以下构造以前在 TypeScript 中是允许的:
// ./someOtherModule.ts
interface SomeType {
y: string;
}
// ./myModule.ts
import { SomeType } from "./someOtherModule";
export interface SomeType {
x: number;
}
function fn(arg: SomeType) {
console.log(arg.x); // 错误!'x' 在 'SomeType' 上不存在
}这里,SomeType 似乎同时来自 import 声明和本地 interface 声明。也许令人惊讶的是,在模块内部,SomeType 专指 import 的定义,而本地声明 SomeType 仅在从另一个文件导入时才可用。这非常令人困惑,并且我们对极少数此类代码的审查表明,开发者通常认为发生了不同的事情。
在 TypeScript 3.7 中,这现在被正确识别为重复标识符错误。正确的修复取决于作者的原始意图,应逐案处理。通常,命名冲突是无意的,最好的修复是重命名导入的类型。如果意图是增强导入的类型,则应编写适当的模块扩展。
3.7 API 更改
为了启用上面描述的递归类型别名模式,typeArguments 属性已从 TypeReference 接口中移除。用户应改用 TypeChecker 实例上的 getTypeArguments 函数。