TypeScript 4.2 有哪些新特性

爱丽丝13 等级 273 0 0

TypeScript 4.2 发布了!对于不熟悉 TypeScript 的人来说,TypeScript 就是增加了静态类型和类型检查的 JavaScript。有了类型限制,你就可以精确的表达你的函数需要什么类型的参数以及返回什么类型的结果。

同时,利用 TypeScript 的类型检查,你可以很容易避免一些常见错误,例如拼写错误或者忘记处理 null 和 undefined。因为 TypeScript 代码看起来就像是增加了类型的 JavaScript,所以你对 JavaScript 所知的一切知识依然适用于 TypeScript。在需要的时候,你也可以剥离类型,从而获得纯净、可读并且在任何地方可以运行的 JavaScript 代码。如果你想深入了解 TypeScript , 你可以访问这个网站[1]。

开始使用 TypeScript 4.2,你可以通过 NuGet[2] 安装,或使用 npm 运行以下命令:

npm install typescript  

让我们预览一下 TypeScript 4.2 的新特性:

  • 更智能的类型别名保持

  • 元组类型中的前置/中置的扩展元素

  • 更严格的 in 运算符检查

  • --noPropertyAccessFromIndexSignature

  • 抽象构建签名

  • 通过 --explainFiles 了解你的项目结构

  • 改进逻辑表达式中非执行函数的检查

  • 可以将解构变量明确标记为未使用

  • 在可选属性和字符串索引签名之间宽松的规则

  • 声明缺少的帮助函数

  • 破坏性变化

更智能的类型别名保持

TypeScript 有一种方式可以声明新的类型叫做类型别名。如果你写了一系列函数可以处理 string | number | boolean 三种类型的数据,那么你可以定义一个类型别名,用于避免重复工作:

type BasicPrimitive = number | string | boolean  

TypeScript 曾使用一系列的规则来猜测何时应当使用类型别名,而何时又应该把所有类型都打印出来。例如,来看下以下代码片段:

export type BasicPrimitive = number | string | boolean;  

export function doStuff(value: BasicPrimitive) {  
  let x = value;  
  return x;  
}  

如果我们在诸如 Visual Studio、VS Code 编辑器中或者 TypeScript 运行环境[3] 中把鼠标悬停在变量 x 上,我们就会看到一个快速展示面板,显示出 x 的类型为 BasicPrimitive。同样的,如果我们为这个方法定义一个声明文件(.d.ts 文件),TypeScript 将会显示 doStuff 返回值的类型为 BasicPrimitive

但是,如果我们返回的是 BasicPrimitive 或者 undefined 会发生什么呢?

export type BasicPrimitive = number | string | boolean;  

export function doStuff(value: BasicPrimitive) {  
  if (Math.random() < 0.5) {  
    return undefined;  
  }  

  return value;  
}  

我们可以在TypeScript 运行环境[4]中观察发生了什么。尽管我们希望看到 TypeScript 展示的 doStuff 返回值类型是 BasicPrimitive | undefined,但是实际情况是,显示的返回值类型为 string | number | boolean | undefined!这是怎么回事?

这要归因于 TypeScript 内部对于类型的解析方式。当创建了一个联合类型包含一个或多个其他的联合类型时,TypeScript 会将这些类型归一化为一个新的扁平的联合类型 —— 此时,原本类型的信息就丢失了。类型检查器将会查找 string | number | boolean | undefined 每种组合是否具有类型别名,即使这样,仍可能会得到多个 string | number | boolean 类型别名的结果。

在 TypeScript 4.2 中,我们的内部逻辑将更加智能。我们会通过保留类型的原始定义以及后续对其的更新,从而持续追踪该类型的构造变化。我们同时也会追踪被键入其他类型别名实例的类型别名,并加以区分。

能够根据你在代码中使用的方式打印出这些类型,意味着对于 TypeScript 使用者来说,可以避免看到那些令人厌恶的巨型类型定义;并且这种方式可以帮助转化出更优质的 .d.ts 文件输出、错误信息以及在编辑器中对变量展示的快速信息和帮助等。

更详细的内容,可以查阅改进保留实例间联合和相交类型别名的第一个Pull Request[5],以及随后的保留间接别名的第二个Pull Request[6]。

元组类型中的前置/中置的扩展元素

