TypeScript 4.2

更智能地保留类型别名

在 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,Visual Studio Code 或者 TypeScript 演练场编辑器中把鼠标光标放在 x 上,我们就会看到信息面板中显示出了 BasicPrimitive 类型。 同样地,如果我们查看由该文件生成的声明文件(.d.ts),那么 TypeScript 会显示出 doStuff 的返回值类型为 BasicPrimitive 类型。

那么你猜一猜,如果返回值类型为 BasicPrimitiveundefined 时会发生什么?

export type BasicPrimitive = number | string | boolean;

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

    return value;
}

可以在TypeScript 4.1 演练场中查看结果。 虽然我们希望 TypeScript 将 doStuff 的返回值类型显示为 BasicPrimitive | undefined,但是它却显示成了 string | number | boolean | undefined 类型! 这是怎么回事?

这与 TypeScript 内部的类型表示方式有关。 当基于一个联合类型来创建另一个联合类型时,TypeScript 会将类型标准化,也就是把类型展开为一个新的联合类型 - 但这么做也可能会丢失信息。 类型检查器不得不根据 string | number | boolean | undefined 类型来尝试每一种可能的组合并查看使用了哪些类型别名,即便这样也可能会有多个类型别名指向 string | number | boolean 类型。

TypeScript 4.2 的内部实现更加智能了。 我们会记录类型是如何被构造的,会记录它们原本的编写方式和之后的构造方式。 我们同样会记录和区分不同的类型别名!

有能力根据类型使用的方式来回显这个类型就意味着,对于 TypeScript 用户来讲能够避免显示很长的类型;同时也意味着会生成更友好的 .d.ts 声明文件、错误消息和编辑器内显示的类型及签名帮助信息。 这会让 TypeScript 对于初学者来讲更友好一些。

更多详情,请参考PR:改进保留类型别名的联合,以及PR:保留间接的类型别名

元组类型中前导的/中间的剩余元素

在 TypeScript 中,元组类型用于表示固定长度和元素类型的数组。

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

// 存储了一个string,一个number和一个boolean的元组
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 只允许 ...rest 元素位于元组的末尾。

但现在,剩余元素可以出现在元组中的任意位置 - 但有一点限制。

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[]];
//                                    ~~~~~~~~~~ 错误

let StringsAndMaybeBoolean: [...string[], boolean?];
//                                        ~~~~~~~~ 错误

这些不在结尾的剩余元素能够用来描述,可接收任意数量的前导参数加上固定数量的结尾参数的函数。

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

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

尽管 JavaScript 中没有声明前导剩余参数的语法,但我们仍可以将 doStuff 函数的参数声明为带有前导剩余元素 ...args 的元组类型。 使用这种方式可以帮助我们描述许多的 JavaScript 代码!

更多详情,请参考 PR

更严格的 in 运算符检查

在 JavaScript 中,如果 in 运算符的右操作数是非对象类型,那么会产生运行时错误。 TypeScript 4.2 确保了该错误能够在编译时被捕获。

'foo' in 42;
// The right-hand side of an 'in' expression must not be a primitive.

这个检查在大多数情况下是相当保守的,如果你看到提示了这个错误,那么代码中很可能真的有问题。

非常感谢外部贡献者 Jonas HübotterPR

--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 允许使用点语法来访问通过字符串索引签名定义的属性。 这会让从 JavaScript 代码到 TypeScript 代码的迁移工作变得容易。

然而,放宽限制同样意味着更容易出现属性名拼写错误。

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

    /**
     * 这会将其余所有未声明的属性定义为 'any' 类型。
     */
    [x: string]: any;
}
// ---cut---
function processOptions(opts: Options) {
    // ...

    // 注意,我们不小心访问了错误的 `excludes`。
    // 但是!这是合法的!
    for (const excludePattern of opts.excludes) {
        // ...
    }
}

在某些情况下,用户会想要选择使用索引签名 - 在使用点号语法进行属性访问时,如果访问了没有明确定义的属性,就得到一个错误。

这就是为什么 TypeScript 引入了一个新的 --noPropertyAccessFromIndexSignature 编译选项。 在该模式下,你可以有选择的启用 TypeScript 之前的行为,即在上述使用场景中产生错误。 该编译选项不属于 strict 编译选项集合的一员,因为我们知道该功能只适用于部分用户。

更多详情,请参考 PR。 我们同时要感谢 Wenlu Wang 为该功能的付出!

abstract 构造签名

TypeScript 允许将一个类标记为 abstract。 这相当于告诉 TypeScript 这个类只是用于继承,并且有些成员需要在子类中实现,以便能够真正地创建出实例。

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