在 TypeScript 中,元组类型最初用于对特定长度和特定元素类型进行建模。

 // 一个存放了一对数字的元组  
 let a: [number, number] = [1, 2];  

 // 一个存放了一个字符串、一个数字以及一个布尔值的元组  
 let b: [string, number, boolean] = ["hello", 42, true];

随着版本的更新,TypeScript 元组变得越来越复杂,因为它们还被用于对像 JavaScript 中的参数列表一样的事务进行建模。这样带来的结果就是,元组类型可以包含可选元素以及扩展元素,甚至为了提高可读性和易用性,元素还可以拥有标签。

// 一个包含一个或两个字符串的元组  
let c: [string, string?] = ["hello"];  
c = ["hello", "world"];  

// 一个包含一个或两个字符串,并且打了标签的元组  
let d: [first: string, second?: string] = ["hello"];  
d = ["hello", "world"];  

// 一个包含扩展元素的元组 —— 前置元素至少包含两个字符串  
// 后置元素可以包含任意多个布尔值  
let e: [string, string, ...boolean[]];  

e = ["hello", "world"];  
e = ["hello", "world", false];  
e = ["hello", "world", true, false, true];  

在 TypeScript 4.2 中,扩展元素的使用特别得到了扩展。在较早的版本中,TypeScript 只允许扩展元素出现在元组的最后位置。

但是现在,扩展元素可以出现在元组的任意位置 —— 仅仅需要满足一些限制条件。

let foo: [...string[], number];  

foo = [123];  
foo = ["hello", 123];  
foo = ["hello!", "hello!", "hello!", 123];  

let bar: [boolean, ...string[], boolean];  

bar = [true, false];  
bar = [true, "some text", false];  
bar = [true, "some", "separated", "text", false];  

想让扩展元素放在元组的任意位置,唯一的限制条件就是:不能有可选元素在其后面的位置并且不能有其他的扩展元素。换句话说,每个元组只允许一个扩展元素,并且该扩展元素后序位置不能跟随一个可选元素。

interface Clown { /*...*/ }  
interface Joker { /*...*/ }  

let StealersWheel: [...Clown[], "me", ...Joker[]];  
//                                    ~~~~~~~~~~ Error!  
// 扩展元素后续不能有其他的扩展元素  

let StringsAndMaybeBoolean: [...string[], boolean?];  
//                                        ~~~~~~~~ Error!  
// 扩展元素后序不能是可选元素  

这些灵活的扩展元素,可以用于对具有任意数量前置参数以及固定格式后置参数的函数进行建模。

declare function doStuff(...args: [...names: string[], shouldCapitalize: boolean]): void;  

doStuff(/*shouldCapitalize:*/ false)  
doStuff("fee", "fi", "fo", "fum", /*shouldCapitalize:*/ true);  

尽管 JavaScript 没有任何语法可以支持前置的扩展参数,我们仍可以使用 ...args 这种元组类型来实现上述 doStuff 那样具有前置扩展参数的函数。用这种方式可以对许多现有的 JavaScript 进行建模。

更多详细信息,请参考这个 Pull Request[7]。

更严格的 in 运算符检查

在 JavaScript 中,在 in 运算符右侧使用非对象类型是一个运行时错误。而在 TypeScript 4.2 中,该错误可以在代码设计时即被捕获。

"foo" in 42  
//       ~~  
// 错误!'in' 表达式右侧不能为基数据类型  

这项检查在大多数情况下都是相当保守的,如果你遇到过相关的错误提示,很可能是代码出了问题。

非常感谢我们的外围贡献者 Jonas Hübotter[8] 提出的 Pull Request[9]。

--noPropertyAccessFromIndexSignature

在 TypeScript 首次引入索引签名时,你只能使用诸如 person["name"] 这种 “中括号” 元素访问语法来声明对象的属性。

interface SomeType {  
    /** 这是一个索引签名 */  
    [propName: string]: any;  
}  

function doStuff(value: SomeType) {  
    let x = value["someProperty"];  
}  

如果我们需要一个具有任意属性的对象,这种方式将会非常麻烦。举个例子,假设有一个 API,犯了一个常见的拼写错误——在某个属性名字后面多加了一个 s。

interface Options {  
    /** 需要排除的文件格式 */  
    exclude?: string[];  

    /**  
     * 处理未声明为 'any' 的所有其他属性  
     */  
    [x: string]: any;  
}  

function processOptions(opts: Options) {  
    // 注意这里我们故意访问的是 `excludes` 而非 `exclude`  
    if (opts.excludes) {  
        console.error("The option `excludes` is not valid. Did you mean `exclude`?");  
    }  
}  

为了使这些类型更易用,前不久,TypeScript 允许了使用 “.” 方法访问具有字符串索引签名对象(例如:person.name)的属性的语法。这也使得从现有 JavaScript 代码过渡为 TypeScript 代码变得容易。

但是,放松限制同时也意味着拼写错误导致的显示声明属性的错误访问会更加容易。

function processOptions(opts: Options) {  
    // ...  

    // 注意,这次我们是 “无意间” 访问了 `excludes`  
    // 糟糕!完全奏效。  
    for (const excludePattern of opts.excludes) {  
        // ...  
    }  
}  

某些情况下,用户只希望在显示声明的索引签名中进行访问——他们希望在使用点方法访问对象属性时,如果该属性不具有显示声明,则应当返回错误信息。

这就是 TypeScript 引入新的标识 --noPropertyAccessFromIndexSignature 的目的。开启这么模式,你将选择使用 TypeScript 旧的验证行为,从而在上述过程中抛出错误。这个新的设置不受严格模式的限制,因为我们相信用户会发现它在某些特定代码中会很有用。

你可以通过阅读这个 Pull Request[10] 获取对这个功能更详细的了解。同时,我们还要非常感谢 Wenlu Wang[11] 向我们提交了这个 Pull Request。

抽象构建签名

TypeScript 允许我们标记一个类为抽象类。这将告诉 TypeScript 该类只能被其他类扩展,并且扩展类必须包含确切的属性才能实例化。

abstract class Shape {  
    abstract getArea(): number;  
}  

// 错误! 不能实例化一个抽象类。  
new Shape();  

class Square extends Shape {  
    #sideLength: number;  

    constructor(sideLength: number) {  
        this.#sideLength = sideLength;  
    }  

    getArea() {  
        return this.#sideLength ** 2;  
    }  
}  

// 可以正确执行  
new Square(42);  

为了确保这条限制在新建抽象类的过程中始终有效,你不能将抽象类赋值给任何需要构造签名的对象。

interface HasArea {  
    getArea(): number;  
}  

// 错误!不能将抽象构造类型赋值给非抽象构造类型  
let Ctor: new () => HasArea = Shape;  

如果我们原意是执行 new Ctor 这样的代码,抛出异常是正确的行为。但如果我们是想编写 Ctor 的子类,这种限制就显得过于严格。

functon makeSubclassWithArea(Ctor: new () => HasArea) {  
    return class extends Ctor {  
        getArea() {  
            // ...  
        }  
    }  
}  

let MyShape = makeSubclassWithArea(Shape);  

同样的,它同样不能与内置的帮助类,例如 InstanceType,配合使用。

// 错误!  
// 类型 'typeof Shape' 不满足于约束条件 'new (...args: any) => any'。  
// 不能将抽象构造类型赋值给非抽象构造类型  
type MyInstance = InstanceType<typeof Shape>;  

这就是为什么 TypeScript 4.2 要允许你在构造签名中指定抽象指示器。

interface HasArea {  
    getArea(): number;  
}  

// 成功!  
let Ctor: abstract new () => HasArea = Shape;  
//        ^^^^^^^^  

为构造签名增加抽象指示器,意味着你可以将其在抽象构造函数中传递。这不会阻止你向其传递其他的 “具象” 类或构造函数,该指示器实际上只是表示该类不会直接运行构造函数,因此可以安全地传递任何一种类型的类。

此项功能使我们可以用带有抽象类的方式编写 mixin 工厂函数。举例来说,在下面这段代码中,我们可以使用包含了抽象类 SuperClass 的 mixin 函数 withStyles

abstract class SuperClass {  
    abstract someMethod(): void;  
    badda() {}  
}  

type AbstractConstructor<T> = abstract new (...args: any[]) => T  

function withStyles<T extends AbstractConstructor<object>>(Ctor: T) {  
    abstract class StyledClass extends Ctor {  
        getstyles() {  
            // ...  
        }  
    }  
    return StyledClass;  
}  