// 不能创建抽象类的实例
new Shape();

class Square extends Shape {
    #sideLength: number;

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

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

// 没问题
new Square(42);

为了能够确保一贯的对 new 一个 abstract 类进行限制,不允许将 abstract 类赋值给接收构造签名的值。

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

interface HasArea {
    getArea(): number;
}

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

如果有代码调用了 new Ctor,那么上述的行为是正确的,但若想要编写 Ctor 的子类,就会出现过度限制的情况。

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

interface HasArea {
    getArea(): number;
}

function makeSubclassWithArea(Ctor: new () => HasArea) {
    return class extends Ctor {
        getArea() {
            return 42;
        }
    };
}

// 不能将抽象构造函数类型赋值给非抽象构造函数类型。
let MyShape = makeSubclassWithArea(Shape);

对于内置的工具类型InstanceType来讲,它也不是工作得很好。

// 错误!
// 不能将抽象构造函数类型赋值给非抽象构造函数类型。
type MyInstance = InstanceType<typeof Shape>;

这就是为什么 TypeScript 4.2 允许在构造签名上指定 abstract 修饰符。

abstract class Shape {
  abstract getArea(): number;
}
// ---cut---
interface HasArea {
    getArea(): number;
}

// Works!
let Ctor: abstract new () => HasArea = Shape;

在构造签名上添加 abstract 修饰符表示可以传入一个 abstract 构造函数。 它不会阻止你传入其它具体的类/构造函数 - 它只是想表达不会直接调用这个构造函数,因此可以安全地传入任意一种类类型。

这个特性允许我们编写支持抽象类的混入工厂函数。 例如,在下例中,我们可以同时使用混入函数 withStylesabstractSuperClass

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()
    }
}

注意,withStyles 展示了一个特殊的规则,若一个类(StyledClass)继承了被抽象构造函数所约束的泛型值,那么这个类也需要被声明为 abstract。 由于无法知道传入的类是否拥有更多的抽象成员,因此也无法知道子类是否实现了所有的抽象成员。

更多详情,请参考 PR

使用 --explainFiles 来理解工程的结构

TypeScript 用户时常会问“为什么 TypeScript 包含了这个文件?”。 推断程序中所包含的文件是个很复杂的过程,比如有很多原因会导致使用了 lib.d.ts 文件的组合,会导致 node_modules 中的文件被包含进来,会导致有些已经 exclude 的文件被包含进来。

这就是 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'

目前,TypeScript 不保证输出文件的格式 - 它在将来可能会改变。 关于这一点,我们也打算改进输出文件格式,请给出你的建议!

更多详情,请参考 PR

改进逻辑表达式中的未被调用函数检查

感谢 Alex Tarasyuk 提供的持续改进,TypeScript 中的未调用函数检查现在也作用于 &&|| 表达式。

--strictNullChecks 模式下,下面的代码会产生错误。

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

function getVisibleItems(elements: Element[]) {
    return elements.filter((e) => shouldDisplayElement && e.children.length);
    //                          ~~~~~~~~~~~~~~~~~~~~
    // 该条件表达式永远返回 true,因为函数永远是定义了的。
    // 你是否想要调用它?
}

更多详情,请参考 PR

解构出来的变量可以被明确地标记为未使用的

感谢 Alex Tarasyuk 提供的另一个 PR,你可以使用下划线(_ 字符)将解构变量标记为未使用的。

let [_first, second] = getValues();

在之前,如果 _first 未被使用,那么在启用了 noUnusedLocals 时 TypeScript 会产生一个错误。 现在,TypeScript 会识别出使用了下划线的 _first 变量是有意的未使用的变量。

更多详情,请参考 PR

放宽了在可选属性和字符串索引签名间的限制

字符串索引签名可用于为类似于字典的对象添加类型,它表示允许使用任意的键来访问对象:

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 中仍是错误。
const movieWatchCount: { [key: string]: number } = batmanWatchCount;

// 在 TypeScript 4.2 中仍是错误。
// 索引签名不允许显式地赋值 `undefined`。
movieWatchCount["It's the Great Pumpkin, Charlie Brown"] = undefined;

这条新规则不适用于数字索引签名,因为它们被当成是类数组的并且是稠密的:

declare let sortOfArrayish: { [key: number]: string };
declare let numberKeys: { 42?: string };

sortOfArrayish = numberKeys;

更多详情,请参考 PR

声明缺失的函数

感谢 Alexander Tarasyuk 提交的 PR,TypeScript 支持了一个新的快速修复功能,那就是根据调用方来生成新的函数和方法声明!

一个未被声明的 foo 函数被调用了,使用快速修复