class SubClass extends withStyles(SuperClass) {  
    someMethod() {  
        this.someMethod()  
    }  
}  

请注意,withStyle 展示的是一条特定规则,必须将扩展自抽象构造函数(如 Ctor) 的类 (如 StyledClass) 也声明为抽象类。这是因为无法知道是否传入了具有更多抽象成员的类,并且也无法知道子类是否实现了所有抽象成员。

你可以在这个Pull Request[12]里,查看更多抽象构造签名的内容。

通过 --explainFiles 了解你的项目结构

对于 TypeScript 用户来说,一个令人惊讶又常见的场景是被问到:“为什么 TypeScript 包含这个文件?” 推断程序文件是一个复杂的过程,这就是为什么一个特定组合的 lib.d.ts 很有必要,为什么 node_modules 中特定的文件需要被包含以及为什么有些文件我们认为应该被排除但是结果却被包含。

这也就是为什么 TypeScript 现在提供了 --explainFiles 标志。

tsc --explainFiles  

当你启用这个选项,TypeScript 编译器将给出一些非常冗长的输出,用于说明某个文件为什么会出现在程序里。为了方便的阅读,你可以把输出内容存入一个文件,或者通过管道输出到更容易阅读的程序中。

# 将输出内容存入文件  
tsc --explainFiles > expanation.txt  

# 通过管道将输出内容发送到工具程序例如`less`,或者像 VS Code 这种编辑器  
tsc --explainFiles | less  

tsc --explainFiles | code -  

通常,输出内容首先会列出包含 lib.d.ts 的原因,然后是本地文件,最后是 node_modules 文件。

TS_Compiler_Directory/4.2.2/lib/lib.es5.d.ts  
  Library referenced via 'es5' from file 'TS_Compiler_Directory/4.2.2/lib/lib.es2015.d.ts'  
TS_Compiler_Directory/4.2.2/lib/lib.es2015.d.ts  
  Library referenced via 'es2015' from file 'TS_Compiler_Directory/4.2.2/lib/lib.es2016.d.ts'  
TS_Compiler_Directory/4.2.2/lib/lib.es2016.d.ts  
  Library referenced via 'es2016' from file 'TS_Compiler_Directory/4.2.2/lib/lib.es2017.d.ts'  
TS_Compiler_Directory/4.2.2/lib/lib.es2017.d.ts  
  Library referenced via 'es2017' from file 'TS_Compiler_Directory/4.2.2/lib/lib.es2018.d.ts'  
TS_Compiler_Directory/4.2.2/lib/lib.es2018.d.ts  
  Library referenced via 'es2018' from file 'TS_Compiler_Directory/4.2.2/lib/lib.es2019.d.ts'  
TS_Compiler_Directory/4.2.2/lib/lib.es2019.d.ts  
  Library referenced via 'es2019' from file 'TS_Compiler_Directory/4.2.2/lib/lib.es2020.d.ts'  
TS_Compiler_Directory/4.2.2/lib/lib.es2020.d.ts  
  Library referenced via 'es2020' from file 'TS_Compiler_Directory/4.2.2/lib/lib.esnext.d.ts'  
TS_Compiler_Directory/4.2.2/lib/lib.esnext.d.ts  
  Library 'lib.esnext.d.ts' specified in compilerOptions  

... More Library References...  

foo.ts  
  Matched by include pattern '**/*' in 'tsconfig.json'  

目前,我们还没发保证输出的格式 —— 在后续的更新中可能会不断变化。值得一提的是,如果你对格式改进有任何好的建议,我们都非常感兴趣。

更多信息,请参照原始 Pull Request[13]。

改进逻辑表达式中非执行函数的检查

多亏了 Alex Tarasyuk[14] 的进一步改进,TypeScript 非执行函数检查现在适用于 &&|| 表达式。

现在在 --strictNullChecks模式下,以下代码会报错:

function shouldDisplayElement(element: Element) {  
    // ...  
    return true;  
}  

function getVisibleItems(elements: Element[]) {  
    return elements.filter(e => shouldDisplayElement && e.children.length)  
    //                                  ~~~~~~~~~~~~~~~~~~~~  
    // 该条件始终会返回 true,因为该函数已经被定义了。  
    // 你是否是想执行它来代替?  
}  

更多信息,详见 Pull Request[15]。

可以将解构变量明确标记为未使用

感谢 Alex Tarasyuk[16] 的另一个 Pull Request,你现在可以通过在解构变量前加上下划线,从而将其标记为未使用变量。

let [_first, second] = getValues();  

之前的版本中,如果 _first 在之后的代码中没有被使用,那么 TypeScript 会抛出一个 noUnusedLocals 错误。现在,TypeScript 会认识到 _first 只声明不调用是有意为之。

详情可见这个 Pull Request [17]。

在可选属性和字符串索引签名之间宽松的规则

字符串索引签名是用于键入类似字典对象的方式 —— 当你想允许该对象包含使用任意键名。

const movieWatchCount: { [key: string]: number } = {};  

function watchMovie(title: string) {  
  movieWatchCount[title] = (movieWatchCount[title] ?? 0) + 1;  
}  

当然,现在该典中没有包含任何电影标题,movieWatchCount[title] 将返回 undefined (TypeScript 4.1 中新增了一个选项 --noUncheckedIndexedAccess 会给字符串索引签名自动添加 undefined 可选类型)。即使很明显,movieWatchCount 必将在之后包含某些字符串,但由于存在 undefined,之前的版本的 TypeScript ,仍然会将可选对象属性视为无法分配给其他兼容的索引签名。

type WesAndersonWatchCount = {  
  "Fantastic Mr. Fox"?: number;  
  "The Royal Tenenbaums"?: number;  
  "Moonrise Kingdom"?: number;  
  "The Grand Budapest Hotel"?: number;  
};  

declare const wesAndersonWatchCount: WesAndersonWatchCount;  
const movieWatchCount: { [key: string]: number } = wesAndersonWatchCount;  
//    ~~~~~~~~~~~~~~~ 错误!  
// 类型 'WesAndersonWatchCount' 不能赋值给 '{ [key: string]: number; }'.  
// 属性 '"Fantastic Mr. Fox"' 和索引签名不兼容。  
// 类型 'number | undefined' 不能赋值给 'number'.  
// 类型 'undefined' 不能赋值给类型 'number'. (2322)  

TypeScript 4.2 允许这个指派。但是,它不允许分配类型为 undefined 的非可选属性,也不允许将 undefined 写入特定键:

type BatmanWatchCount = {  
  "Batman Begins": number | undefined;  
  "The Dark Knight": number | undefined;  
  "The Dark Knight Rises": number | undefined;  
};  

declare const batmanWatchCount: BatmanWatchCount;  

// TypeScript 4.2. 依然会报错  
// `undefined` 指挥在属性被标记为可选时才会被忽略。  
const movieWatchCount: { [key: string]: number } = batmanWatchCount;  

// TypeScript 4.2. 依然会报错  
// 索引签名不允许显示定义为  `undefined`。  
movieWatchCount["It's the Great Pumpkin, Charlie Brown"] = undefined;  

新规则同样不适用于数字索引签名,因为它们被假定为类似数组的密集型数据结构。

你可以通过阅读这个 Pull Request[18] 更好的理解这条规则。

声明缺少的帮助函数

感谢 Alex Tarasyuk[19] 实现的来自社区的 Pull Request[20],我们现在有了一个在声明新函数和方法时的快速修复。

TypeScript 4.2 有哪些新特性

图片

破坏性变化

我们始终致力于将发布中的破坏性变化降至最低。TypeScript 4.2 包含一些破坏性变化,但我们认为它们在升级过程中时可控的。

lib.d.ts 升级

如同每个版本的 TypeScript 升级,lib.d.ts 的声明(特别是为 Web 上下文生成的声明)都会发生改变。升级的变化有很多,但是最终可能最具有破坏性的是 IntlResizeObserver

onImplicitAny 错误适用于宽松的 yield 表达式

当捕获了 yield 表达式的值,但是 TypeScript 不能立刻分辨出你打算接受的类型是哪种时(举个例子:yield 表达式未按上下文类型输入),TypeScript 现在会发出类型隐式声明为 any 的错误。

function* g1() {  
  const value = yield 1;  
  //            ~~~~~~~  
  // 错误!  
  // 'yield' 表达式隐式返回 'any' 类型  
  // 因为其包含缺少返回类型注释的生成器。  
}  

function* g2() {  
  // 正确。  
  // `yield 1` 的结果未被使用。  
  yield 1;  
}  

function* g3() {  
  // 正确。  
  // `yield 1` 根据上下文被定义为 'string' 类型。  
  const value: string = yield 1;  
}  

function* g3(): Generator<number, void, string> {  
  // 正确。  
  // 通过 'g3' 的显式返回结果,  
  // TypeScript 可以知道 'yield 1' 的类型  
  const value = yield 1;  
}  

详情可见这个 Pull Request[21]。

扩展的未执行函数检查

如上文所述,当启用 --strictNullChecks 模式时,未执行函数检查将会在 &&|| 表达式中一致执行。这可能会成为一个潜在的破坏性,但通常表示现有代码中存在逻辑错误。

JavaScript 中的类型参数不会被解析未类型参数

JavaScript 中已经不允许使用类型参数,但是在 TypeScript 4.2 中,解析器将以更符合规范的方式解析它们。所以,当在 JavaScript 中编写以下代码时:

f<T>(100)  

TypeScript 会把它解析为以下代码:

(f < T) > (100)  

当你利用 TypeScript 的 API 来解析 JavaScript 文件中的类型构造,你可能会感到困扰。

in 运算符不再允许右侧的值为基类型

"foo" in 42  
//       ~~  
// 错误!'in' 表达式右侧不允许是基类型  

详细请参考这个 Pull Request[22]。

元组中的扩展运算符大小受限

TypeScript 中元组类型可以由任何类型的扩展运算语法生成

// 由扩展元素生成的元组类型  
type NumStr = [number, string];  
type NumStrNumStr = [...NumStr, ...NumStr];  

// 数组扩展运算  
const numStr = [123, "hello"] as const;  
const numStrNumStr = [...numStr, ...numStr] as const;  

有时,这些元组类型可能会意外的变得巨大,这会使类型检查花费很长时间。为了防止类型检查挂起(这在编辑器场景中尤为糟糕),TypeScript 使用一个限制器来避免这个情况的发生。

你可以在这个 Pull Request[23] 中查看详情。

.d.ts 扩展名的文件不能被 Import

// 必须改为类似于以下类型:  
//   - "./foo"  
//   - "./foo.js"  
import { Foo } from "./foo.d.ts";  

导入路径应反映加载程序在运行时将执行的操作。以下几种导入都是等价的:

import { Foo } from "./foo";  
import { Foo } from "./foo.js";  
import { Foo } from "./foo/index.js";  

恢复模板字面推断

这个改变是从 TypeScript 4.2 Beta 版中删除了一个功能。如果你还没有升级到我们最新的一个稳定版,你可以不必关注这个。但是,也许你也有兴趣简单了解一下。

TypeScript 4.2 Beta 版包含了一个对模板字符串推断的更改。在这个更改中,模板字符串字面或者被定义为给定的模板字符串类型或者简化为多个字符串字面类型。这些类型当被赋值给变量时,都会被扩大为字符串类型。

declare const yourName: string;  

// 'bar' 是常量。  
// 它拥有类型 '`hello ${string}`'.  
const bar = `hello ${yourName}`;  


// 'baz' 是变量  
// 它拥有类型 'string'.  
let baz = `hello ${yourName}`;  

这类似于字符串字面推断的工作方式。

// 'bar' 的类型是 '"hello"'.  
const bar = "hello";  

// 'baz' 的类型是 'string'.  
let baz = "hello";  

因此,我们认为拥有字符串类型的模板字符串表达式应该为 “常量”。但是,从我们所见所得的情况来看,这并不总是可取的。

作为回应,我们恢复了这个功能(以及潜在的破坏性)。如果你确实想要给一个模板字符串表达式定义为字面量类型,你可以在它的后面增加 as const

declare const yourName: string;  

// 'bar' 拥有类型 '`hello ${string}`'.  
const bar = `hello ${yourName}` as const;  
//                              ^^^^^^^^  

// 'baz' 拥有类型 'string'.  
const baz = `hello ${yourName}`;  

TypeScript lift Callback 在 visitNode 中使用不同的类型

TypeScript 具有带lift功能的 visitNode 函数。现在,lift 需要一个只读的 Node[] 而非 NodeArray<Node>

从技术上讲,这是一个API重大更改,您可以在此处阅读更多内容。

收藏
评论区