前言

Repo: https://github.com/zhongsp/TypeScript

该工程是对 TypeScript 官方及开源社区书写的编程手册、版本发布说明等综合内容的中文翻译。 感谢 Microsoft 和开源社区的工程师们的工作,为 JavaScript 开发带来了全新的体验!

这个项目是我在 2015 年创建的,没想到已经维护快 7 年了,它已然是我参与过的时间最长的项目。 在 2015 年之前,我都是在使用 JavaScript 语言,主要参与的项目也大都是采用 AngularJS 框架的项目,没有接触过 TypeScript。 那时候,TypeScript 在国内项目里用的好像不多,但是在国外已经有不少项目开始采用这个新技术。 2015 年,我正好参与了一个和国外一起合作的项目,决定使用 TypeScript 1.x。 也正因为这个机会,我开始了 TypeScript 的学习。 学习没多久,我就喜欢上了这个语言,并且确信这个东西一定能火。 因为作为一个多年的 JavaScript 程序员来讲,我很清楚它解决了多少痛点(必须得把 VS Code 一起代上)。

早些时候,TypeScript 的文档也不多。 原因之一,TypeScript 是 JavaScript 的超集,JavaScript 的知识点已经有足够的资料了,TypeScript 一笔代过。 原因之二,早期的 TypeScript 里特性不多,知识点不多。原因之三,它的文档相较于做的好的语言来讲确实较弱,可能没什么专门的团队负责,或者没有专职的 technical writer 去写作。 于是,我决定边学边翻译,一方面为了自己,另一方面为了其它小伙伴。

哪些内容会继续更新?

我会继续翻译 TypeScript 新版本的 Release Notes。

哪些内容可能不会继续更新?

这个项目中的 Handbook 是翻译老版本的 Handbook。 TypeScript 官网大约从 2020 年开始要打造新版的官网,其中包括官网的样式,以及要重写大部分的文档。 目前,我不打算再翻译一遍新版的 Handbook。 我看了下新版的手册,确实优化了不少,但也不代表老版本是无用的或错误的。

现在,TypeScript 官网也开始支持国际化了,已经有部分文档翻译成了中文,我之前还翻译了一篇。 本着开源和为社区服务的精神,推荐学有余力的同学直接给官网提交翻译的 Pull Reuqest,造福开发者。

关于《TypeScript入门与实战》一书

因为长期维护 TypeScript 更新的内容再加上在项目中一直使用 TypeScript, 所以有机会将知识进行梳理总结成书。

我出版了《TypeScript入门与实战》一书。

TypeScript入门与实战

在该书中,尝试着尽可能完整地介绍TypeScript语言的基础知识,并结合了一些本人的使用经验和体会。 它主要面向的是TypeScript语言的初级和中级使用者。 本人还处于TypeScript语言的学习阶段,可能存在理解错误的地方,还请大家指正,一起进步。 但需要强调的是,本书不是对 Handbook 的翻译。

感谢

在过去的七年中,有很多素不相识、极富开源精神的小伙伴们曾参与到本工程的翻译与校对工作中。 对你们表示感谢!同时也欢迎其它任何想参与到该工程中的朋友们,贡献你们的力量!

快速上手

5 分钟了解 TypeScript

让我们使用 TypeScript 来创建一个简单的 Web 应用。

安装 TypeScript

有两种主要的方式来获取 TypeScript 工具:

  • 通过 npm(Node.js 包管理器)
  • 安装 Visual Studio 的 TypeScript 插件

Visual Studio 2017 和 Visual Studio 2015 Update 3 默认包含了 TypeScript。 如果你的 Visual Studio 还没有安装 TypeScript,你可以下载它。

针对使用 npm 的用户:

> npm install -g typescript

构建你的第一个 TypeScript 文件

在编辑器,将下面的代码输入到greeter.ts文件里:

function greeter(person) {
  return 'Hello, ' + person;
}

let user = 'Jane User';

document.body.textContent = greeter(user);

编译代码

我们使用了.ts扩展名,但是这段代码仅仅是 JavaScript 而已。 你可以直接从现有的 JavaScript 应用里复制/粘贴这段代码。

在命令行上,运行 TypeScript 编译器:

tsc greeter.ts

输出结果为一个greeter.js文件,它包含了和输入文件中相同的 JavsScript 代码。 一切准备就绪,我们可以运行这个使用 TypeScript 写的 JavaScript 应用了!

接下来让我们看看 TypeScript 工具带来的高级功能。 给person函数的参数添加: string类型注解,如下:

function greeter(person: string) {
  return 'Hello, ' + person;
}

let user = 'Jane User';

document.body.textContent = greeter(user);

类型注解

TypeScript 里的类型注解是一种轻量级的为函数或变量添加约束的方式。 在这个例子里,我们希望greeter函数接收一个字符串参数。 然后尝试把greeter的调用改成传入一个数组:

function greeter(person: string) {
  return 'Hello, ' + person;
}

let user = [0, 1, 2];

document.body.textContent = greeter(user);

重新编译,你会看到产生了一个错误。

error TS2345: Argument of type 'number[]' is not assignable to parameter of type 'string'.

类似地,尝试删除greeter调用的所有参数。 TypeScript 会告诉你使用了非期望个数的参数调用了这个函数。 在这两种情况中,TypeScript 提供了静态的代码分析,它可以分析代码结构和提供的类型注解。

要注意的是尽管有错误,greeter.js文件还是被创建了。 就算你的代码里有错误,你仍然可以使用 TypeScript。但在这种情况下,TypeScript 会警告你代码可能不会按预期执行。

接口

让我们开发这个示例应用。这里我们使用接口来描述一个拥有firstNamelastName字段的对象。 在 TypeScript 里,只要两个类型内部的结构兼容那么这两个类型就是兼容的。 这就允许我们在实现接口时候只要保证包含了接口要求的结构就可以,而不必明确地使用implements语句。

interface Person {
  firstName: string;
  lastName: string;
}

function greeter(person: Person) {
  return 'Hello, ' + person.firstName + ' ' + person.lastName;
}

let user = { firstName: 'Jane', lastName: 'User' };

document.body.textContent = greeter(user);

最后,让我们使用类来改写这个例子。 TypeScript 支持 JavaScript 的新特性,比如支持基于类的面向对象编程。

让我们创建一个Student类,它带有一个构造函数和一些公共字段。 注意类和接口可以一起工作,程序员可以自行决定抽象的级别。

还要注意的是,在构造函数的参数上使用public等同于创建了同名的成员变量。

class Student {
  fullName: string;
  constructor(
    public firstName: string,
    public middleInitial: string,
    public lastName: string
  ) {
    this.fullName = firstName + ' ' + middleInitial + ' ' + lastName;
  }
}

interface Person {
  firstName: string;
  lastName: string;
}

function greeter(person: Person) {
  return 'Hello, ' + person.firstName + ' ' + person.lastName;
}

let user = new Student('Jane', 'M.', 'User');

document.body.textContent = greeter(user);

重新运行tsc greeter.ts,你会看到生成的 JavaScript 代码和原先的一样。 TypeScript 里的类只是 JavaScript 里常用的基于原型面向对象编程的简写。

运行 TypeScript Web 应用

greeter.html里输入如下内容:

<!DOCTYPE html>
<html>
    <head><title>TypeScript Greeter</title></head>
    <body>
        <script src="greeter.js"></script>
    </body>
</html>

在浏览器里打开greeter.html运行这个应用!

可选地:在 Visual Studio 里打开greeter.ts或者把代码复制到 TypeScript playground。 将鼠标悬停在标识符上查看它们的类型。 注意在某些情况下它们的类型可以被自动地推断出来。 重新输入一下最后一行代码,看一下自动补全列表和参数列表,它们会根据 DOM 元素类型而变化。 将光标放在greeter函数上,点击 F12 可以跟踪到它的定义。 还有一点,你可以右键点击标识,使用重构功能来重命名。

这些类型信息以及工具可以很好的和 JavaScript 一起工作。 更多的 TypeScript 功能演示,请查看本网站的示例部分。

ASP.NET Core

ASP.NET Core

安装 ASP.NET Core 和 TypeScript

首先,若有需要请安装 ASP.NET Core。此篇指南需要使用 Visual Studio 2015 或 2017。

其次,如果你的 Visual Studio 不带有最新版本的 TypeScript,你可以从这里安装。

新建工程

  1. 选择 File

  2. 选择 New Project (Ctrl + Shift + N)

  3. 选择 Visual C#

  4. 若使用 VS2015,选择 ASP.NET Web Application > ASP.NET 5 Empty,并且取消勾选“Host in the cloud”,因为我们要在本地运行。

    使用空白模版

  5. 若使用 VS2017,选择 ASP.NET Core Web Application (.NET Core) > ASP.NET Core 1.1 Empty

    使用空白模版VS2017

运行此应用以确保它能正常工作。

设置服务项

VS2015

project.json 文件的 "dependencies" 字段里添加:

"Microsoft.AspNet.StaticFiles": "1.0.0-rc1-final"

最终的 dependencies 部分应该类似于下面这样:

"dependencies": {
  "Microsoft.AspNet.IISPlatformHandler": "1.0.0-rc1-final",
  "Microsoft.AspNet.Server.Kestrel": "1.0.0-rc1-final",
  "Microsoft.AspNet.StaticFiles": "1.0.0-rc1-final"
},

用以下内容替换 Startup.cs 文件里的 Configure 函数:

public void Configure(IApplicationBuilder app)
{
    app.UseIISPlatformHandler();
    app.UseDefaultFiles();
    app.UseStaticFiles();
}

VS2017

打开 Dependencies > Manage NuGet Packages > Browse。搜索并安装Microsoft.AspNetCore.StaticFiles 1.1.2:

安装Microsoft.AspNetCore.StaticFiles

如下替换掉Startup.csConfigure的内容:

public void Configure(IApplicationBuilder app)
{
    app.UseDefaultFiles();
    app.UseStaticFiles();
}

你可能需要重启 VS,这样UseDefaultFilesUseStaticFiles下面的波浪线才会消失。

添加 TypeScript

下一步我们为 TypeScript 添加一个文件夹。

Create new folder

将文件夹命名为 scripts

scripts folder

添加 TypeScript 代码

scripts上右击并选择New Item。 接着选择TypeScript File(也可能 .NET Core 部分),并将此文件命名为app.ts

New item

添加示例代码

将以下代码写入 app.ts 文件。

function sayHello() {
  const compiler = (document.getElementById('compiler') as HTMLInputElement)
    .value;
  const framework = (document.getElementById('framework') as HTMLInputElement)
    .value;
  return `Hello from ${compiler} and ${framework}!`;
}

构建设置

配置 TypeScript 编译器

我们先来告诉 TypeScript 怎样构建。 右击 scripts 文件夹并选择New Item。 接着选择TypeScript Configuration File,保持文件的默认名字为tsconfig.json

Create tsconfig.json

将默认的tsconfig.json内容改为如下所示:

{
  "compilerOptions": {
    "noImplicitAny": true,
    "noEmitOnError": true,
    "sourceMap": true,
    "target": "es5"
  },
  "files": [
    "./app.ts"
  ],
  "compileOnSave": true
}

看起来和默认的设置差不多,但注意以下不同之处:

  1. 设置"noImplicitAny": true
  2. 显式列出了"files"而不是依据"excludes"
  3. 设置"compileOnSave": true

当你写新代码时,设置"noImplicitAny"选项是个不错的选择 — 这可以确保你不会错写任何新的类型。 设置"compileOnSave"选项可以确保你在运行 web 程序前自动编译保存变更后的代码。

配置 NPM

现在,我们来配置 NPM 以使用我们能够下载 JavaScript 包。 在工程上右击并选择New Item。 接着选择NPM Configuration File,保持文件的默认名字为package.json。 在"devDependencies"部分添加"gulp"和"del":

"devDependencies": {
  "gulp": "3.9.0",
  "del": "2.2.0"
}

保存这个文件后,Visual Studio 将开始安装 gulp 和 del。 若没有自动开始,请右击 package.json 文件选择Restore Packages

设置 gulp

最后,添加一个新 JavaScript 文件gulpfile.js。 键入以下内容:

/// <binding AfterBuild='default' Clean='clean' />
/*
This file is the main entry point for defining Gulp tasks and using Gulp plugins.
Click here to learn more. http://go.microsoft.com/fwlink/?LinkId=518007
*/

var gulp = require('gulp');
var del = require('del');

var paths = {
  scripts: ['scripts/**/*.js', 'scripts/**/*.ts', 'scripts/**/*.map'],
};

gulp.task('clean', function () {
  return del(['wwwroot/scripts/**/*']);
});

gulp.task('default', function () {
  gulp.src(paths.scripts).pipe(gulp.dest('wwwroot/scripts'));
});

第一行是告诉 Visual Studio 构建完成后,立即运行'default'任务。 当你应答 Visual Studio 清除构建内容后,它也将运行'clean'任务。

现在,右击gulpfile.js并选择Task Runner Explorer。 若'default'和'clean'任务没有显示输出内容的话,请刷新 explorer:

Refresh Task Runner Explorer

编写 HTML 页

wwwroot中添加一个新建项 index.html。 在index.html中写入以下代码:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <script src="scripts/app.js"></script>
    <title></title>
</head>
<body>
    <div id="message"></div>
    <div>
        Compiler: <input id="compiler" value="TypeScript" onkeyup="document.getElementById('message').innerText = sayHello()" /><br />
        Framework: <input id="framework" value="ASP.NET" onkeyup="document.getElementById('message').innerText = sayHello()" />
    </div>
</body>
</html>

测试

  1. 运行项目。
  2. 在输入框中键入时,您应该看到一个消息:

Picture of running demo

调试

  1. 在 Edge 浏览器中,按 F12 键并选择 Debugger 标签页。
  2. 展开 localhost 列表,选择 scripts/app.ts
  3. return 那一行上打一个断点。
  4. 在输入框中键入一些内容,确认 TypeScript 代码命中断点,观察它是否能正确地工作。

Demo paused on breakpoint

这就是你需要知道的在 ASP.NET 中使用 TypeScript 的基本知识了。 接下来,我们引入 Angular,写一个简单的 Angular 程序示例。

添加 Angular 2

使用 NPM 下载依赖的包

添加 Angular 2 和 SystemJS 到package.jsondependencies里。

对于 VS2015,新的dependencies列表如下:

"dependencies": {
  "angular2": "2.0.0-beta.11",
  "systemjs": "0.19.24",
  "gulp": "3.9.0",
  "del": "2.2.0"
},

若使用 VS2017,因为 NPM3 反对同行的依赖(peer dependencies),我们需要把 Angular 2 同行的依赖也直接列为依赖项:

"dependencies": {
  "angular2": "2.0.0-beta.11",
  "reflect-metadata": "0.1.2",
  "rxjs": "5.0.0-beta.2",
  "zone.js": "^0.6.4",
  "systemjs": "0.19.24",
  "gulp": "3.9.0",
  "del": "2.2.0"
},

更新 tsconfig.json

现在安装好了 Angular 2 及其依赖项,我们需要启用 TypeScript 中实验性的装饰器支持。 我们还需要添加 ES2015 的声明,因为 Angular 使用 core-js 来支持像Promise的功能。 在未来,装饰器会成为默认设置,那时也就不再需要这些设置了。

添加"experimentalDecorators": true, "emitDecoratorMetadata": true"compilerOptions"部分。 然后,再添加"lib": ["es2015", "es5", "dom"]"compilerOptions",以引入 ES2015 的声明。 最后,我们需要添加"./model.ts""files"里,我们接下来会创建它。 现在tsconfig.json看起来如下:

{
  "compilerOptions": {
    "noImplicitAny": true,
    "noEmitOnError": true,
    "sourceMap": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "target": "es5",
    "lib": [
      "es2015", "es5", "dom"
    ]
  },
  "files": [
    "./app.ts",
    "./model.ts",
    "./main.ts",
  ],
  "compileOnSave": true
}

将 Angular 添加到 gulp 构建中

最后,我们需要确保 Angular 文件作为 build 的一部分复制进来。 我们需要添加:

  1. 库文件目录。
  2. 添加一个 lib 任务来输送文件到 wwwroot
  3. default 任务上添加 lib 任务依赖。

更新后的 gulpfile.js 像如下所示:

/// <binding AfterBuild='default' Clean='clean' />
/*
This file is the main entry point for defining Gulp tasks and using Gulp plugins.
Click here to learn more. http://go.microsoft.com/fwlink/?LinkId=518007
*/

var gulp = require('gulp');
var del = require('del');

var paths = {
    scripts: ['scripts/**/*.js', 'scripts/**/*.ts', 'scripts/**/*.map'],
    libs: ['node_modules/angular2/bundles/angular2.js',
           'node_modules/angular2/bundles/angular2-polyfills.js',
           'node_modules/systemjs/dist/system.src.js',
           'node_modules/rxjs/bundles/Rx.js']
};

gulp.task('lib', function () {
    gulp.src(paths.libs).pipe(gulp.dest('wwwroot/scripts/lib'));
});

gulp.task('clean', function () {
    return del(['wwwroot/scripts/**/*']);
});

gulp.task('default', ['lib'], function () {
    gulp.src(paths.scripts).pipe(gulp.dest('wwwroot/scripts'));
});

此外,保存了此 gulpfile 后,要确保 Task Runner Explorer 能看到 lib 任务。

用 TypeScript 写一个简单的 Angular 应用

首先,将 app.ts 改成:

import { Component } from 'angular2/core';
import { MyModel } from './model';

@Component({
  selector: `my-app`,
  template: `<div>Hello from {{ getCompiler() }}</div>`,
})
export class MyApp {
  model = new MyModel();
  getCompiler() {
    return this.model.compiler;
  }
}

接着在scripts中添加 TypeScript 文件model.ts:

export class MyModel {
  compiler = 'TypeScript';
}

再在scripts中添加main.ts

import { bootstrap } from 'angular2/platform/browser';
import { MyApp } from './app';
bootstrap(MyApp);

最后,将index.html改成:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <script src="scripts/lib/angular2-polyfills.js"></script>
    <script src="scripts/lib/system.src.js"></script>
    <script src="scripts/lib/rx.js"></script>
    <script src="scripts/lib/angular2.js"></script>
    <script>
    System.config({
        packages: {
            'scripts': {
                format: 'cjs',
                defaultExtension: 'js'
            }
        }
    });
    System.import('scripts/main').then(null, console.error.bind(console));
    </script>
    <title></title>
</head>
<body>
    <my-app>Loading...</my-app>
</body>
</html>

这里加载了此应用。 运行 ASP.NET 应用,你应该能看到一个 div 显示"Loading..."紧接着更新成显示"Hello from TypeScript"。

ASP.NET 4

ASP.NET 4

注意: 此教程已从官方删除

安装 TypeScript

如果你使用的 Visual Studio 版本还不支持 TypeScript, 你可以安装 Visual Studio 2015 或者 Visual Studio 2013。 这个快速上手指南使用的是 Visual Studio 2015。

新建项目

  1. 选择 File

  2. 选择 New Project

  3. 选择 Visual C#

  4. 选择 ASP.NET Web Application

    Create new ASP.NET project

  5. 选择 MVC

    取消复选 "Host in the cloud" 本指南将使用一个本地示例。 Use MVC template

运行此应用以确保它能正常工作。

添加 TypeScript

下一步我们为 TypeScript 添加一个文件夹。

Create new folder

将文件夹命名为 src。

src folder

添加 TypeScript 代码

src 上右击并选择 New Item。 接着选择 TypeScript File 并将此文件命名为 app.ts

New item

添加示例代码

将以下代码写入 app.ts 文件。

function sayHello() {
  const compiler = (document.getElementById('compiler') as HTMLInputElement)
    .value;
  const framework = (document.getElementById('framework') as HTMLInputElement)
    .value;
  return `Hello from ${compiler} and ${framework}!`;
}

构建设置

右击项目并选择 New Item。 接着选择 TypeScript Configuration File 保持文件的默认名字为 tsconfig.json

Create tsconfig.json

将默认的 tsconfig.json 内容改为如下所示:

{
  "compilerOptions": {
    "noImplicitAny": true,
    "noEmitOnError": true,
    "sourceMap": true,
    "target": "es5",
    "outDir": "./Scripts/App"
  },
  "files": [
    "./src/app.ts",
  ],
  "compileOnSave": true
}

看起来和默认的设置差不多,但注意以下不同之处:

  1. 设置 "noImplicitAny": true
  2. 特别是这里 "outDir": "./Scripts/App"
  3. 显式列出了 "files" 而不是依据 "excludes"选项。
  4. 设置 "compileOnSave": true

当你写新代码时,设置 "noImplicitAny" 选项是个好主意 — 这可以确保你不会错写任何新的类型。 设置 "compileOnSave" 选项可以确保你在运行 web 程序前自动编译保存变更后的代码。 更多信息请参见 the tsconfig.json documentation

在视图中调用脚本

  1. Solution Explorer 中, 打开 Views | Home | Index.cshtml

    Open Index.cshtml

  2. 修改代码如下:

    @{
        ViewBag.Title = "Home Page";
    }
    <script src="~/Scripts/App/app.js"></script>
    <div id="message"></div>
    <div>
        Compiler: <input id="compiler" value="TypeScript" onkeyup="document.getElementById('message').innerText = sayHello()" /><br />
        Framework: <input id="framework" value="ASP.NET" onkeyup="document.getElementById('message').innerText = sayHello()" />
    </div>
    

测试

  1. 运行项目。
  2. 在输入框中键入时,您应该看到一个消息:

Picture of running demo

调试

  1. 在 Edge 浏览器中, 按 F12 键并选择 Debugger 标签页。
  2. 展开 localhost 列表, 选择 src/app.ts
  3. return 那一行上打一个断点。
  4. 在输入框中键入一些内容,确认 TypeScript 代码命中断点,观察它是否能正确地工作。

Demo paused on breakpoint

这就是你需要知道的在 ASP.NET 中使用 TypeScript 的基本知识了。接下来,我们引入 Angular,写一个简单的 Angular 程序示例。

添加 Angular 2

使用 NPM 下载所需的包

  1. 安装 PackageInstaller

  2. 用 PackageInstaller 来安装 Angular 2, systemjs 和 Typings。

    在 project 上右击, 选择 Quick Install Package

    Use PackageInstaller to install angular2 Use PackageInstaller to install systemjs Use PackageInstaller to install Typings

  3. 用 PackageInstaller 安装 es6-shim 的类型文件。

    Angular 2 包含 es6-shim 以提供 Promise 支持, 但 TypeScript 还需要它的类型文件。 在 PackageInstaller 中, 选择 Typing 替换 npm 选项。接着键入 "es6-shim":

    Use PackageInstaller to install es6-shim typings

更新 tsconfig.json

现在安装好了 Angular 2 及其依赖项, 我们还需要启用 TypeScript 中实验性的装饰器支持并且引入 es6-shim 的类型文件。 将来的版本中,装饰器和 ES6 选项将成为默认选项,我们就可以不做此设置了。 添加"experimentalDecorators": true, "emitDecoratorMetadata": true选项到"compilerOptions",再添加"./typings/index.d.ts""files"。 最后,我们要新建"./src/model.ts"文件,并且得把它加到"files"里。 现在tsconfig.json应该是这样:

{
  "compilerOptions": {
    "noImplicitAny": false,
    "noEmitOnError": true,
    "sourceMap": true,
    "target": "es5",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "outDir": "./Scripts/App"
  },
  "files": [
    "./src/app.ts",
    "./src/model.ts",
    "./src/main.ts",
    "./typings/index.d.ts"
  ]
}

添加 CopyFiles 到 build 中

最后,我们需要确保 Angular 文件作为 build 的一部分复制进来。这样操作,右击项目选择 'Unload' ,再次右击项目选择 'Edit csproj'。 在 TypeScript 配置项 PropertyGroup 之后,添加一个 ItemGroup 和 Target 配置项来复制 Angular 文件。

<ItemGroup>
  <NodeLib Include="$(MSBuildProjectDirectory)\node_modules\angular2\bundles\angular2.js"/>
  <NodeLib Include="$(MSBuildProjectDirectory)\node_modules\angular2\bundles\angular2-polyfills.js"/>
  <NodeLib Include="$(MSBuildProjectDirectory)\node_modules\systemjs\dist\system.src.js"/>
  <NodeLib Include="$(MSBuildProjectDirectory)\node_modules\rxjs\bundles\Rx.js"/>
</ItemGroup>
<Target Name="CopyFiles" BeforeTargets="Build">
  <Copy SourceFiles="@(NodeLib)" DestinationFolder="$(MSBuildProjectDirectory)\Scripts"/>
</Target>

现在,在工程上右击选择重新加载项目。 此时应当能在解决方案资源管理器(Solution Explorer)中看到node_modules

用 TypeScript 写一个简单的 Angular 应用

首先,将 app.ts 改成:

import { Component } from 'angular2/core';
import { MyModel } from './model';

@Component({
  selector: `my-app`,
  template: `<div>Hello from {{ getCompiler() }}</div>`,
})
class MyApp {
  model = new MyModel();
  getCompiler() {
    return this.model.compiler;
  }
}

接着在 src 中添加 TypeScript 文件 model.ts:

export class MyModel {
  compiler = 'TypeScript';
}

再在 src 中添加 main.ts

import { bootstrap } from 'angular2/platform/browser';
import { MyApp } from './app';
bootstrap(MyApp);

最后,将 Views/Home/Index.cshtml 改成:

@{
    ViewBag.Title = "Home Page";
}
<script src="~/Scripts/angular2-polyfills.js"></script>
<script src="~/Scripts/system.src.js"></script>
<script src="~/Scripts/rx.js"></script>
<script src="~/Scripts/angular2.js"></script>
<script>
    System.config({
        packages: {
            '/Scripts/App': {
                format: 'cjs',
                defaultExtension: 'js'
            }
        }
    });
    System.import('/Scripts/App/main').then(null, console.error.bind(console));
</script>
<my-app>Loading...</my-app>

这里加载了此应用。 运行 ASP.NET 应用,你应该能看到一个 div 显示 "Loading..." 紧接着更新成显示 "Hello from TypeScript"。

Gulp

这篇快速上手指南将教你如何使用Gulp构建 TypeScript,和如何在 Gulp 管道里添加BrowserifyuglifyWatchify。 本指南还会展示如何使用Babelify来添加Babel的功能。

这里假设你已经在使用Node.jsnpm了。

创建简单工程

我们首先创建一个新目录。 命名为proj,也可以使用任何你喜欢的名字。

mkdir proj
cd proj

我们将以下面的结构开始我们的工程:

proj/
   ├─ src/
   └─ dist/

TypeScript 文件放在src文件夹下,经过 TypeScript 编译器编译生成的目标文件放在dist目录下。

下面让我们来创建这些文件夹:

mkdir src
mkdir dist

初始化工程

现在让我们把这个文件夹转换成 npm 包:

npm init

你将看到有一些提示操作。 除了入口文件外,其余的都可以使用默认项。 入口文件使用./dist/main.js。 你可以随时在package.json文件里更改生成的配置。

安装依赖项

现在我们可以使用npm install命令来安装包。 首先全局安装gulp-cli(如果你使用 Unix 系统,你可能需要在npm install命令上使用sudo)。

npm install -g gulp-cli

然后安装typescriptgulpgulp-typescript到开发依赖项。 Gulp-typescript是 TypeScript 的一个 Gulp 插件。

npm install --save-dev typescript gulp@4.0.0 gulp-typescript

写一个简单的例子

让我们写一个 Hello World 程序。 在src目录下创建main.ts文件:

function hello(compiler: string) {
  console.log(`Hello from ${compiler}`);
}
hello('TypeScript');

在工程的根目录proj下新建一个tsconfig.json文件:

{
    "files": [
        "src/main.ts"
    ],
    "compilerOptions": {
        "noImplicitAny": true,
        "target": "es5"
    }
}

新建gulpfile.js文件

在工程根目录下,新建一个gulpfile.js文件:

var gulp = require('gulp');
var ts = require('gulp-typescript');
var tsProject = ts.createProject('tsconfig.json');

gulp.task('default', function () {
  return tsProject.src().pipe(tsProject()).js.pipe(gulp.dest('dist'));
});

测试这个应用

gulp
node dist/main.js

程序应该能够打印出“Hello from TypeScript!”。

向代码里添加模块

在使用 Browserify 前,让我们先构建一下代码然后再添加一些混入的模块。 这个结构将是你在真实应用程序中会用到的。

新建一个src/greet.ts文件:

export function sayHello(name: string) {
  return `Hello from ${name}`;
}

更改src/main.ts代码,从greet.ts导入sayHello

import { sayHello } from './greet';

console.log(sayHello('TypeScript'));

最后,将src/greet.ts添加到tsconfig.json

{
    "files": [
        "src/main.ts",
        "src/greet.ts"
    ],
    "compilerOptions": {
        "noImplicitAny": true,
        "target": "es5"
    }
}

确保执行gulp后模块是能工作的,在 Node.js 下进行测试:

gulp
node dist/main.js

注意,即使我们使用了 ES2015 的模块语法,TypeScript 还是会生成 Node.js 使用的 CommonJS 模块。 我们在这个教程里会一直使用 CommonJS 模块,但是你可以通过修改module选项来改变这个行为。

Browserify

现在,让我们把这个工程由 Node.js 环境移到浏览器环境里。 因此,我们将把所有模块捆绑成一个 JavaScript 文件。 所幸,这正是 Browserify 要做的事情。 更方便的是,它支持 Node.js 的 CommonJS 模块,这也正是 TypeScript 默认生成的类型。 也就是说 TypeScript 和 Node.js 的设置不需要改变就可以移植到浏览器里。

首先,安装 Browserify,tsify和 vinyl-source-stream。 tsify 是 Browserify 的一个插件,就像 gulp-typescript 一样,它能够访问 TypeScript 编译器。 vinyl-source-stream 会将 Browserify 的输出文件适配成 gulp 能够解析的格式,它叫做vinyl

npm install --save-dev browserify tsify vinyl-source-stream

新建一个页面

src目录下新建一个index.html文件:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>Hello World!</title>
    </head>
    <body>
        <p id="greeting">Loading ...</p>
        <script src="bundle.js"></script>
    </body>
</html>

修改main.ts文件来更新这个页面:

import { sayHello } from './greet';

function showHello(divName: string, name: string) {
  const elt = document.getElementById(divName);
  elt.innerText = sayHello(name);
}

showHello('greeting', 'TypeScript');

showHello调用sayHello函数更改页面上段落的文字。 现在修改 gulpfile 文件如下:

var gulp = require('gulp');
var browserify = require('browserify');
var source = require('vinyl-source-stream');
var tsify = require('tsify');
var paths = {
  pages: ['src/*.html'],
};

gulp.task('copy-html', function () {
  return gulp.src(paths.pages).pipe(gulp.dest('dist'));
});

gulp.task(
  'default',
  gulp.series(gulp.parallel('copy-html'), function () {
    return browserify({
      basedir: '.',
      debug: true,
      entries: ['src/main.ts'],
      cache: {},
      packageCache: {},
    })
      .plugin(tsify)
      .bundle()
      .pipe(source('bundle.js'))
      .pipe(gulp.dest('dist'));
  })
);

这里增加了copy-html任务并且把它加作default的依赖项。 这样,当default执行时,copy-html会被首先执行。 我们还修改了default任务,让它使用tsify插件调用 Browserify,而不是gulp-typescript。 方便的是,两者传递相同的参数对象到 TypeScript 编译器。

调用bundle后,我们使用source(vinyl-source-stream 的别名)把输出文件命名为bundle.js

测试此页面,运行gulp,然后在浏览器里打开dist/index.html。 你应该能在页面上看到“Hello from TypeScript”。

注意,我们为 Broswerify 指定了debug: true。 这会让tsify在输出文件里生成source mapssource maps允许我们在浏览器中直接调试 TypeScript 源码,而不是在合并后的 JavaScript 文件上调试。 你要打开调试器并在main.ts里打一个断点,看看source maps是否能工作。 当你刷新页面时,代码会停在断点处,从而你就能够调试greet.ts

Watchify,Babel 和 Uglify

现在代码已经用 Browserify 和 tsify 捆绑在一起了,我们可以使用 Browserify 插件为构建添加一些特性。

  • Watchify 启动 Gulp 并保持运行状态,当你保存文件时自动编译。 帮你进入到编辑-保存-刷新浏览器的循环中。
  • Babel 是个十分灵活的编译器,将 ES2015 及以上版本的代码转换成 ES5 和 ES3。 你可以添加大量自定义的 TypeScript 目前不支持的转换器。
  • Uglify 帮你压缩代码,将花费更少的时间去下载它们。

Watchify

我们启动 Watchify,让它在后台帮我们编译:

npm install --save-dev watchify fancy-log

修改 gulpfile 文件如下:

var gulp = require('gulp');
var browserify = require('browserify');
var source = require('vinyl-source-stream');
var watchify = require('watchify');
var tsify = require('tsify');
var fancy_log = require('fancy-log');
var paths = {
  pages: ['src/*.html'],
};

var watchedBrowserify = watchify(
  browserify({
    basedir: '.',
    debug: true,
    entries: ['src/main.ts'],
    cache: {},
    packageCache: {},
  }).plugin(tsify)
);

gulp.task('copy-html', function () {
  return gulp.src(paths.pages).pipe(gulp.dest('dist'));
});

function bundle() {
  return watchedBrowserify
    .bundle()
    .on('error', fancy_log)
    .pipe(source('bundle.js'))
    .pipe(gulp.dest('dist'));
}

gulp.task('default', gulp.series(gulp.parallel('copy-html'), bundle));
watchedBrowserify.on('update', bundle);
watchedBrowserify.on('log', fancy_log);

共有三处改变,但是需要你略微重构一下代码。

  1. browserify实例包裹在watchify的调用里,控制生成的结果。
  2. 调用watchedBrowserify.on('update', bundle);,每次 TypeScript 文件改变时 Browserify 会执行bundle函数。
  3. 调用watchedBrowserify.on('log', fancy_log);将日志打印到控制台。

(1)和(2)在一起意味着我们要将browserify调用移出default任务。 然后给函数起个名字,因为 Watchify 和 Gulp 都要调用它。 (3)是可选的,但是对于调试来讲很有用。

现在当你执行gulp,它会启动并保持运行状态。 试着改变main.ts文件里showHello的代码并保存。 你会看到这样的输出:

proj$ gulp
[10:34:20] Using gulpfile ~/src/proj/gulpfile.js
[10:34:20] Starting 'copy-html'...
[10:34:20] Finished 'copy-html' after 26 ms
[10:34:20] Starting 'default'...
[10:34:21] 2824 bytes written (0.13 seconds)
[10:34:21] Finished 'default' after 1.36 s
[10:35:22] 2261 bytes written (0.02 seconds)
[10:35:24] 2808 bytes written (0.05 seconds)

Uglify

首先安装 Uglify。 因为 Uglify 是用于混淆你的代码,所以我们还要安装 vinyl-buffer 和 gulp-sourcemaps 来支持 sourcemaps。

npm install --save-dev gulp-uglify vinyl-buffer gulp-sourcemaps

修改 gulpfile 文件如下:

var gulp = require('gulp');
var browserify = require('browserify');
var source = require('vinyl-source-stream');
var tsify = require('tsify');
var uglify = require('gulp-uglify');
var sourcemaps = require('gulp-sourcemaps');
var buffer = require('vinyl-buffer');
var paths = {
  pages: ['src/*.html'],
};

gulp.task('copy-html', function () {
  return gulp.src(paths.pages).pipe(gulp.dest('dist'));
});

gulp.task(
  'default',
  gulp.series(gulp.parallel('copy-html'), function () {
    return browserify({
      basedir: '.',
      debug: true,
      entries: ['src/main.ts'],
      cache: {},
      packageCache: {},
    })
      .plugin(tsify)
      .bundle()
      .pipe(source('bundle.js'))
      .pipe(buffer())
      .pipe(sourcemaps.init({ loadMaps: true }))
      .pipe(uglify())
      .pipe(sourcemaps.write('./'))
      .pipe(gulp.dest('dist'));
  })
);

注意uglify只是调用了自己—buffersourcemaps的调用是用于确保 sourcemaps 可以工作。 这些调用让我们可以使用单独的 sourcemap 文件,而不是之前的内嵌的 sourcemaps。 你现在可以执行gulp来检查bundle.js是否被压缩了:

gulp
cat dist/bundle.js

Babel

首先安装 Babelify 和 ES2015 的 Babel 预置程序。 和 Uglify 一样,Babelify 也会混淆代码,因此我们也需要 vinyl-buffer 和 gulp-sourcemaps。 默认情况下 Babelify 只会处理扩展名为.js.es.es6.jsx的文件,因此我们需要添加.ts扩展名到 Babelify 选项。

npm install --save-dev babelify@8 babel-core babel-preset-es2015 vinyl-buffer gulp-sourcemaps

修改 gulpfile 文件如下:

var gulp = require('gulp');
var browserify = require('browserify');
var source = require('vinyl-source-stream');
var tsify = require('tsify');
var sourcemaps = require('gulp-sourcemaps');
var buffer = require('vinyl-buffer');
var paths = {
  pages: ['src/*.html'],
};

gulp.task('copyHtml', function () {
  return gulp.src(paths.pages).pipe(gulp.dest('dist'));
});

gulp.task(
  'default',
  gulp.series(gulp.parallel('copy-html'), function () {
    return browserify({
      basedir: '.',
      debug: true,
      entries: ['src/main.ts'],
      cache: {},
      packageCache: {},
    })
      .plugin(tsify)
      .transform('babelify', {
        presets: ['es2015'],
        extensions: ['.ts'],
      })
      .bundle()
      .pipe(source('bundle.js'))
      .pipe(buffer())
      .pipe(sourcemaps.init({ loadMaps: true }))
      .pipe(sourcemaps.write('./'))
      .pipe(gulp.dest('dist'));
  })
);

我们需要设置 TypeScript 目标为 ES2015。 Babel 稍后会从 TypeScript 生成的 ES2015 代码中生成 ES5。 修改tsconfig.json:

{
    "files": [
        "src/main.ts"
    ],
    "compilerOptions": {
        "noImplicitAny": true,
        "target": "es2015"
    }
}

对于这样一段简单的代码来说,Babel 的 ES5 输出应该和 TypeScript 的输出相似。

Knockout.js

注意: 此教程已从官方删除

这个快速上手指南会告诉你如何结合使用 TypeScript 和Knockout.js

这里我们假设你已经会使用Node.jsnpm

新建工程

首先,我们新建一个目录。 暂时命名为proj,当然了你可以使用任何喜欢的名字。

mkdir proj
cd proj

接下来,我们按如下方式来组织这个工程:

proj/
   ├─ src/
   └─ built/

TypeScript 源码放在src目录下,结过 TypeScript 编译器编译后,生成的文件放在built目录里。

下面创建目录:

mkdir src
mkdir built

初始化工程

现在将这个文件夹转换为 npm 包。

npm init

你会看到一系列提示。 除了入口点外其它设置都可以使用默认值。 你可以随时到生成的package.json文件里修改这些设置。

安装构建依赖

首先确保 TypeScript 已经全局安装。

npm install -g typescript

我们还要获取 Knockout 的声明文件,它描述了这个库的结构供 TypeScript 使用。

npm install --save @types/knockout

获取运行时依赖

我们需要 Knockout 和 RequireJS。 RequireJS是一个库,它可以让我们在运行时异步地加载模块。

有以下几种获取方式:

  1. 手动下载文件并维护它们。
  2. 通过像Bower这样的包管理下载并维护它们。
  3. 使用内容分发网络(CDN)来维护这两个文件。

我们使用第一种方法,它会简单一些,但是 Knockout 的官方文档上有讲解如何使用 CDN,更多像 RequireJS 一样的代码库可以在cdnjs上查找。

下面让我们在工程根目录下创建externals目录。

mkdir externals

然后下载 Knockout下载 RequireJS到这个目录里。 最新的压缩后版本就可以。

添加 TypeScript 配置文件

下面我们想把所有的 TypeScript 文件整合到一起 - 包括自己写的和必须的声明文件。

我们需要创建一个tsconfig.json文件,包含了输入文件列表和编译选项。 在工程根目录下创建一个新文件tsconfig.json,内容如下:

{
    "compilerOptions": {
        "outDir": "./built/",
        "sourceMap": true,
        "noImplicitAny": true,
        "module": "amd",
        "target": "es5"
    },
    "files": [
        "./src/require-config.ts",
        "./src/hello.ts"
    ]
}

这里引用了typings/index.d.ts,它是 Typings 帮我们创建的。 这个文件会自动地包含所有安装的依赖。

你可能会对typings目录下的browser.d.ts文件感到好奇,尤其因为我们将在浏览器里运行代码。 其实原因是这样的,当目标为浏览器的时候,一些包会生成不同的版本。 通常来讲,这些情况很少发生并且在这里我们不会遇到这种情况,所以我们可以忽略browser.d.ts

你可以在这里查看更多关于tsconfig.json文件的信息

写些代码

下面我们使用 Knockout 写一段 TypeScript 代码。 首先,在src目录里新建一个hello.ts文件。

import * as ko from 'knockout';

class HelloViewModel {
  language: KnockoutObservable<string>;
  framework: KnockoutObservable<string>;

  constructor(language: string, framework: string) {
    this.language = ko.observable(language);
    this.framework = ko.observable(framework);
  }
}

ko.applyBindings(new HelloViewModel('TypeScript', 'Knockout'));

接下来,在src目录下再新建一个require-config.ts文件。

declare var require: any;
require.config({
  paths: {
    knockout: 'externals/knockout-3.4.0',
  },
});

这个文件会告诉 RequireJS 从哪里导入 Knockout,好比我们在hello.ts里做的一样。 你创建的所有页面都应该在 RequireJS 之后和导入任何东西之前引入它。 为了更好地理解这个文件和如何配置 RequireJS,可以查看文档

我们还需要一个视图来显示HelloViewModel。 在proj目录的根上创建一个文件index.html,内容如下:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>Hello Knockout!</title>
    </head>
    <body>
        <p>
            Hello from
            <strong data-bind="text: language">todo</strong>
            and
            <strong data-bind="text: framework">todo</strong>!
        </p>

        <p>Language: <input data-bind="value: language" /></p>
        <p>Framework: <input data-bind="value: framework" /></p>

        <script src="./externals/require.js"></script>
        <script src="./built/require-config.js"></script>
        <script>
            require(["built/hello"]);
        </script>
    </body>
</html>

注意,有两个 script 标签。 首先,我们引入 RequireJS。 然后我们再在require-config.js里映射外部依赖,这样 RequireJS 就能知道到哪里去查找它们。 最后,使用我们要去加载的模块去调用require

将所有部分整合在一起

运行

tsc

现在,在你喜欢的浏览器打开index.html,所有都应该好用了。 你应该可以看到页面上显示“Hello from TypeScript and Knockout!”。 在它下面,你还会看到两个输入框。 当你改变输入和切换焦点时,就会看到原先显示的信息改变了。

React 与 webpack

这篇指南将会教你如何将 TypeScript 和React还有webpack结合在一起使用。

如果你正在做一个全新的工程,可以先阅读这篇React 快速上手指南

否则,我们假设已经在使用Node.jsnpm

初始化项目结构

让我们新建一个目录。 将会命名为proj,但是你可以改成任何你喜欢的名字。

mkdir proj
cd proj

我们会像下面的结构组织我们的工程:

proj/
├─ dist/
└─ src/
   └─ components/

TypeScript 文件会放在src文件夹里,通过 TypeScript 编译器编译,然后经 webpack 处理,最后生成一个main.js文件放在dist目录下。 我们自定义的组件将会放在src/components文件夹下。

下面来创建基本结构:

mkdir src
cd src
mkdir components
cd ..

Webpack 会帮助我们生成dist目录。

初始化工程

现在把这个目录变成 npm 包。

npm init -y

它会使用默认值生成一个package.json文件。

安装依赖

首先确保已经全局安装了 Webpack。

npm install --save-dev webpack webpack-cli

Webpack 这个工具可以将你的所有代码和可选择地将依赖捆绑成一个单独的.js文件。

现在我们添加 React 和 React-DOM 以及它们的声明文件到package.json文件里做为依赖:

npm install --save react react-dom
npm install --save-dev @types/react @types/react-dom

使用@types/前缀表示我们额外要获取 React 和 React-DOM 的声明文件。 通常当你导入像"react"这样的路径,它会查看react包; 然而,并不是所有的包都包含了声明文件,所以 TypeScript 还会查看@types/react包。 你会发现我们以后将不必在意这些。

接下来,我们要添加开发时依赖ts-loadersource-map-loader

npm install --save-dev typescript ts-loader source-map-loader

这些依赖会让 TypeScript 和 webpack 在一起良好地工作。 ts-loader可以让 Webpack 使用 TypeScript 的标准配置文件tsconfig.json编译 TypeScript 代码。 source-map-loader 使用 TypeScript 输出的 sourcemap 文件来告诉 webpack 何时生成_自己的_sourcemaps。 这就允许你在调试最终生成的文件时就好像在调试 TypeScript 源码一样。

请注意,ts-loader并不是唯一的TypeScript加载器。

你还可以选择awesome-typescript-loader。 可以到这里查看它们之间的区别。

注意我们安装 TypeScript 为一个开发依赖。 我们还可以使用npm link typescript来链接 TypeScript 到一个全局拷贝,但这不是常见用法。

添加 TypeScript 配置文件

我们想将 TypeScript 文件整合到一起 - 这包括我们写的源码和必要的声明文件。

我们需要创建一个tsconfig.json文件,它包含了输入文件列表以及编译选项。 在工程根目录下新建文件tsconfig.json文件,添加以下内容:

{
    "compilerOptions": {
        "outDir": "./dist/",
        "sourceMap": true,
        "noImplicitAny": true,
        "module": "commonjs",
        "target": "es6",
        "jsx": "react"
    }
}

你可以在这里了解更多关于tsconfig.json文件的说明。

写些代码

下面使用 React 写一段 TypeScript 代码。 首先,在src/components目录下创建一个名为Hello.tsx的文件,代码如下:

import * as React from 'react';

export interface HelloProps {
  compiler: string;
  framework: string;
}

export const Hello = (props: HelloProps) => (
  <h1>
    Hello from {props.compiler} and {props.framework}!
  </h1>
);

注意这个例子使用了函数组件,我们可以让它更像一点

import * as React from 'react';

export interface HelloProps {
  compiler: string;
  framework: string;
}

// 'HelloProps' describes the shape of props.
// State is never set so we use the '{}' type.
export class Hello extends React.Component<HelloProps, {}> {
  render() {
    return (
      <h1>
        Hello from {this.props.compiler} and {this.props.framework}!
      </h1>
    );
  }
}

接下来,在src下创建index.tsx文件,源码如下:

import * as React from 'react';
import * as ReactDOM from 'react-dom';

import { Hello } from './components/Hello';

ReactDOM.render(
  <Hello compiler="TypeScript" framework="React" />,
  document.getElementById('example')
);

我们仅仅将Hello组件导入index.tsx。 注意,不同于"react""react-dom",我们使用Hello.tsx相对路径 - 这很重要。 如果不这样做,TypeScript 只会尝试在node_modules文件夹里查找。

我们还需要一个页面来显示Hello组件。 在根目录proj创建一个名为index.html的文件,如下:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>Hello React!</title>
    </head>
    <body>
        <div id="example"></div>

        <!-- Dependencies -->
        <script src="./node_modules/react/umd/react.development.js"></script>
        <script src="./node_modules/react-dom/umd/react-dom.development.js"></script>

        <!-- Main -->
        <script src="./dist/main.js"></script>
    </body>
</html>

需要注意一点我们是从node_modules引入的文件。 React 和 React-DOM 的 npm 包里包含了独立的.js文件,你可以在页面上引入它们,这里我们为了快捷就直接引用了。 可以随意地将它们拷贝到其它目录下,或者从 CDN 上引用。 Facebook 在 CND 上提供了一系列可用的 React 版本,你可以在这里查看更多内容

创建一个 webpack 配置文件

在工程根目录下创建一个webpack.config.js文件。

module.exports = {
  mode: 'production',

  // Enable sourcemaps for debugging webpack's output.
  devtool: 'source-map',

  resolve: {
    // Add '.ts' and '.tsx' as resolvable extensions.
    extensions: ['.ts', '.tsx'],
  },

  module: {
    rules: [
      {
        test: /\.ts(x?)$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'ts-loader',
          },
        ],
      },
      // All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'.
      {
        enforce: 'pre',
        test: /\.js$/,
        loader: 'source-map-loader',
      },
    ],
  },

  // When importing a module whose path matches one of the following, just
  // assume a corresponding global variable exists and use that instead.
  // This is important because it allows us to avoid bundling all of our
  // dependencies, which allows browsers to cache those libraries between builds.
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
};

大家可能对externals字段有所疑惑。 我们想要避免把所有的 React 都放到一个文件里,因为会增加编译时间并且浏览器还能够缓存没有发生改变的库文件。

理想情况下,我们只需要在浏览器里引入 React 模块,但是大部分浏览器还没有支持模块。 因此大部分代码库会把自己包裹在一个单独的全局变量内,比如:jQuery_。 这叫做“命名空间”模式,webpack 也允许我们继续使用通过这种方式写的代码库。 通过我们的设置"react": "React",webpack 会神奇地将所有对"react"的导入转换成从React全局变量中加载。

你可以在这里了解更多如何配置 webpack。

整合在一起

执行:

npx webpack

在浏览器里打开index.html,工程应该已经可以用了! 你可以看到页面上显示着“Hello from TypeScript and React!”

React

这篇快速上手指南会教你如何将 TypeScript 与React结合起来使用。 在最后,你将学到:

  • 使用 TypeScript 和 React 创建工程
  • 使用TSLint进行代码检查
  • 使用JestEnzyme进行测试,以及
  • 使用Redux管理状态

我们会使用create-react-app工具快速搭建工程环境。

这里假设你已经在使用Node.jsnpm。 并且已经了解了React 的基础知识

创建新工程

让我们首先创建一个叫做my-app的新工程:

npx create-react-app my-app --template typescript

react-scripts-ts是一系列适配器,它利用标准的 create-react-app 工程管道并把 TypeScript 混入进来。

此时的工程结构应如下所示:

my-app/
├─ .gitignore
├─ node_modules/
├─ public/
├─ src/
│  └─ ...
├─ package.json
├─ tsconfig.json
└─ tslint.json

注意:

  • tsconfig.json包含了工程里 TypeScript 特定的选项。
  • tslint.json保存了要使用的代码检查器的设置,TSLint
  • package.json包含了依赖,还有一些命令的快捷方式,如测试命令,预览命令和发布应用的命令。
  • public包含了静态资源如 HTML 页面或图片。除了index.html文件外,其它的文件都可以删除。
  • src包含了 TypeScript 和 CSS 源码。index.tsx是强制使用的入口文件。

运行工程

通过下面的方式即可轻松地运行这个工程。

npm run start

它会执行package.json里面指定的start命令,并且会启动一个服务器,当我们保存文件时还会自动刷新页面。 通常这个服务器的地址是http://localhost:3000,页面应用会被自动地打开。

它会保持监听以方便我们快速地预览改动。

测试工程

测试也仅仅是一行命令的事儿:

npm run test

这个命令会运行 Jest,一个非常好用的测试工具,它会运行所有扩展名是.test.ts.spec.ts的文件。 好比是npm run start命令,当检测到有改动的时候 Jest 会自动地运行。 如果喜欢的话,你还可以同时运行npm run startnpm run test,这样你就可以在预览的同时进行测试。

生成生产环境的构建版本

在使用npm run start运行工程的时候,我们并没有生成一个优化过的版本。 通常我们想给用户一个运行的尽可能快并在体积上尽可能小的代码。 像压缩这样的优化方法可以做到这一点,但是总是要耗费更多的时间。 我们把这样的构建版本称做“生产环境”版本(与开发版本相对)。

要执行生产环境的构建,可以运行如下命令:

npm run build

这会相应地创建优化过的 JS 和 CSS 文件,./build/static/js./build/static/css

大多数情况下你不需要生成生产环境的构建版本, 但它可以帮助你衡量应用最终版本的体积大小。

创建一个组件

下面我们将要创建一个Hello组件。 这个组件接收任意一个我们想对之打招呼的名字(我们把它叫做name),并且有一个可选数量的感叹号做为结尾(通过enthusiasmLevel)。

若我们这样写<Hello name="Daniel" enthusiasmLevel={3} />,这个组件大至会渲染成<div>Hello Daniel!!!</div>。 如果没指定enthusiasmLevel,组件将默认显示一个感叹号。 若enthusiasmLevel0或负值将抛出一个错误。

下面来写一下Hello.tsx

// src/components/Hello.tsx

import * as React from 'react';

export interface Props {
  name: string;
  enthusiasmLevel?: number;
}

function Hello({ name, enthusiasmLevel = 1 }: Props) {
  if (enthusiasmLevel <= 0) {
    throw new Error('You could be a little more enthusiastic. :D');
  }

  return (
    <div className="hello">
      <div className="greeting">
        Hello {name + getExclamationMarks(enthusiasmLevel)}
      </div>
    </div>
  );
}

export default Hello;

// helpers

function getExclamationMarks(numChars: number) {
  return Array(numChars + 1).join('!');
}

注意我们定义了一个类型Props,它指定了我们组件要用到的属性。 name是必需的且为string类型,同时enthusiasmLevel是可选的且为number类型(你可以通过名字后面加?为指定可选参数)。

我们创建了一个函数组件Hello。 具体来讲,Hello是一个函数,接收一个Props对象并拆解它。 如果Props对象里没有设置enthusiasmLevel,默认值为1

使用函数是 React 中定义组件的两种方式之一。 如果你喜欢的话,也可以通过类的方式定义:

class Hello extends React.Component<Props, object> {
  render() {
    const { name, enthusiasmLevel = 1 } = this.props;

    if (enthusiasmLevel <= 0) {
      throw new Error('You could be a little more enthusiastic. :D');
    }

    return (
      <div className="hello">
        <div className="greeting">
          Hello {name + getExclamationMarks(enthusiasmLevel)}
        </div>
      </div>
    );
  }
}

当我们的组件具有某些状态的时候,使用类的方式是很有用处的。 但在这个例子里我们不需要考虑状态 - 事实上,在React.Component<Props, object>我们把状态指定为了object,因此使用函数组件更简洁。 当在创建可重用的通用 UI 组件的时候,在表现层使用组件局部状态比较适合。 针对我们应用的生命周期,我们会审视应用是如何通过 Redux 轻松地管理普通状态的。

现在我们已经写好了组件,让我们仔细看看index.tsx,把<App />替换成<Hello ... />

首先我们在文件头部导入它:

import Hello from './components/Hello';

然后修改render调用:

ReactDOM.render(
  <Hello name="TypeScript" enthusiasmLevel={10} />,
  document.getElementById('root') as HTMLElement
);

类型断言

这里还有一点要指出,就是最后一行document.getElementById('root') as HTMLElement。 这个语法叫做类型断言,有时也叫做转换。 当你比类型检查器更清楚一个表达式的类型的时候,你可以通过这种方式通知 TypeScript。

这里,我们之所以这么做是因为getElementById的返回值类型是HTMLElement | null。 简单地说,getElementById返回null是当无法找对对应id元素的时候。 我们假设getElementById总是成功的,因此我们要使用as语法告诉 TypeScript 这点。

TypeScript 还有一种感叹号(!)结尾的语法,它会从前面的表达式里移除nullundefined。 所以我们也可以写成document.getElementById('root')!,但在这里我们想写的更清楚些。

:sunglasses:添加样式

通过我们的设置为一个组件添加样式很容易。 若要设置Hello组件的样式,我们可以创建这样一个 CSS 文件src/components/Hello.css

.hello {
  text-align: center;
  margin: 20px;
  font-size: 48px;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.hello button {
  margin-left: 25px;
  margin-right: 25px;
  font-size: 40px;
  min-width: 50px;
}

create-react-app包含的工具(Webpack 和一些加载器)允许我们导入样式表文件。 当我们构建应用的时候,所有导入的.css文件会被拼接成一个输出文件。 因此在src/components/Hello.tsx,我们需要添加如下导入语句。

import './Hello.css';

使用 Jest 编写测试

如果你没使用过 Jest,你可能先要把它安装为开发依赖项。

npm install -D jest jest-cli jest-config

我们对Hello组件有一些假设。 让我们在此重申一下:

  • 当这样写<Hello name="Daniel" enthusiasmLevel={3} />时,组件应被渲染成<div>Hello Daniel!!!</div>
  • 若未指定enthusiasmLevel,组件应默认显示一个感叹号。
  • enthusiasmLevel0或负值,它应抛出一个错误。

我们将针对这些需求为组件写一些注释。

但首先,我们要安装 Enzyme。 Enzyme是 React 生态系统里一个通用工具,它方便了针对组件的行为编写测试。 默认地,我们的应用包含了一个叫做 jsdom 的库,它允许我们模拟 DOM 以及在非浏览器的环境下测试运行时的行为。 Enzyme 与此类似,但是是基于 jsdom 的,并且方便我们查询组件。

让我们把它安装为开发依赖项。

npm install -D enzyme @types/enzyme enzyme-adapter-react-16 @types/enzyme-adapter-react-16

如果你的 react 版本低于 15.5.0,还需安装如下

npm install -D react-addons-test-utils

注意我们同时安装了enzyme@types/enzymeenzyme包指的是包含了实际运行的 JavaScript 代码包,而@types/enzyme则包含了声明文件(.d.ts文件)的包,以便 TypeScript 能够了解该如何使用 Enzyme。 你可以在这里了解更多关于@types包的信息。

我们还需要安装enzyme-adapterreact-addons-test-utils。 它们是使用enzyme所需要安装的包,前者作为配置适配器是必须的,而后者若采用的 React 版本在 15.5.0 之上则毋需安装。

现在我们已经设置好了 Enzyme,下面开始编写测试! 先创建一个文件src/components/Hello.test.tsx,与先前的Hello.tsx文件放在一起。

// src/components/Hello.test.tsx

import * as React from 'react';
import * as enzyme from 'enzyme';
import * as Adapter from 'enzyme-adapter-react-16';
import Hello from './Hello';

enzyme.configure({ adapter: new Adapter() });

it('renders the correct text when no enthusiasm level is given', () => {
  const hello = enzyme.shallow(<Hello name="Daniel" />);
  expect(hello.find('.greeting').text()).toEqual('Hello Daniel!');
});

it('renders the correct text with an explicit enthusiasm of 1', () => {
  const hello = enzyme.shallow(<Hello name="Daniel" enthusiasmLevel={1} />);
  expect(hello.find('.greeting').text()).toEqual('Hello Daniel!');
});

it('renders the correct text with an explicit enthusiasm level of 5', () => {
  const hello = enzyme.shallow(<Hello name="Daniel" enthusiasmLevel={5} />);
  expect(hello.find('.greeting').text()).toEqual('Hello Daniel!!!!!');
});

it('throws when the enthusiasm level is 0', () => {
  expect(() => {
    enzyme.shallow(<Hello name="Daniel" enthusiasmLevel={0} />);
  }).toThrow();
});

it('throws when the enthusiasm level is negative', () => {
  expect(() => {
    enzyme.shallow(<Hello name="Daniel" enthusiasmLevel={-1} />);
  }).toThrow();
});

这些测试都十分基础,但你可以从中得到启发。

添加 state 管理

到此为止,如果你使用 React 的目的是只获取一次数据并显示,那么你已经完成了。 但是如果你想开发一个可以交互的应用,那么你需要添加 state 管理。

state 管理概述

React 本身就是一个适合于创建可组合型视图的库。 但是,React 并没有任何在应用间同步数据的功能。 就 React 组件而言,数据是通过每个元素上指定的 props 向子元素传递。

因为 React 本身并没有提供内置的 state 管理功能,React 社区选择了 Redux 和 MobX 库。

Redux依靠一个统一且不可变的数据存储来同步数据,并且更新那里的数据时会触发应用的更新渲染。 state 的更新是以一种不可变的方式进行,它会发布一条明确的 action 消息,这个消息必须被 reducer 函数处理。 由于使用了这样明确的方式,很容易弄清楚一个 action 是如何影响程序的 state。

MobX借助于函数式响应型模式,state 被包装在了可观察对象里,并通过 props 传递。 通过将 state 标记为可观察的,即可在所有观察者之间保持 state 的同步性。 另一个好处是,这个库已经使用 TypeScript 实现了。

这两者各有优缺点。 但 Redux 使用得更广泛,因此在这篇教程里,我们主要看如何使用 Redux; 但是也鼓励大家两者都去了解一下。

后面的小节学习曲线比较陡。 因此强烈建议大家先去熟悉一下 Redux

设置 actions

只有当应用里的 state 会改变的时候,我们才需要去添加 Redux。 我们需要一个 action 的来源,它将触发改变。 它可以是一个定时器或者 UI 上的一个按钮。

为此,我们将增加两个按钮来控制Hello组件的感叹级别。

安装 Redux

安装reduxreact-redux以及它们的类型文件做为依赖。

npm install -S redux react-redux @types/react-redux

这里我们不需要安装@types/redux,因为 Redux 已经自带了声明文件(.d.ts文件)。

定义应用的状态

我们需要定义 Redux 保存的 state 的结构。 创建src/types/index.tsx文件,它保存了类型的定义,我们在整个程序里都可能用到。

// src/types/index.tsx

export interface StoreState {
  languageName: string;
  enthusiasmLevel: number;
}

这里我们想让languageName表示应用使用的编程语言(例如,TypeScript 或者 JavaScript),enthusiasmLevel是可变的。 在写我们的第一个容器的时候,就会明白为什么要令 state 与 props 稍有不同。

添加 actions

下面我们创建这个应用将要响应的消息类型,src/constants/index.tsx

// src/constants/index.tsx

export const INCREMENT_ENTHUSIASM = 'INCREMENT_ENTHUSIASM';
export type INCREMENT_ENTHUSIASM = typeof INCREMENT_ENTHUSIASM;

export const DECREMENT_ENTHUSIASM = 'DECREMENT_ENTHUSIASM';
export type DECREMENT_ENTHUSIASM = typeof DECREMENT_ENTHUSIASM;

这里的const/type模式允许我们以容易访问和重构的方式使用 TypeScript 的字符串字面量类型。

接下来,我们创建一些 actions 以及创建这些 actions 的函数,src/actions/index.tsx

import * as constants from '../constants';

export interface IncrementEnthusiasm {
  type: constants.INCREMENT_ENTHUSIASM;
}

export interface DecrementEnthusiasm {
  type: constants.DECREMENT_ENTHUSIASM;
}

export type EnthusiasmAction = IncrementEnthusiasm | DecrementEnthusiasm;

export function incrementEnthusiasm(): IncrementEnthusiasm {
  return {
    type: constants.INCREMENT_ENTHUSIASM,
  };
}

export function decrementEnthusiasm(): DecrementEnthusiasm {
  return {
    type: constants.DECREMENT_ENTHUSIASM,
  };
}

我们创建了两个类型,它们负责增加操作和减少操作的行为。 我们还定义了一个类型(EnthusiasmAction),它描述了哪些 action 是可以增加或减少的。 最后,我们定义了两个函数用来创建实际的 actions。

这里有一些清晰的模版,你可以参考类似redux-actions的库。

添加 reducer

现在我们可以开始写第一个 reducer 了! Reducers 是函数,它们负责生成应用 state 的拷贝使之产生变化,但它并没有副作用。 它们是一种纯函数

我们的 reducer 将放在src/reducers/index.tsx文件里。 它的功能是保证增加操作会让感叹级别加 1,减少操作则要将感叹级别减 1,但是这个级别永远不能小于 1。

// src/reducers/index.tsx

import { EnthusiasmAction } from '../actions';
import { StoreState } from '../types/index';
import { INCREMENT_ENTHUSIASM, DECREMENT_ENTHUSIASM } from '../constants/index';

export function enthusiasm(
  state: StoreState,
  action: EnthusiasmAction
): StoreState {
  switch (action.type) {
    case INCREMENT_ENTHUSIASM:
      return { ...state, enthusiasmLevel: state.enthusiasmLevel + 1 };
    case DECREMENT_ENTHUSIASM:
      return {
        ...state,
        enthusiasmLevel: Math.max(1, state.enthusiasmLevel - 1),
      };
  }
  return state;
}

注意我们使用了对象展开...state),当替换enthusiasmLevel时,它可以对状态进行浅拷贝。 将enthusiasmLevel属性放在末尾是十分关键的,否则它将被旧的状态覆盖。

你可能想要对 reducer 写一些测试。 因为 reducers 是纯函数,它们可以传入任意的数据。 针对每个输入,可以测试 reducers 生成的新的状态。 可以考虑使用 Jest 的toEqual方法。

创建容器

在使用 Redux 时,我们常常要创建组件和容器。 组件是数据无关的,且工作在表现层。 容器通常包裹组件及其使用的数据,用以显示和修改状态。 你可以在这里阅读更多关于这个概念的细节:Dan Abramov 写的表现层的容器组件

现在我们修改src/components/Hello.tsx,让它可以修改状态。 我们将添加两个可选的回调属性到Props,它们分别是onIncrementonDecrement

export interface Props {
  name: string;
  enthusiasmLevel?: number;
  onIncrement?: () => void;
  onDecrement?: () => void;
}

然后将这两个回调绑定到两个新按钮上,将按钮添加到我们的组件里。

function Hello({ name, enthusiasmLevel = 1, onIncrement, onDecrement }: Props) {
  if (enthusiasmLevel <= 0) {
    throw new Error('You could be a little more enthusiastic. :D');
  }

  return (
    <div className="hello">
      <div className="greeting">
        Hello {name + getExclamationMarks(enthusiasmLevel)}
      </div>
      <div>
        <button onClick={onDecrement}>-</button>
        <button onClick={onIncrement}>+</button>
      </div>
    </div>
  );
}

通常情况下,我们应该给onIncrementonDecrement写一些测试,它们是在各自的按钮被点击时调用。 试一试以便掌握编写测试的窍门。

现在我们的组件更新好了,可以把它放在一个容器里了。 让我们来创建一个文件src/containers/Hello.tsx,在开始的地方使用下列导入语句。

import Hello from '../components/Hello';
import * as actions from '../actions/';
import { StoreState } from '../types/index';
import { connect, Dispatch } from 'react-redux';

两个关键点是初始的Hello组件和 react-redux 的connect函数。 connect可以将我们的Hello组件转换成一个容器,通过以下两个函数:

  • mapStateToProps将当前 store 里的数据以我们的组件需要的形式传递到组件。
  • mapDispatchToProps利用dispatch函数,创建回调 props 将 actions 送到 store。

回想一下,我们的应用包含两个属性:languageNameenthusiasmLevel。 我们的Hello组件,希望得到一个name和一个enthusiasmLevelmapStateToProps会从 store 得到相应的数据,如果需要的话将针对组件的 props 调整它。 下面让我们继续往下写。

export function mapStateToProps({ enthusiasmLevel, languageName }: StoreState) {
  return {
    enthusiasmLevel,
    name: languageName,
  };
}

注意mapStateToProps仅创建了Hello组件需要的四个属性中的两个。 我们还想要传入onIncrementonDecrement回调函数。 mapDispatchToProps是一个函数,它需要传入一个调度函数。 这个调度函数可以将 actions 传入 store 来触发更新,因此我们可以创建一对回调函数,它们会在需要的时候调用调度函数。

export function mapDispatchToProps(
  dispatch: Dispatch<actions.EnthusiasmAction>
) {
  return {
    onIncrement: () => dispatch(actions.incrementEnthusiasm()),
    onDecrement: () => dispatch(actions.decrementEnthusiasm()),
  };
}

最后,我们可以调用connect了。 connect首先会接收mapStateToPropsmapDispatchToProps,然后返回另一个函数,我们用它来包裹我们的组件。 最终的容器是通过下面的代码定义的:

export default connect(mapStateToProps, mapDispatchToProps)(Hello);

现在,我们的文件应该是下面这个样子:

// src/containers/Hello.tsx

import Hello from '../components/Hello';
import * as actions from '../actions/';
import { StoreState } from '../types/index';
import { connect, Dispatch } from 'react-redux';

export function mapStateToProps({ enthusiasmLevel, languageName }: StoreState) {
  return {
    enthusiasmLevel,
    name: languageName,
  };
}

export function mapDispatchToProps(
  dispatch: Dispatch<actions.EnthusiasmAction>
) {
  return {
    onIncrement: () => dispatch(actions.incrementEnthusiasm()),
    onDecrement: () => dispatch(actions.decrementEnthusiasm()),
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(Hello);

创建 store

让我们回到src/index.tsx。 要把所有的东西合到一起,我们需要创建一个带初始状态的 store,并用我们所有的 reducers 来设置它。

import { createStore } from 'redux';
import { enthusiasm } from './reducers/index';
import { StoreState } from './types/index';

const store = createStore<StoreState>(enthusiasm, {
  enthusiasmLevel: 1,
  languageName: 'TypeScript',
});

store可能正如你想的那样,它是我们应用全局状态的核心 store。

接下来,我们将要用./src/containers/Hello来包裹./src/components/Hello,然后使用 react-redux 的Provider将 props 与容器连通起来。 我们将导入它们:

import Hello from './containers/Hello';
import { Provider } from 'react-redux';

storeProvider的属性形式传入:

ReactDOM.render(
  <Provider store={store}>
    <Hello />
  </Provider>,
  document.getElementById('root') as HTMLElement
);

注意,Hello不再需要 props 了,因为我们使用了connect函数为包裹起来的Hello组件的 props 适配了应用的状态。

退出

如果你发现 create-react-app 使一些自定义设置变得困难,那么你就可以选择不使用它,使用你需要配置。 比如,你要添加一个 Webpack 插件,你就可以利用 create-react-app 提供的“eject”功能。

运行:

npm run eject

这样就可以了!

你要注意,在运行 eject 前最好保存你的代码。 你不能撤销 eject 命令,因此退出操作是永久性的除非你从一个运行 eject 前的提交来恢复工程。

下一步

create-react-app 带有很多很棒的功能。 它们的大多数都在我们工程生成的README.md里面有记录,所以可以简单阅读一下。

如果你想学习更多关于 Redux 的知识,你可以前往官方站点查看文档。 同样的,MobX官方站点。

如果你想要在某个时间点 eject,你需要了解再多关于 Webpack 的知识。 你可以查看React & Webpack 教程

有时候你需要路由功能。 已经有一些解决方案了,但是对于 Redux 工程来讲react-router是最流行的,并经常与react-router-redux联合使用。

Angular 2

即将到来的 Angular 2 框架是使用 TypeScript 开发的。 因此 Angular 和 TypeScript 一起使用非常简单方便。 Angular 团队也在其文档里把 TypeScript 视为一等公民。

正因为这样,你总是可以在Angular 2 官网(或Angular 2 官网中文版)里查看到最新的结合使用 Angular 和 TypeScript 的参考文档。 在这里查看快速上手指南,现在就开始学习吧!

从 JavaScript 迁移到 TypeScript

TypeScript 不是凭空存在的。 它从 JavaScript 生态系统和大量现存的 JavaScript 而来。 将 JavaScript 代码转换成 TypeScript 虽乏味却不是难事。 接下来这篇教程将教你怎么做。 在开始转换 TypeScript 之前,我们假设你已经理解了足够多本手册里的内容。

如果你打算要转换一个 React 工程,推荐你先阅读React 转换指南

设置目录

如果你在写纯 JavaScript,你大概是想直接运行这些 JavaScript 文件, 这些文件存在于srclibdist目录里,它们可以按照预想运行。

若如此,那么你写的纯 JavaScript 文件将做为 TypeScript 的输入,你将要运行的是 TypeScript 的输出。 在从 JS 到 TS 的转换过程中,我们会分离输入文件以防 TypeScript 覆盖它们。 你也可以指定输出目录。

你可能还需要对 JavaScript 做一些中间处理,比如合并或经过 Babel 再次编译。 在这种情况下,你应该已经有了如下的目录结构。

那么现在,我们假设你已经设置了这样的目录结构:

projectRoot
├── src
│   ├── file1.js
│   └── file2.js
├── built
└── tsconfig.json

如果你在src目录外还有tests文件夹,那么在src里可以有一个tsconfig.json文件,在tests里还可以有一个。

书写配置文件

TypeScript 使用tsconfig.json文件管理工程配置,例如你想包含哪些文件和进行哪些检查。 让我们先创建一个简单的工程配置文件:

{
    "compilerOptions": {
        "outDir": "./built",
        "allowJs": true,
        "target": "es5"
    },
    "include": [
        "./src/**/*"
    ]
}

这里我们为 TypeScript 设置了一些东西:

  1. 读取所有可识别的src目录下的文件(通过include)。
  2. 接受 JavaScript 做为输入(通过allowJs)。
  3. 生成的所有文件放在built目录下(通过outDir)。
  4. 将 JavaScript 代码降级到低版本比如 ECMAScript 5(通过target)。

现在,如果你在工程根目录下运行tsc,就可以在built目录下看到生成的文件。 built下的文件应该与src下的文件相同。 现在你的工程里的 TypeScript 已经可以工作了。

早期收益

现在你已经可以看到 TypeScript 带来的好处,它能帮助我们理解当前工程。 如果你打开像VS CodeVisual Studio这样的编译器,你就能使用像自动补全这样的工具。 你还可以配置如下的选项来帮助查找 BUG:

  • noImplicitReturns 会防止你忘记在函数末尾返回值。
  • noFallthroughCasesInSwitch 会防止在switch代码块里的两个case之间忘记添加break语句。

TypeScript 还能发现那些执行不到的代码和标签,你可以通过设置allowUnreachableCodeallowUnusedLabels选项来禁用。

与构建工具进行集成

在你的构建管道中可能包含多个步骤。 比如为每个文件添加一些内容。 每种工具的使用方法都是不同的,我们会尽可能的包涵主流的工具。

Gulp

如果你在使用时髦的 Gulp,我们已经有一篇关于使用 Gulp结合 TypeScript 并与常见构建工具 Browserify,Babelify 和 Uglify 进行集成的教程。 请阅读这篇教程。

Webpack

Webpack 集成非常简单。 你可以使用awesome-typescript-loader,它是一个 TypeScript 的加载器,结合source-map-loader方便调试。 运行:

npm install awesome-typescript-loader source-map-loader

并将下面的选项合并到你的webpack.config.js文件里:

module.exports = {
  entry: './src/index.ts',
  output: {
    filename: './dist/bundle.js',
  },

  // Enable sourcemaps for debugging webpack's output.
  devtool: 'source-map',

  resolve: {
    // Add '.ts' and '.tsx' as resolvable extensions.
    extensions: ['', '.webpack.js', '.web.js', '.ts', '.tsx', '.js'],
  },

  module: {
    loaders: [
      // All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'.
      { test: /\.tsx?$/, loader: 'awesome-typescript-loader' },
    ],

    preLoaders: [
      // All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'.
      { test: /\.js$/, loader: 'source-map-loader' },
    ],
  },

  // Other options...
};

要注意的是,awesome-typescript-loader必须在其它处理.js文件的加载器之前运行。

这与另一个 TypeScript 的 Webpack 加载器ts-loader是一样的。 你可以到这里了解两者之间的差别。

你可以在React 和 Webpack 教程里找到使用 Webpack 的例子。

转换到 TypeScript 文件

到目前为止,你已经做好了使用 TypeScript 文件的准备。 第一步,将.js文件重命名为.ts文件。 如果你使用了 JSX,则重命名为.tsx文件。

第一步达成? 太棒了! 你已经成功地将一个文件从 JavaScript 转换成了 TypeScript!

当然了,你可能感觉哪里不对劲儿。 如果你在支持 TypeScript 的编辑器(或运行tsc --pretty)里打开了那个文件,你可能会看到有些行上有红色的波浪线。 你可以把它们当做在 Microsoft Word 里看到的红色波浪线一样。 但是 TypeScript 仍然会编译你的代码,就好比 Word 还是允许你打印你的文档一样。

如果对你来说这种行为太随便了,你可以让它变得严格些。 如果,你不想在发生错误的时候,TypeScript 还会被编译成 JavaScript,你可以使用noEmitOnError选项。 从某种意义上来讲,TypeScript 具有一个调整它的严格性的刻度盘,你可以将指针拔动到你想要的位置。

如果你计划使用可用的高度严格的设置,最好现在就启用它们(查看启用严格检查)。 比如,如果你不想让 TypeScript 将没有明确指定的类型默默地推断为any类型,可以在修改文件之前启用noImplicitAny。 你可能会觉得这有些过度严格,但是长期收益很快就能显现出来。

去除错误

我们提到过,若不出所料,在转换后将会看到错误信息。 重要的是我们要逐一的查看它们并决定如何处理。 通常这些都是真正的 BUG,但有时必须要告诉 TypeScript 你要做的是什么。

由模块导入

首先你可能会看到一些类似Cannot find name 'require'.Cannot find name 'define'.的错误。 遇到这种情况说明你在使用模块。 你仅需要告诉 TypeScript 它们是存在的:

// For Node/CommonJS
declare function require(path: string): any;

// For RequireJS/AMD
declare function define(...args: any[]): any;

最好是避免使用这些调用而改用 TypeScript 的导入语法。

首先,你要使用 TypeScript 的module标记来启用一些模块系统。 可用的选项有commonjsamdsystem,and umd

如果代码里存在下面的 Node/CommonJS 代码:

var foo = require('foo');

foo.doStuff();

或者下面的 RequireJS/AMD 代码:

define(['foo'], function (foo) {
  foo.doStuff();
});

那么可以写做下面的 TypeScript 代码:

import foo = require('foo');

foo.doStuff();

获取声明文件

如果你开始做转换到 TypeScript 导入,你可能会遇到Cannot find module 'foo'.这样的错误。 问题出在没有声明文件来描述你的代码库。 幸运的是这非常简单。 如果 TypeScript 报怨像是没有lodash包,那你只需这样做

npm install -S @types/lodash

如果你没有使用commonjs模块模块选项,那么就需要将moduleResolution选项设置为node

之后,你应该就可以导入lodash了,并且会获得精确的自动补全功能。

由模块导出

通常来讲,由模块导出涉及添加属性到exportsmodule.exports。 TypeScript 允许你使用顶级的导出语句。 比如,你要导出下面的函数:

module.exports.feedPets = function (pets) {
  // ...
};

那么你可以这样写:

export function feedPets(pets) {
  // ...
}

有时你会完全重写导出对象。 这是一个常见模式,这会将模块变为可立即调用的模块:

var express = require('express');
var app = express();

之前你可以是这样写的:

function foo() {
  // ...
}
module.exports = foo;

在 TypeScript 里,你可以使用export =来代替。

function foo() {
  // ...
}
export = foo;

过多或过少的参数

有时你会发现你在调用一个具有过多或过少参数的函数。 通常,这是一个 BUG,但在某些情况下,你可以声明一个使用arguments对象的函数而不需要写出所有参数:

function myCoolFunction() {
  if (arguments.length == 2 && !Array.isArray(arguments[1])) {
    var f = arguments[0];
    var arr = arguments[1];
    // ...
  }
  // ...
}

myCoolFunction(
  function (x) {
    console.log(x);
  },
  [1, 2, 3, 4]
);
myCoolFunction(
  function (x) {
    console.log(x);
  },
  1,
  2,
  3,
  4
);

这种情况下,我们需要利用 TypeScript 的函数重载来告诉调用者myCoolFunction函数的调用方式。

function myCoolFunction(f: (x: number) => void, nums: number[]): void;
function myCoolFunction(f: (x: number) => void, ...nums: number[]): void;
function myCoolFunction() {
  if (arguments.length == 2 && !Array.isArray(arguments[1])) {
    var f = arguments[0];
    var arr = arguments[1];
    // ...
  }
  // ...
}

我们为myCoolFunction函数添加了两个重载签名。 第一个检查myCoolFunction函数是否接收一个函数(它又接收一个number参数)和一个number数组。 第二个同样是接收了一个函数,并且使用剩余参数(...nums)来表示之后的其它所有参数必须是number类型。

连续添加属性

有些人可能会因为代码美观性而喜欢先创建一个对象然后立即添加属性:

var options = {};
options.color = 'red';
options.volume = 11;

TypeScript 会提示你不能给colorvolumn赋值,因为先前指定options的类型为{}并不带有任何属性。 如果你将声明变成对象字面量的形式将不会产生错误:

let options = {
  color: 'red',
  volume: 11,
};

你还可以定义options的类型并且添加类型断言到对象字面量上。

interface Options {
  color: string;
  volume: number;
}

let options = {} as Options;
options.color = 'red';
options.volume = 11;

或者,你可以将options指定成any类型,这是最简单的,但也是获益最少的。

anyObject,和{}

你可能会试图使用Object{}来表示一个值可以具有任意属性,因为Object是最通用的类型。 然而在这种情况下**any是真正想要使用的类型**,因为它是最灵活的类型。

比如,有一个Object类型的东西,你将不能够在其上调用toLowerCase()

越普通意味着更少的利用类型,但是any比较特殊,它是最普通的类型但是允许你在上面做任何事情。 也就是说你可以在上面调用,构造它,访问它的属性等等。 记住,当你使用any时,你会失去大多数 TypeScript 提供的错误检查和编译器支持。

如果你还是决定使用Object{},你应该选择{}。 虽说它们基本一样,但是从技术角度上来讲{}在一些深奥的情况里比Object更普通。

启用严格检查

TypeScript 提供了一些检查来保证安全以及帮助分析你的程序。 当你将代码转换为了 TypeScript 后,你可以启用这些检查来帮助你获得高度安全性。

没有隐式的any

在某些情况下 TypeScript 没法确定某些值的类型。 那么 TypeScript 会使用any类型代替。 这对代码转换来讲是不错,但是使用any意味着失去了类型安全保障,并且你得不到工具的支持。 你可以使用noImplicitAny选项,让 TypeScript 标记出发生这种情况的地方,并给出一个错误。

严格的nullundefined检查

默认地,TypeScript 把nullundefined当做属于任何类型。 这就是说,声明为number类型的值可以为nullundefined。 因为在 JavaScript 和 TypeScript 里,nullundefined经常会导致 BUG 的产生,所以 TypeScript 包含了strictNullChecks选项来帮助我们减少对这种情况的担忧。

当启用了strictNullChecksnullundefined获得了它们自己各自的类型nullundefined。 当任何值可能null,你可以使用联合类型。 比如,某值可能为numbernull,你可以声明它的类型为number | null

假设有一个值 TypeScript 认为可以为nullundefined,但是你更清楚它的类型,你可以使用!后缀。

declare var foo: string[] | null;

foo.length; // error - 'foo' is possibly 'null'

foo!.length; // okay - 'foo!' just has type 'string[]'

要当心,当你使用strictNullChecks,你的依赖也需要相应地启用strictNullChecks

this没有隐式的any

当你在类的外部使用this关键字时,它会默认获得any类型。 比如,假设有一个Point类,并且我们要添加一个函数做为它的方法:

class Point {
  constructor(public x, public y) {}
  getDistance(p: Point) {
    let dx = p.x - this.x;
    let dy = p.y - this.y;
    return Math.sqrt(dx ** 2 + dy ** 2);
  }
}
// ...

// Reopen the interface.
interface Point {
  distanceFromOrigin(point: Point): number;
}
Point.prototype.distanceFromOrigin = function (point: Point) {
  return this.getDistance({ x: 0, y: 0 });
};

这就产生了我们上面提到的错误 - 如果我们错误地拼写了getDistance并不会得到一个错误。 正因此,TypeScript 有noImplicitThis选项。 当设置了它,TypeScript 会产生一个错误当没有明确指定类型(或通过类型推断)的this被使用时。 解决的方法是在接口或函数上使用指定了类型的this参数:

Point.prototype.distanceFromOrigin = function (this: Point, point: Point) {
  return this.getDistance({ x: 0, y: 0 });
};

手册

基础类型

介绍

为了让程序有价值,我们需要能够处理最简单的数据单元:数字,字符串,结构体,布尔值等。 TypeScript 支持与 JavaScript 几乎相同的数据类型,此外还提供了实用的枚举类型方便我们使用。

Boolean

最基本的数据类型就是简单的 true/false 值,在 JavaScript 和 TypeScript 里叫做boolean(其它语言中也一样)。

let isDone: boolean = false;

Number

和 JavaScript 一样,TypeScript 里的所有数字都是浮点数或者大整数 。 这些浮点数的类型是number, 而大整数的类型则是 bigint。 除了支持十进制和十六进制字面量,TypeScript 还支持 ECMAScript 2015 中引入的二进制和八进制字面量。

let decLiteral: number = 6;
let hexLiteral: number = 0xf00d;
let binaryLiteral: number = 0b1010;
let octalLiteral: number = 0o744;
let bigLiteral: bigint = 100n;

String

JavaScript 程序的另一项基本操作是处理网页或服务器端的文本数据。 像其它语言里一样,我们使用string表示文本数据类型。 和 JavaScript 一样,可以使用双引号(")或单引号(')表示字符串。

let name: string = 'bob';
name = 'smith';

你还可以使用模版字符串,它可以定义多行文本和内嵌表达式。 这种字符串是被反引号包围(` `),并且以${ expr }这种形式嵌入表达式

let name: string = `Gene`;
let age: number = 37;
let sentence: string = `Hello, my name is ${name}.

I'll be ${age + 1} years old next month.`;

这与下面定义sentence的方式效果相同:

let sentence: string =
  'Hello, my name is ' +
  name +
  '.\n\n' +
  "I'll be " +
  (age + 1) +
  ' years old next month.';

Array

TypeScript 像 JavaScript 一样可以操作数组元素。 有两种方式可以定义数组。 第一种,可以在元素类型后面接上[],表示由此类型元素组成的一个数组:

let list: number[] = [1, 2, 3];

第二种方式是使用数组泛型,Array<元素类型>

let list: Array<number> = [1, 2, 3];

Tuple

元组类型允许表示一个已知元素数量和类型的数组,各元素的类型不必相同。比如,你可以定义一对值分别为stringnumber类型的元组。

// Declare a tuple type
let x: [string, number];
// Initialize it
x = ['hello', 10]; // OK
// Initialize it incorrectly
x = [10, 'hello']; // Error

当访问一个已知索引的元素,会得到正确的类型:

console.log(x[0].substr(1)); // OK
console.log(x[1].substr(1)); // Error, 'number' does not have 'substr'

当访问一个越界的元素会报错。

x[3] = 'world'; // Error, Property '3' does not exist on type '[string, number]'.

console.log(x[5].toString()); // Error, Property '5' does not exist on type '[string, number]'.

Enum

enum类型是对 JavaScript 标准数据类型的一个补充。 像 C#等其它语言一样,使用枚举类型可以为一组数值赋予友好的名字。

enum Color {
  Red,
  Green,
  Blue,
}
let c: Color = Color.Green;

默认情况下,从0开始为元素编号。 你也可以手动的指定成员的数值。 例如,我们将上面的例子改成从1开始编号:

enum Color {
  Red = 1,
  Green,
  Blue,
}
let c: Color = Color.Green;

或者,全部都采用手动赋值:

enum Color {
  Red = 1,
  Green = 2,
  Blue = 4,
}
let c: Color = Color.Green;

枚举类型提供的一个便利是你可以由枚举的值得到它的名字。 例如,我们知道数值为 2,但是不确定它映射到 Color 里的哪个名字,我们可以查找相应的名字:

enum Color {
  Red = 1,
  Green,
  Blue,
}
let colorName: string = Color[2];

console.log(colorName); // 显示'Green'因为上面代码里它的值是2

Unknown

当我们在写应用的时候可能会需要描述一个我们还不知道其类型的变量。这些值可以来自动态内容,例如从用户获得,或者我们想在我们的 API 中接收所有可能类型的值。在这些情况下,我们想要让编译器以及未来的用户知道这个变量可以是任意类型。这个时候我们会对它使用 unknown 类型。

let notSure: unknown = 4;
notSure = 'maybe a string instead';

// OK, definitely a boolean
notSure = false;

如果你有一个 unknwon 类型的变量,你可以通过进行 typeof 、比较或者更高级的类型检查来将其的类型范围缩小,这些方法会在后续章节中进一步讨论:

// @errors: 2322 2322 2322
declare const maybe: unknown;
// 'maybe' could be a string, object, boolean, undefined, or other types
const aNumber: number = maybe;

if (maybe === true) {
  // TypeScript knows that maybe is a boolean now
  const aBoolean: boolean = maybe;
  // So, it cannot be a string
  const aString: string = maybe;
}

if (typeof maybe === 'string') {
  // TypeScript knows that maybe is a string
  const aString: string = maybe;
  // So, it cannot be a boolean
  const aBoolean: boolean = maybe;
}

Any

有时候,我们会想要为那些在编程阶段还不清楚类型的变量指定一个类型。 这些值可能来自于动态的内容,比如来自用户输入或第三方代码库。 这种情况下,我们不希望类型检查器对这些值进行检查而是直接让它们通过编译阶段的检查。 那么我们可以使用any类型来标记这些变量:

let notSure: any = 4;
notSure = 'maybe a string instead';
notSure = false; // okay, definitely a boolean

在对现有代码进行改写的时候,any类型是十分有用的,它允许你在编译时可选择地包含或移除类型检查。 你可能认为Object有相似的作用,就像它在其它语言中那样。 但是Object类型的变量只是允许你给它赋任意值 - 但是却不能够在它上面调用任意的方法,即便它真的有这些方法:

let notSure: any = 4;
notSure.ifItExists(); // okay, ifItExists might exist at runtime
notSure.toFixed(); // okay, toFixed exists (but the compiler doesn't check)

let prettySure: Object = 4;
prettySure.toFixed(); // Error: Property 'toFixed' doesn't exist on type 'Object'.

注意:应避免使用Object,而是使用非原始object类型,正如Do's and Don'ts里所讲的那样。

当你只知道一部分数据的类型时,any类型也是有用的。 比如,你有一个数组,它包含了不同的类型的数据:

let list: any[] = [1, true, 'free'];

list[1] = 100;

Void

某种程度上来说,void类型像是与any类型相反,它表示没有任何类型。 当一个函数没有返回值时,你通常会见到其返回值类型是void

function warnUser(): void {
  console.log('This is my warning message');
}

声明一个void类型的变量没有什么大用,因为你只能为它赋予null(只在--strictNullChecks未指定时)和undefined

let unusable: void = undefined;

Null 和 Undefined

TypeScript 里,undefinednull两者各自有自己的类型分别叫做undefinednull。 和void相似,它们的本身的类型用处不是很大:

// Not much else we can assign to these variables!
let u: undefined = undefined;
let n: null = null;

默认情况下nullundefined是所有类型的子类型。 就是说你可以把nullundefined赋值给number类型的变量。

然而,当你指定了--strictNullChecks标记,nullundefined只能赋值给any和它们各自的类型(有一个例外是undefined还可以赋值给void类型)。 这能避免很多常见的问题。 也许在某处你想传入一个stringnullundefined,你可以使用联合类型string | null | undefined

联合类型是高级主题,我们会在以后的章节里讨论它。

注意:我们鼓励尽可能地使用--strictNullChecks,但在本手册里我们假设这个标记是关闭的。

Never

never类型表示的是那些永不存在的值的类型。 例如,never类型是那些总是会抛出异常或根本就不会有返回值的函数表达式或箭头函数表达式的返回值类型; 变量也可能是never类型,当它们被永不为真的类型保护所约束时。

never类型是任何类型的子类型,也可以赋值给任何类型;然而,没有类型是never的子类型或可以赋值给never类型(除了never本身之外)。 即使any也不可以赋值给never

下面是一些返回never类型的函数:

// 返回never的函数必须存在无法达到的终点
function error(message: string): never {
  throw new Error(message);
}

// 推断的返回值类型为never
function fail() {
  return error('Something failed');
}

// 返回never的函数必须存在无法达到的终点
function infiniteLoop(): never {
  while (true) {}
}

Object

object表示非原始类型,也就是除numberstringbooleanbigintsymbolnullundefined之外的类型。

使用object类型,就可以更好的表示像Object.create这样的 API。例如:

declare function create(o: object | null): void;

create({ prop: 0 }); // OK
create(null); // OK

create(42); // Error
create('string'); // Error
create(false); // Error
create(undefined); // Error

类型断言

有时候你会遇到这样的情况,你会比 TypeScript 更了解某个值的详细信息。 通常这会发生在你清楚地知道一个实体具有比它现有类型更确切的类型。

通过类型断言这种方式可以告诉编译器,“相信我,我知道自己在干什么”。 类型断言好比其它语言里的类型转换,但是不进行特殊的数据检查和解构。 它没有运行时的影响,只是在编译阶段起作用。 TypeScript 会假设你,程序员,已经进行了必须的检查。

类型断言有两种形式。 其一是“尖括号”语法:

let someValue: any = 'this is a string';

let strLength: number = (<string>someValue).length;

另一个为as语法:

let someValue: any = 'this is a string';

let strLength: number = (someValue as string).length;

两种形式是等价的。 至于使用哪个大多数情况下是凭个人喜好;然而,当你在 TypeScript 里使用 JSX 时,只有as语法断言是被允许的。

关于let

你可能已经注意到了,我们使用let关键字来代替大家所熟悉的 JavaScript 关键字varlet是 ES2015 引入的关键字,它比var更加安全,因此被看做是声明变量的标准方式。 我们会在以后详细介绍它,很多常见的问题都可以通过使用let来解决,所以尽可能地使用let来代替var吧。

关于 Number, String, Boolean, Symbol 和 Object

我们很容易会认为 NumberStringBooleanSymbol 以及 Object 这些类型和我们以上推荐的小写版本的类型是一样的。但这些类型不属于语言的基本类型,并且几乎在任何时候都不应该被用作一个类型:

// @errors: 2339
function reverse(s: String): String {
  return s.split('').reverse().join('');
}

reverse('hello world');

相对地,我们应该使用 numberstringbooleanobjectsymbol

function reverse(s: string): string {
  return s.split('').reverse().join('');
}

reverse('hello world');

接口

介绍

TypeScript 的核心原则之一是对值所具有的结构进行类型检查。 它有时被称做“鸭式辨型法”或“结构性子类型化”。 在 TypeScript 里,接口的作用就是为这些类型命名和为你的代码或第三方代码定义契约。

接口初探

下面通过一个简单示例来观察接口是如何工作的:

function printLabel(labeledObj: { label: string }) {
  console.log(labeledObj.label);
}

let myObj = { size: 10, label: 'Size 10 Object' };
printLabel(myObj);

类型检查器会查看printLabel的调用。 printLabel有一个参数,并要求这个对象参数有一个名为label类型为string的属性。 需要注意的是,我们传入的对象参数实际上会包含很多属性,但是编译器只会检查那些必需的属性是否存在,并且其类型是否匹配。 然而,有些时候 TypeScript 却并不会这么宽松,我们下面会稍做讲解。

下面我们重写上面的例子,这次使用接口来描述:必须包含一个label属性且类型为string

interface LabeledValue {
  label: string;
}

function printLabel(labeledObj: LabeledValue) {
  console.log(labeledObj.label);
}

let myObj = { size: 10, label: 'Size 10 Object' };
printLabel(myObj);

LabeledValue接口就好比一个名字,用来描述上面例子里的要求。 它代表了有一个label属性且类型为string的对象。 需要注意的是,我们在这里并不能像在其它语言里一样,说传给printLabel的对象实现了这个接口。我们只会去关注值的外形。 只要传入的对象满足上面提到的必要条件,那么它就是被允许的。

还有一点值得提的是,类型检查器不会去检查属性的顺序,只要相应的属性存在并且类型也是对的就可以。

可选属性

接口里的属性不全都是必需的。 有些是只在某些条件下存在,或者根本不存在。 可选属性在应用“option bags”模式时很常用,即给函数传入的参数对象中只有部分属性赋值了。

下面是应用了“option bags”的例子:

interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  let newSquare = { color: 'white', area: 100 };
  if (config.color) {
    newSquare.color = config.color;
  }
  if (config.width) {
    newSquare.area = config.width * config.width;
  }
  return newSquare;
}

let mySquare = createSquare({ color: 'black' });

带有可选属性的接口与普通的接口定义差不多,只是在可选属性名字定义的后面加一个?符号。

可选属性的好处之一是可以对可能存在的属性进行预定义,好处之二是可以捕获引用了不存在的属性时的错误。 比如,我们故意将createSquare里的color属性名拼错,就会得到一个错误提示:

interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  let newSquare = { color: 'white', area: 100 };
  if (config.clor) {
    // Error: Property 'clor' does not exist on type 'SquareConfig'
    newSquare.color = config.clor;
  }
  if (config.width) {
    newSquare.area = config.width * config.width;
  }
  return newSquare;
}

let mySquare = createSquare({ color: 'black' });

只读属性

一些对象属性只能在对象刚刚创建的时候修改其值。 你可以在属性名前用readonly来指定只读属性:

interface Point {
  readonly x: number;
  readonly y: number;
}

你可以通过赋值一个对象字面量来构造一个Point。 赋值后,xy再也不能被改变了。

let p1: Point = { x: 10, y: 20 };
p1.x = 5; // error!

TypeScript 具有ReadonlyArray<T>类型,它与Array<T>相似,只是把所有可变方法去掉了,因此可以确保数组创建后再也不能被修改:

let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;
ro[0] = 12; // error!
ro.push(5); // error!
ro.length = 100; // error!
a = ro; // error!

上面代码的最后一行,可以看到就算把整个ReadonlyArray赋值到一个普通数组也是不可以的。 但是你可以用类型断言重写:

a = ro as number[];

readonly vs const

最简单判断该用readonly还是const的方法是看要把它做为变量使用还是做为一个属性。 做为变量使用的话用const,若做为属性则使用readonly

额外的属性检查

我们在第一个例子里使用了接口,TypeScript 让我们传入{ size: number; label: string; }到仅期望得到{ label: string; }的函数里。 我们已经学过了可选属性,并且知道他们在“option bags”模式里很有用。

然而,天真地将这两者结合的话就会像在 JavaScript 里那样搬起石头砸自己的脚。比如,拿createSquare例子来说:

interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  // ...
}

let mySquare = createSquare({ colour: 'red', width: 100 });

注意传入createSquare的参数拼写为colour而不是color。 在 JavaScript 里,这会默默地失败。

你可能会争辩这个程序已经正确地类型化了,因为width属性是兼容的,不存在color属性,而且额外的colour属性是无意义的。

然而,TypeScript 会认为这段代码可能存在 bug。 对象字面量会被特殊对待而且会经过额外属性检查,当将它们赋值给变量或作为参数传递的时候。 如果一个对象字面量存在任何“目标类型”不包含的属性时,你会得到一个错误。

// error: Object literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?
let mySquare = createSquare({ colour: 'red', width: 100 });

绕开这些检查非常简单。 最简便的方法是使用类型断言:

let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);

然而,最佳的方式是能够添加一个字符串索引签名,前提是你能够确定这个对象可能具有某些做为特殊用途使用的额外属性。 如果SquareConfig带有上面定义的类型的colorwidth属性,并且还会带有任意数量的其它属性,那么我们可以这样定义它:

interface SquareConfig {
  color?: string;
  width?: number;
  [propName: string]: any;
}

我们稍后会讲到索引签名,但在这我们要表示的是SquareConfig可以有任意数量的属性,并且只要它们不是colorwidth,那么就无所谓它们的类型是什么。

还有最后一种跳过这些检查的方式,这可能会让你感到惊讶,它就是将这个对象赋值给一个另一个变量: 因为squareOptions不会经过额外属性检查,所以编译器不会报错。

let squareOptions = { colour: 'red', width: 100 };
let mySquare = createSquare(squareOptions);

上面的方法只在squareOptionsSquareConfig之间有共同的属性时才好用。 在这个例子中,这个属性为width。如果变量间不存在共同的对象属性将会报错。例如:

let squareOptions = { colour: 'red' };
let mySquare = createSquare(squareOptions);

要留意,在像上面一样的简单代码里,你可能不应该去绕开这些检查。 对于包含方法和内部状态的复杂对象字面量来讲,你可能需要使用这些技巧,但是大部额外属性检查错误是真正的 bug。 就是说你遇到了额外类型检查出的错误,比如“option bags”,你应该去审查一下你的类型声明。 在这里,如果支持传入colorcolour属性到createSquare,你应该修改SquareConfig定义来体现出这一点。

函数类型

接口能够描述 JavaScript 中对象拥有的各种各样的外形。 除了描述带有属性的普通对象外,接口也可以描述函数类型。

为了使用接口表示函数类型,我们需要给接口定义一个调用签名。 它就像是一个只有参数列表和返回值类型的函数定义。参数列表里的每个参数都需要名字和类型。

interface SearchFunc {
  (source: string, subString: string): boolean;
}

这样定义后,我们可以像使用其它接口一样使用这个函数类型的接口。 下例展示了如何创建一个函数类型的变量,并将一个同类型的函数赋值给这个变量。

let mySearch: SearchFunc;
mySearch = function (source: string, subString: string) {
  let result = source.search(subString);
  return result > -1;
};

对于函数类型的类型检查来说,函数的参数名不需要与接口里定义的名字相匹配。 比如,我们使用下面的代码重写上面的例子:

let mySearch: SearchFunc;
mySearch = function (src: string, sub: string): boolean {
  let result = src.search(sub);
  return result > -1;
};

函数的参数会逐个进行检查,要求对应位置上的参数类型是兼容的。 如果你不想指定类型,TypeScript 的类型系统会推断出参数类型,因为函数直接赋值给了SearchFunc类型变量。 函数的返回值类型是通过其返回值推断出来的(此例是falsetrue)。

let mySearch: SearchFunc;
mySearch = function (src, sub) {
  let result = src.search(sub);
  return result > -1;
};

如果让这个函数返回数字或字符串,类型检查器会警告我们函数的返回值类型与SearchFunc接口中的定义不匹配。

let mySearch: SearchFunc;

// error: Type '(src: string, sub: string) => string' is not assignable to type 'SearchFunc'.
// Type 'string' is not assignable to type 'boolean'.
mySearch = function (src, sub) {
  let result = src.search(sub);
  return 'string';
};

可索引的类型

与使用接口描述函数类型差不多,我们也可以描述那些能够“通过索引得到”的类型,比如a[10]ageMap["daniel"]。 可索引类型具有一个索引签名,它描述了对象索引的类型,还有相应的索引返回值类型。 让我们看一个例子:

interface StringArray {
  [index: number]: string;
}

let myArray: StringArray;
myArray = ['Bob', 'Fred'];

let myStr: string = myArray[0];

上面例子里,我们定义了StringArray接口,它具有索引签名。 这个索引签名表示了当用number去索引StringArray时会得到string类型的返回值。

Typescript 支持两种索引签名:字符串和数字。 可以同时使用两种类型的索引,但是数字索引的返回值必须是字符串索引返回值类型的子类型。 这是因为当使用number来索引时,JavaScript 会将它转换成string然后再去索引对象。 也就是说用100(一个number)去索引等同于使用"100"(一个string)去索引,因此两者需要保持一致。

class Animal {
  name: string;
}
class Dog extends Animal {
  breed: string;
}

// 错误:使用数值型的字符串索引,有时会得到完全不同的Animal!
interface NotOkay {
  [x: number]: Animal;
  [x: string]: Dog;
}

字符串索引签名能够很好的描述dictionary模式,并且它们也会确保所有属性与其返回值类型相匹配。 因为字符串索引声明了obj.propertyobj["property"]两种形式都可以。 下面的例子里,name的类型与字符串索引类型不匹配,所以类型检查器给出一个错误提示:

interface NumberDictionary {
  [index: string]: number;
  length: number; // 可以,length是number类型
  name: string; // 错误,`name`的类型与索引类型返回值的类型不匹配
}

但如果索引签名是包含属性类型的联合类型,那么使用不同类型的属性就是允许的。

interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // ok, length is a number
  name: string; // ok, name is a string
}

最后,你可以将索引签名设置为只读,这样就防止了给索引赋值:

interface ReadonlyStringArray {
  readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = ['Alice', 'Bob'];
myArray[2] = 'Mallory'; // error!

你不能设置myArray[2],因为索引签名是只读的。

类类型

实现接口

与 C# 或 Java 里接口的基本作用一样,TypeScript 也能够用它来明确的强制一个类去符合某种契约。

interface ClockInterface {
  currentTime: Date;
}

class Clock implements ClockInterface {
  currentTime: Date = new Date();
  constructor(h: number, m: number) {}
}

你也可以在接口中描述一个方法,在类里实现它,如同下面的setTime方法一样:

interface ClockInterface {
  currentTime: Date;
  setTime(d: Date): void;
}

class Clock implements ClockInterface {
  currentTime: Date = new Date();
  setTime(d: Date) {
    this.currentTime = d;
  }
  constructor(h: number, m: number) {}
}

接口描述了类的公共部分,而不是公共和私有两部分。 它不会帮你检查类是否具有某些私有成员。

类静态部分与实例部分的区别

当你操作类和接口的时候,你要知道类是具有两个类型的:静态部分的类型和实例的类型。 你会注意到,当你用构造器签名去定义一个接口并试图定义一个类去实现这个接口时会得到一个错误:

interface ClockConstructor {
  new (hour: number, minute: number);
}

class Clock implements ClockConstructor {
  currentTime: Date;
  constructor(h: number, m: number) {}
}

这里因为当一个类实现了一个接口时,只对其实例部分进行类型检查。 constructor 存在于类的静态部分,所以不在检查的范围内。

因此,我们应该直接操作类的静态部分。 看下面的例子,我们定义了两个接口,ClockConstructor为构造函数所用和ClockInterface为实例方法所用。 为了方便我们定义一个构造函数createClock,它用传入的类型创建实例。

interface ClockConstructor {
  new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
  tick(): void;
}

function createClock(
  ctor: ClockConstructor,
  hour: number,
  minute: number
): ClockInterface {
  return new ctor(hour, minute);
}

class DigitalClock implements ClockInterface {
  constructor(h: number, m: number) {}
  tick() {
    console.log('beep beep');
  }
}
class AnalogClock implements ClockInterface {
  constructor(h: number, m: number) {}
  tick() {
    console.log('tick tock');
  }
}

let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);

因为createClock的第一个参数是ClockConstructor类型,在createClock(AnalogClock, 7, 32)里,会检查AnalogClock是否符合构造函数签名。

另一种简单方式是使用类表达式:

interface ClockConstructor {
  new (hour: number, minute: number);
}

interface ClockInterface {
  tick();
}

const Clock: ClockConstructor = class Clock implements ClockInterface {
  constructor(h: number, m: number) {}
  tick() {
    console.log('beep beep');
  }
};

继承接口

和类一样,接口也可以相互继承。 这让我们能够从一个接口里复制成员到另一个接口里,可以更灵活地将接口分割到可重用的模块里。

interface Shape {
  color: string;
}

interface Square extends Shape {
  sideLength: number;
}

let square = {} as Square;
square.color = 'blue';
square.sideLength = 10;

一个接口可以继承多个接口,创建出多个接口的合成接口。

interface Shape {
  color: string;
}

interface PenStroke {
  penWidth: number;
}

interface Square extends Shape, PenStroke {
  sideLength: number;
}

let square = {} as Square;
square.color = 'blue';
square.sideLength = 10;
square.penWidth = 5.0;

混合类型

先前我们提过,接口能够描述 JavaScript 里丰富的类型。 因为 JavaScript 其动态灵活的特点,有时你会希望一个对象可以同时具有上面提到的多种类型。

一个例子就是,一个对象可以同时作为函数和对象使用,并带有额外的属性。

interface Counter {
  (start: number): string;
  interval: number;
  reset(): void;
}

function getCounter(): Counter {
  let counter = function (start: number) {} as Counter;
  counter.interval = 123;
  counter.reset = function () {};
  return counter;
}

let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;

在使用 JavaScript 第三方库的时候,你可能需要像上面那样去完整地定义类型。

接口继承类

当接口继承了一个类类型时,它会继承类的成员但不包括其实现。 就好像接口声明了所有类中存在的成员,但并没有提供具体实现一样。 接口同样会继承到类的 private 和 protected 成员。 这意味着当你创建了一个接口继承了一个拥有私有或受保护的成员的类时,这个接口类型只能被这个类或其子类所实现(implement)。

当你有一个庞大的继承结构时这很有用,但要指出的是你的代码只在子类拥有特定属性时起作用。 除了继承自基类,子类之间不必相关联。 例:

class Control {
  private state: any;
}

interface SelectableControl extends Control {
  select(): void;
}

class Button extends Control implements SelectableControl {
  select() {}
}

class TextBox extends Control {
  select() {}
}

class ImageControl implements SelectableControl {
  // Error: Class 'ImageControl' incorrectly implements interface 'SelectableControl'.
  //  Types have separate declarations of a private property 'state'.
  private state: any;
  select() {}
}

在上面的例子里,SelectableControl包含了Control的所有成员,包括私有成员state。 因为state是私有成员,所以只能够是Control的子类们才能实现SelectableControl接口。 因为只有Control的子类才能够拥有一个声明于Control的私有成员state,这对私有成员的兼容性是必需的。

Control类内部,是允许通过SelectableControl的实例来访问私有成员state的。 实际上,SelectableControl就像Control一样,并拥有一个select方法。 ButtonTextBox类是SelectableControl的子类(因为它们都继承自Control并有select方法)。而对于 ImageControl 类,它有自身的私有成员 state 而不是通过继承 Control 得来的,所以它不可以实现 SelectableControl

函数

介绍

函数是 JavaScript 应用程序的基础。 它帮助你实现抽象层,模拟类,信息隐藏和模块。 在 TypeScript 里,虽然已经支持类,命名空间和模块,但函数仍然是主要的定义行为的地方。 TypeScript 为 JavaScript 函数添加了额外的功能,让我们可以更容易地使用。

函数

和 JavaScript 一样,TypeScript 函数可以创建有名字的函数和匿名函数。 你可以随意选择适合应用程序的方式,不论是定义一系列 API 函数还是只使用一次的函数。

通过下面的例子可以迅速回想起这两种 JavaScript 中的函数:

// Named function
function add(x, y) {
  return x + y;
}

// Anonymous function
let myAdd = function (x, y) {
  return x + y;
};

在 JavaScript 里,函数可以使用函数体外部的变量。 当函数这么做时,我们说它‘捕获’了这些变量。 至于为什么可以这样做以及其中的利弊超出了本文的范围,但是深刻理解这个机制对学习 JavaScript 和 TypeScript 会很有帮助。

let z = 100;

function addToZ(x, y) {
  return x + y + z;
}

函数类型

为函数定义类型

让我们为上面那个函数添加类型:

function add(x: number, y: number): number {
  return x + y;
}

let myAdd = function (x: number, y: number): number {
  return x + y;
};

我们可以给每个参数添加类型之后再为函数本身添加返回值类型。 TypeScript 能够根据返回语句自动推断出返回值类型,因此我们通常省略它。

书写完整函数类型

现在我们已经为函数指定了类型,下面让我们写出函数的完整类型。

let myAdd: (x: number, y: number) => number = function (
  x: number,
  y: number
): number {
  return x + y;
};

函数类型包含两部分:参数类型和返回值类型。 当写出完整函数类型的时候,这两部分都是需要的。 我们以参数列表的形式写出参数类型,为每个参数指定一个名字和类型。 这个名字只是为了增加可读性。 我们也可以这么写:

let myAdd: (baseValue: number, increment: number) => number = function (
  x: number,
  y: number
): number {
  return x + y;
};

只要参数类型是匹配的,那么就认为它是有效的函数类型,而不在乎参数名是否正确。

第二部分是返回值类型。 对于返回值,我们在函数和返回值类型之前使用(=>)符号,使之清晰明了。 如之前提到的,返回值类型是函数类型的必要部分,如果函数没有返回任何值,你也必须指定返回值类型为void而不能留空。

函数的类型只是由参数类型和返回值组成的。 函数中使用的捕获变量不会体现在类型里。 实际上,这些变量是函数的隐藏状态并不是组成 API 的一部分。

推断类型

尝试这个例子的时候,你会注意到,就算仅在等式的一侧带有类型,TypeScript 编译器仍可正确识别类型:

// myAdd has the full function type
let myAdd = function (x: number, y: number): number {
  return x + y;
};

// The parameters `x` and `y` have the type number
let myAdd: (baseValue: number, increment: number) => number = function (x, y) {
  return x + y;
};

这叫做“按上下文归类”,是类型推论的一种。 它帮助我们更好地为程序指定类型。

可选参数和默认参数

TypeScript 里的每个函数参数都是必须的。 这不是指不能传递nullundefined作为参数,而是说编译器检查用户是否为每个参数都传入了值。 编译器还会假设只有这些参数会被传递进函数。 简短地说,传递给一个函数的参数个数必须与函数期望的参数个数一致。

function buildName(firstName: string, lastName: string) {
  return firstName + ' ' + lastName;
}

let result1 = buildName('Bob'); // error, too few parameters
let result2 = buildName('Bob', 'Adams', 'Sr.'); // error, too many parameters
let result3 = buildName('Bob', 'Adams'); // ah, just right

JavaScript 里,每个参数都是可选的,可传可不传。 没传参的时候,它的值就是 undefined。 在 TypeScript 里我们可以在参数名旁使用?实现可选参数的功能。 比如,我们想让 last name 是可选的:

function buildName(firstName: string, lastName?: string) {
  if (lastName) return firstName + ' ' + lastName;
  else return firstName;
}

let result1 = buildName('Bob'); // works correctly now
let result2 = buildName('Bob', 'Adams', 'Sr.'); // error, too many parameters
let result3 = buildName('Bob', 'Adams'); // ah, just right

可选参数必须跟在必须参数后面。 如果上例我们想让 first name 是可选的,那么就必须调整它们的位置,把 first name 放在后面。

在 TypeScript 里,我们也可以为参数提供一个默认值当用户没有传递这个参数或传递的值是undefined时。 它们叫做有默认初始化值的参数。 让我们修改上例,把 last name 的默认值设置为"Smith"

function buildName(firstName: string, lastName = 'Smith') {
  return firstName + ' ' + lastName;
}

let result1 = buildName('Bob'); // works correctly now, returns "Bob Smith"
let result2 = buildName('Bob', undefined); // still works, also returns "Bob Smith"
let result3 = buildName('Bob', 'Adams', 'Sr.'); // error, too many parameters
let result4 = buildName('Bob', 'Adams'); // ah, just right

在所有必须参数后面的带默认初始化的参数都是可选的,与可选参数一样,在调用函数的时候可以省略。 也就是说可选参数与末尾的默认参数共享参数类型。

function buildName(firstName: string, lastName?: string) {
  // ...
}

function buildName(firstName: string, lastName = 'Smith') {
  // ...
}

共享同样的类型(firstName: string, lastName?: string) => string。 在函数类型中,默认参数的默认值不会显示,而只会显示它是一个可选参数。

与普通可选参数不同的是,带默认值的参数不需要放在必须参数的后面。 如果带默认值的参数出现在必须参数前面,用户必须明确的传入undefined值来获得默认值。 例如,我们重写最后一个例子,让firstName是带默认值的参数:

function buildName(firstName = 'Will', lastName: string) {
  return firstName + ' ' + lastName;
}

let result1 = buildName('Bob'); // error, too few parameters
let result2 = buildName('Bob', 'Adams', 'Sr.'); // error, too many parameters
let result3 = buildName('Bob', 'Adams'); // okay and returns "Bob Adams"
let result4 = buildName(undefined, 'Adams'); // okay and returns "Will Adams"

剩余参数

必要参数,默认参数和可选参数有个共同点:它们表示某一个参数。 有时,你想同时操作多个参数,或者你并不知道会有多少参数传递进来。 在 JavaScript 里,你可以使用arguments来访问所有传入的参数。

在 TypeScript 里,你可以把所有参数收集到一个变量里:

function buildName(firstName: string, ...restOfName: string[]) {
  return firstName + ' ' + restOfName.join(' ');
}

let employeeName = buildName('Joseph', 'Samuel', 'Lucas', 'MacKinzie');

剩余参数会被当做个数不限的可选参数。 可以一个都没有,同样也可以有任意个。 编译器创建参数数组,名字是你在省略号(...)后面给定的名字,你可以在函数体内使用这个数组。

这个省略号也会在带有剩余参数的函数类型定义上使用到:

function buildName(firstName: string, ...restOfName: string[]) {
  return firstName + ' ' + restOfName.join(' ');
}

let buildNameFun: (fname: string, ...rest: string[]) => string = buildName;

this

学习如何在 JavaScript 里正确使用this就好比一场成年礼。 由于 TypeScript 是 JavaScript 的超集,TypeScript 程序员也需要弄清this工作机制并且当有 bug 的时候能够找出错误所在。 幸运的是,TypeScript 能通知你错误地使用了this的地方。 如果你想了解 JavaScript 里的this是如何工作的,那么首先阅读 Yehuda Katz 写的Understanding JavaScript Function Invocation and "this"。 Yehuda 的文章详细的阐述了this的内部工作原理,因此我们这里只做简单介绍。

this和箭头函数

JavaScript 里,this的值在函数被调用的时候才会指定。 这是个既强大又灵活的特点,但是你需要花点时间弄清楚函数调用的上下文是什么。 但众所周知,这不是一件很简单的事,尤其是在返回一个函数或将函数当做参数传递的时候。

下面看一个例子:

let deck = {
  suits: ['hearts', 'spades', 'clubs', 'diamonds'],
  cards: Array(52),
  createCardPicker: function () {
    return function () {
      let pickedCard = Math.floor(Math.random() * 52);
      let pickedSuit = Math.floor(pickedCard / 13);

      return { suit: this.suits[pickedSuit], card: pickedCard % 13 };
    };
  },
};

let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();

alert('card: ' + pickedCard.card + ' of ' + pickedCard.suit);

可以看到createCardPicker是个函数,并且它又返回了一个函数。 如果我们尝试运行这个程序,会发现它并没有弹出对话框而是报错了。 因为createCardPicker返回的函数里的this被设置成了window而不是deck对象。 因为我们只是独立地调用了cardPicker()。 顶级的非方法式调用会将this视为window。 (注意:在严格模式下,thisundefined而不是window)。

为了解决这个问题,我们可以在函数被返回时就绑好正确的this。 这样的话,无论之后怎么使用它,都会引用绑定的‘deck’对象。 我们需要改变函数表达式来使用 ECMAScript 6 箭头语法。 箭头函数能保存函数创建时的this值,而不是调用时的值:

let deck = {
  suits: ['hearts', 'spades', 'clubs', 'diamonds'],
  cards: Array(52),
  createCardPicker: function () {
    // NOTE: the line below is now an arrow function, allowing us to capture 'this' right here
    return () => {
      let pickedCard = Math.floor(Math.random() * 52);
      let pickedSuit = Math.floor(pickedCard / 13);

      return { suit: this.suits[pickedSuit], card: pickedCard % 13 };
    };
  },
};

let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();

alert('card: ' + pickedCard.card + ' of ' + pickedCard.suit);

更好事情是,TypeScript 会警告你犯了一个错误,如果你给编译器设置了--noImplicitThis标记。 它会指出this.suits[pickedSuit]里的this的类型为any

this参数

不幸的是,this.suits[pickedSuit]中的this的类型依旧为any。 这是因为this来自对象字面量里的函数表达式。 修改的方法是,提供一个显式的this参数。 this参数是个假的参数,它出现在参数列表的最前面:

function f(this: void) {
  // make sure `this` is unusable in this standalone function
}

让我们往例子里添加一些接口,CardDeck,让类型重用能够变得清晰简单些:

interface Card {
  suit: string;
  card: number;
}
interface Deck {
  suits: string[];
  cards: number[];
  createCardPicker(this: Deck): () => Card;
}
let deck: Deck = {
  suits: ['hearts', 'spades', 'clubs', 'diamonds'],
  cards: Array(52),
  // NOTE: The function now explicitly specifies that its callee must be of type Deck
  createCardPicker: function (this: Deck) {
    return () => {
      let pickedCard = Math.floor(Math.random() * 52);
      let pickedSuit = Math.floor(pickedCard / 13);

      return { suit: this.suits[pickedSuit], card: pickedCard % 13 };
    };
  },
};

let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();

alert('card: ' + pickedCard.card + ' of ' + pickedCard.suit);

现在 TypeScript 知道createCardPicker期望在某个Deck对象上调用。 也就是说thisDeck类型的,而非any,因此--noImplicitThis不会报错了。

回调函数里的this参数

当你将一个函数传递到某个库函数里在稍后被调用时,你可能也见到过回调函数里的this会报错。 因为当回调函数被调用时,它会被当成一个普通函数调用,this将为undefined。 稍做改动,你就可以通过this参数来避免错误。 首先,库函数的作者要指定this的类型:

interface UIElement {
  addClickListener(onclick: (this: void, e: Event) => void): void;
}

this: void意味着addClickListener期望onclick是一个函数且它不需要一个this类型。 然后,为调用代码里的this添加类型注解:

class Handler {
  info: string;
  onClickBad(this: Handler, e: Event) {
    // oops, used this here. using this callback would crash at runtime
    this.info = e.message;
  }
}
let h = new Handler();
uiElement.addClickListener(h.onClickBad); // error!

指定了this类型后,你显式声明onClickBad必须在Handler的实例上调用。 然后 TypeScript 会检测到addClickListener要求函数带有this: void。 改变this类型来修复这个错误:

class Handler {
  info: string;
  onClickGood(this: void, e: Event) {
    // can't use this here because it's of type void!
    console.log('clicked!');
  }
}
let h = new Handler();
uiElement.addClickListener(h.onClickGood);

因为onClickGood指定了this类型为void,因此传递addClickListener是合法的。 当然了,这也意味着不能使用this.info. 如果你两者都想要,你不得不使用箭头函数了:

class Handler {
  info: string;
  onClickGood = (e: Event) => {
    this.info = e.message;
  };
}

这是可行的因为箭头函数使用外层的this,所以你总是可以把它们传给期望this: void的函数。 缺点是每个Handler对象都会创建一个箭头函数。 另一方面,方法只会被创建一次,添加到Handler的原型链上。 它们在不同Handler对象间是共享的。

重载

JavaScript 本身是个动态语言。 JavaScript 里函数根据传入不同的参数而返回不同类型的数据是很常见的。

let suits = ['hearts', 'spades', 'clubs', 'diamonds'];

function pickCard(x): any {
  // Check to see if we're working with an object/array
  // if so, they gave us the deck and we'll pick the card
  if (typeof x == 'object') {
    let pickedCard = Math.floor(Math.random() * x.length);
    return pickedCard;
  }
  // Otherwise just let them pick the card
  else if (typeof x == 'number') {
    let pickedSuit = Math.floor(x / 13);
    return { suit: suits[pickedSuit], card: x % 13 };
  }
}

let myDeck = [
  { suit: 'diamonds', card: 2 },
  { suit: 'spades', card: 10 },
  { suit: 'hearts', card: 4 },
];
let pickedCard1 = myDeck[pickCard(myDeck)];
alert('card: ' + pickedCard1.card + ' of ' + pickedCard1.suit);

let pickedCard2 = pickCard(15);
alert('card: ' + pickedCard2.card + ' of ' + pickedCard2.suit);

pickCard方法根据传入参数的不同会返回两种不同的类型。 如果传入的是代表纸牌的对象,函数作用是从中抓一张牌。 如果用户想抓牌,我们告诉他抓到了什么牌。 但是这怎么在类型系统里表示呢。

方法是为同一个函数提供多个函数类型定义来进行函数重载。 编译器会根据这个列表去处理函数的调用。 下面我们来重载pickCard函数。

let suits = ['hearts', 'spades', 'clubs', 'diamonds'];

function pickCard(x: { suit: string; card: number }[]): number;
function pickCard(x: number): { suit: string; card: number };
function pickCard(x): any {
  // Check to see if we're working with an object/array
  // if so, they gave us the deck and we'll pick the card
  if (typeof x == 'object') {
    let pickedCard = Math.floor(Math.random() * x.length);
    return pickedCard;
  }
  // Otherwise just let them pick the card
  else if (typeof x == 'number') {
    let pickedSuit = Math.floor(x / 13);
    return { suit: suits[pickedSuit], card: x % 13 };
  }
}

let myDeck = [
  { suit: 'diamonds', card: 2 },
  { suit: 'spades', card: 10 },
  { suit: 'hearts', card: 4 },
];
let pickedCard1 = myDeck[pickCard(myDeck)];
alert('card: ' + pickedCard1.card + ' of ' + pickedCard1.suit);

let pickedCard2 = pickCard(15);
alert('card: ' + pickedCard2.card + ' of ' + pickedCard2.suit);

这样改变后,重载的pickCard函数在调用的时候会进行正确的类型检查。

为了让编译器能够选择正确的检查类型,它与 JavaScript 里的处理流程相似。 它查找重载列表,尝试使用第一个重载定义。 如果匹配的话就使用这个。 因此,在定义重载的时候,一定要把最精确的定义放在最前面。

注意,function pickCard(x): any并不是重载列表的一部分,因此这里只有两个重载:一个是接收对象另一个接收数字。 以其它参数调用pickCard会产生错误。

字面量类型

介绍

一个字面量是一个集体类型中更为具体的一种子类型。意思是:"Hello World" 是一个 string,但是一个 string 不是类型系统中的 "Hello World"

目前 TypeScript 中有三种可用的字面量类型集合,分别是:字符串、数字和布尔值。通过使用字面量类型,你可以规定一个字符串、数字或布尔值必须含有的确定值。

字面量收窄

当你通过 varlet 来声明一个变量时,实际上你在告诉编译器这个变量中的内容有可能会被改变。与之相对地,用 const 来声明对象会让 TypeScript 知道这个对象永远不会被改变。

// We're making a guarantee that this variable
// helloWorld will never change, by using const.

// So, TypeScript sets the type to be "Hello World" not string
const helloWorld = "Hello World";

// On the other hand, a let can change, and so the compiler declares it a string
let hiWorld = "Hi World";

从无穷多种可能的例子(string 变量的值有无穷多种)到一个更小、确定数量的例子(在上述例子中,"Hello Wrold" 的可能值只有一种)的过程就叫收窄。

字符串字面量类型

字面量类型可以通过联合联系、类型守卫、类型别名来结合实际字符串值。通过这些特性,我们可以获取一种字符串并使其有类似枚举(enum)的行为。

type Easing = "ease-in" | "ease-out" | "ease-in-out";

class UIElement {
  animate(dx: number, dy: number, easing: Easing) {
    if (easing === "ease-in") {
      // ...
    } else if (easing === "ease-out") {
    } else if (easing === "ease-in-out") {
    } else {
      // It's possible that someone could reach this
      // by ignoring your types though.
    }
  }
}

let button = new UIElement();
button.animate(0, 0, "ease-in");
button.animate(0, 0, "uneasy");
// Error: Argument of type '"uneasy"' is not assignable to parameter of type 'Easing'.

你可以传递三种允许的字符串,但是如果传递其他的字符串会收到如下错误:

Argument of type '"uneasy"' is not assignable to parameter of type '"ease-in" | "ease-out" | "ease-in-out"'

字符串字面可以通过相同的方式用来分别重载:

function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
// ... more overloads ...
function createElement(tagName: string): Element {
  // ... code goes here ...
}

数字字面量类型

TypeScript 还有数字字面量类型,它的行为和上述字符串字面量类型相同。

function rollDice(): 1 | 2 | 3 | 4 | 5 | 6 {
  return (Math.floor(Math.random() * 6) + 1) as 1 | 2 | 3 | 4 | 5 | 6;
}

const result = rollDice();

数字字面量类型经常用来描述配置值:

interface MapConfig {
  lng: number;
  lat: number;
  tileSize: 8 | 16 | 32;
}

setupMap({ lng: -73.935242, lat: 40.73061, tileSize: 16 });

布尔字面量类型

TypeScript 还有布尔值字面量类型,你可以通过他们来约束某些属性之间互有关联的对象。

interface ValidationSuccess {
  isValid: true;
  reason: null;
};

interface ValidationFailure {
  isValid: false;
  reason: string;
};

type ValidationResult =
  | ValidationSuccess
  | ValidationFailure;

联合类型和交叉类型

介绍

到目前为止,手册已经涵盖了原子对象的类型。 但是,随着对更多类型进行建模,你会发现自己正在寻找可以组合现有类型的工具,而不是从头开始创建它们。

交叉类型和联合类型是组合类型的方式之一。

联合类型

有时,你会遇到一个库,它期望一个参数是 numberstring 。 例如下面的函数:

/**
 * Takes a string and adds "padding" to the left.
 * If 'padding' is a string, then 'padding' is appended to the left side.
 * If 'padding' is a number, then that number of spaces is added to the left side.
 */
function padLeft(value: string, padding: any) {
  if (typeof padding === "number") {
    return Array(padding + 1).join(" ") + value;
  }
  if (typeof padding === "string") {
    return padding + value;
  }
  throw new Error(`Expected string or number, got '${typeof padding}'.`);
}

padLeft("Hello world", 4); // returns "    Hello world"

在上面的例子中,padLeft的问题在于其padding参数的类型为any。 这意味着我们可以用numberstring之外的参数类型来调用它,而TypeScript也能接受。

declare function padLeft(value: string, padding: any): string;
// ---cut---
// 编译时通过但是运行时失败。
let indentedString = padLeft("Hello world", true);

在传统的面向对象编程中,我们会通过创建一个具有层状结构的类型来抽象这两个类型。 虽然这更明确,但也有点矫枉过正。 padLeft的原始版本的一个好处是,我们可以直接传递基本元素。 这意味着用法简单而简洁。 而且如果我们只是想使用一个已经存在于其他地方的函数,这种新方法也无济于事。

为了取代any,我们可以为padding参数使用 联合类型

// @errors: 2345
/**
 * Takes a string and adds "padding" to the left.
 * If 'padding' is a string, then 'padding' is appended to the left side.
 * If 'padding' is a number, then that number of spaces is added to the left side.
 */
function padLeft(value: string, padding: string | number) {
  // ...
}

let indentedString = padLeft("Hello world", true);

一个联合类型表示一个值的类型可以是几个类型中的一个。 我们用竖线(|)来分隔不同类型,所以number | string | boolean是一个可以是numberstringboolean的值的类型。

具有公共字段的联合

如果我们有一个联合类型的值,则只能访问联合中所有类型共有的成员。

// @errors: 2339

interface Bird {
  fly(): void;
  layEggs(): void;
}

interface Fish {
  swim(): void;
  layEggs(): void;
}

declare function getSmallPet(): Fish | Bird;

let pet = getSmallPet();
pet.layEggs();

// 只有两种可能类型中的一种可用
pet.swim();

联合类型在这里可能有点棘手,但它只是需要一点直觉来适应。 如果一个值的类型是A | B,我们只能 确定 它有A B都有的成员。 在这个例子中,Bird有一个名为fly的成员。 我们不能确定一个类型为Bird | Fish的变量是否有一个fly方法。 如果该变量在运行时确实是Fish,那么调用pet.fly()将会失败。

可区分联合

使用联合的一种常用技术是使用字面量类型的单个字段,您可以使用该字段来缩小 TypeScript 可能的当前类型。例如,我们将创建一个包含三种类型的联合,这些类型具有一个共享字段。

type NetworkLoadingState = {
  state: "loading";
};

type NetworkFailedState = {
  state: "failed";
  code: number;
};

type NetworkSuccessState = {
  state: "success";
  response: {
    title: string;
    duration: number;
    summary: string;
  };
};

// 创建一个只代表上述类型之一的类型,但你还不确定它是哪个。
type NetworkState =
  | NetworkLoadingState
  | NetworkFailedState
  | NetworkSuccessState;

上述类型都以一个名为state的字段,然后它们也有自己的字段。

NetworkLoadingStateNetworkFailedStateNetworkSuccessState
statestatestate
coderesponse

鉴于state字段在NetworkState的每个类型中都是通用的--你的代码无需存在检查即可安全访问。

有了state这个字面类型,你可以将state的值与相应的字符串进行比较,TypeScript就会知道当前使用的是哪个类型。

NetworkLoadingStateNetworkFailedStateNetworkSuccessState
"loading""failed""success"

在这个例子中,你可以使用switch语句来缩小在运行时代表哪种类型:

// @errors: 2339
type NetworkLoadingState = {
  state: "loading";
};

type NetworkFailedState = {
  state: "failed";
  code: number;
};

type NetworkSuccessState = {
  state: "success";
  response: {
    title: string;
    duration: number;
    summary: string;
  };
};
// ---cut---
type NetworkState =
  | NetworkLoadingState
  | NetworkFailedState
  | NetworkSuccessState;

function logger(state: NetworkState): string {
  // 现在,TypeScript不知道state是三种可能类型中的哪一种。

  // 试图访问一个不是所有类型都共享的属性将引发一个错误
  state.code;

  // 通过选择state,TypeScript可以在代码流分析中缩小联合的范围
  switch (state.state) {
    case "loading":
      return "Downloading...";
    case "failed":
      // 这里的类型一定是NetworkFailedState,所以访问`code`字段是安全的。
      return `Error ${state.code} downloading`;
    case "success":
      return `Downloaded ${state.response.title} - ${state.response.summary}`;
  }
}

联合的穷尽性检查

我们希望编译器能在我们没能覆盖可区分联合的所有变体时告诉我们。 比如,如果我们添加NetworkFromCachedStateNetworkState,我们也需要更新logger

// @errors: 2366
type NetworkLoadingState = { state: "loading" };
type NetworkFailedState = { state: "failed"; code: number };
type NetworkSuccessState = {
  state: "success";
  response: {
    title: string;
    duration: number;
    summary: string;
  };
};
// ---cut---
type NetworkFromCachedState = {
  state: "from_cache";
  id: string;
  response: NetworkSuccessState["response"];
};

type NetworkState =
  | NetworkLoadingState
  | NetworkFailedState
  | NetworkSuccessState
  | NetworkFromCachedState;

function logger(s: NetworkState) {
  switch (s.state) {
    case "loading":
      return "loading request";
    case "failed":
      return `failed with code ${s.code}`;
    case "success":
      return "got response";
  }
}

这里有两种方法实现。 第一种方法是打开strictNullChecks并指定返回类型:

// @errors: 2366
type NetworkLoadingState = { state: "loading" };
type NetworkFailedState = { state: "failed"; code: number };
type NetworkSuccessState = { state: "success" };
type NetworkFromCachedState = { state: "from_cache" };

type NetworkState =
  | NetworkLoadingState
  | NetworkFailedState
  | NetworkSuccessState
  | NetworkFromCachedState;

// ---cut---
function logger(s: NetworkState): string {
  switch (s.state) {
    case "loading":
      return "loading request";
    case "failed":
      return `failed with code ${s.code}`;
    case "success":
      return "got response";
  }
}

因为switch不再是详尽的,TypeScript知道函数有时可能会返回undefined。 如果你有一个明确的返回类型string,那么你会得到一个错误,返回类型实际上是string | undefined。 然而,这种方法是相当微妙的,此外,strictNullChecks并不总是对旧代码起作用。

第二种方法是使用编译器用来检查穷尽性的never类型:

// @errors: 2345
type NetworkLoadingState = { state: "loading" };
type NetworkFailedState = { state: "failed"; code: number };
type NetworkSuccessState = { state: "success" };
type NetworkFromCachedState = { state: "from_cache" };

type NetworkState =
  | NetworkLoadingState
  | NetworkFailedState
  | NetworkSuccessState
  | NetworkFromCachedState;
// ---cut---
function assertNever(x: never): never {
  throw new Error("Unexpected object: " + x);
}

function logger(s: NetworkState): string {
  switch (s.state) {
    case "loading":
      return "loading request";
    case "failed":
      return `failed with code ${s.code}`;
    case "success":
      return "got response";
    default:
      return assertNever(s);
  }
}

在这里,assertNever检查s是否属于never类型—即所有其他情况都被移除后剩下的类型。 如果你忘记了这个情况,那么s将会有一个实际的类型,而你将会得到一个类型错误。 这个方法需要你定义一个额外的函数,但是当你忘记的时候就更明显了,因为错误信息中包括了丢失的类型名称。

交叉类型

交叉类型与联合类型密切相关,但它们的使用方式非常不同。 交叉类型将多个类型合并为一个。 这允许你把现有的类型加在一起,得到一个具有你需要的所有功能的单个类型。 例如,Person & Serializable & Loggable是一种类型,它是PersonSerializableLoggable的全部。 这意味着这种类型的对象将拥有这三种类型的所有成员。

例如,如果你有具有一致的错误处理的网络请求,那么你可以将错误处理分离到它自己的类型中,与对应于单个响应类型的类型合并。

interface ErrorHandling {
  success: boolean;
  error?: { message: string };
}

interface ArtworksData {
  artworks: { title: string }[];
}

interface ArtistsData {
  artists: { name: string }[];
}

// 这些接口被组合后拥有一致的错误处理,和它们自己的数据

type ArtworksResponse = ArtworksData & ErrorHandling;
type ArtistsResponse = ArtistsData & ErrorHandling;

const handleArtistsResponse = (response: ArtistsResponse) => {
  if (response.error) {
    console.error(response.error.message);
    return;
  }

  console.log(response.artists);
};

介绍

传统的 JavaScript 程序使用函数和基于原型的继承来创建可重用的组件,但对于熟悉使用面向对象方式的程序员来讲就有些棘手,因为他们用的是基于类的继承并且对象是由类构建出来的。 从 ECMAScript 2015,也就是 ECMAScript 6 开始,JavaScript 程序员将能够使用基于类的面向对象的方式。 使用 TypeScript,我们允许开发者现在就使用这些特性,并且编译后的 JavaScript 可以在所有主流浏览器和平台上运行,而不需要等到下个 JavaScript 版本。

下面看一个使用类的例子:

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  greet() {
    return 'Hello, ' + this.greeting;
  }
}

let greeter = new Greeter('world');

如果你使用过 C#或 Java,你会对这种语法非常熟悉。 我们声明一个Greeter类。这个类有 3 个成员:一个叫做greeting的属性,一个构造函数和一个greet方法。

你会注意到,我们在引用任何一个类成员的时候都用了this。 它表示我们访问的是类的成员。

最后一行,我们使用new构造了Greeter类的一个实例。 它会调用之前定义的构造函数,创建一个Greeter类型的新对象,并执行构造函数初始化它。

继承

在 TypeScript 里,我们可以使用常用的面向对象模式。 基于类的程序设计中一种最基本的模式是允许使用继承来扩展现有的类。

看下面的例子:

class Animal {
  move(distanceInMeters: number = 0) {
    console.log(`Animal moved ${distanceInMeters}m.`);
  }
}

class Dog extends Animal {
  bark() {
    console.log('Woof! Woof!');
  }
}

const dog = new Dog();
dog.bark();
dog.move(10);
dog.bark();

这个例子展示了最基本的继承:类从基类中继承了属性和方法。 这里,Dog是一个派生类,它派生自Animal基类,通过extends关键字。 派生类通常被称作子类,基类通常被称作超类

因为Dog继承了Animal的功能,因此我们可以创建一个Dog的实例,它能够bark()move()

下面我们来看个更加复杂的例子。

class Animal {
  name: string;
  constructor(theName: string) {
    this.name = theName;
  }
  move(distanceInMeters: number = 0) {
    console.log(`${this.name} moved ${distanceInMeters}m.`);
  }
}

class Snake extends Animal {
  constructor(name: string) {
    super(name);
  }
  move(distanceInMeters = 5) {
    console.log('Slithering...');
    super.move(distanceInMeters);
  }
}

class Horse extends Animal {
  constructor(name: string) {
    super(name);
  }
  move(distanceInMeters = 45) {
    console.log('Galloping...');
    super.move(distanceInMeters);
  }
}

let sam = new Snake('Sammy the Python');
let tom: Animal = new Horse('Tommy the Palomino');

sam.move();
tom.move(34);

这个例子展示了一些上面没有提到的特性。 这一次,我们使用extends关键字创建了Animal的两个子类:HorseSnake

与前一个例子的不同点是,派生类包含了一个构造函数,它必须调用super(),它会执行基类的构造函数。 而且,在构造函数里访问this的属性之前,我们一定要调用super()。 这个是 TypeScript 强制执行的一条重要规则。

这个例子演示了如何在子类里可以重写父类的方法。 Snake类和Horse类都创建了move方法,它们重写了从Animal继承来的move方法,使得move方法根据不同的类而具有不同的功能。 注意,即使tom被声明为Animal类型,但因为它的值是Horse,调用tom.move(34)时,它会调用Horse里重写的方法:

Slithering...
Sammy the Python moved 5m.
Galloping...
Tommy the Palomino moved 34m.

公共,私有与受保护的修饰符

默认为public

在上面的例子里,我们可以自由的访问程序里定义的成员。 如果你对其它语言中的类比较了解,就会注意到我们在之前的代码里并没有使用public来做修饰;例如,C#要求必须明确地使用public指定成员是可见的。 在 TypeScript 里,成员都默认为public

你也可以明确的将一个成员标记成public。 我们可以用下面的方式来重写上面的Animal类:

class Animal {
  public name: string;
  public constructor(theName: string) {
    this.name = theName;
  }
  public move(distanceInMeters: number) {
    console.log(`${this.name} moved ${distanceInMeters}m.`);
  }
}

理解private

当成员被标记成private时,它就不能在声明它的类的外部访问。比如:

class Animal {
  private name: string;
  constructor(theName: string) {
    this.name = theName;
  }
}

new Animal('Cat').name; // 错误: 'name' 是私有的.

TypeScript 使用的是结构性类型系统。 当我们比较两种不同的类型时,并不在乎它们从何处而来,如果所有成员的类型都是兼容的,我们就认为它们的类型是兼容的。

然而,当我们比较带有privateprotected成员的类型的时候,情况就不同了。 如果其中一个类型里包含一个private成员,那么只有当另外一个类型中也存在这样一个private成员, 并且它们都是来自同一处声明时,我们才认为这两个类型是兼容的。 对于protected成员也使用这个规则。

下面来看一个例子,更好地说明了这一点:

class Animal {
  private name: string;
  constructor(theName: string) {
    this.name = theName;
  }
}

class Rhino extends Animal {
  constructor() {
    super('Rhino');
  }
}

class Employee {
  private name: string;
  constructor(theName: string) {
    this.name = theName;
  }
}

let animal = new Animal('Goat');
let rhino = new Rhino();
let employee = new Employee('Bob');

animal = rhino;
animal = employee; // 错误: Animal 与 Employee 不兼容.

这个例子中有AnimalRhino两个类,RhinoAnimal类的子类。 还有一个Employee类,其类型看上去与Animal是相同的。 我们创建了几个这些类的实例,并相互赋值来看看会发生什么。 因为AnimalRhino共享了来自Animal里的私有成员定义private name: string,因此它们是兼容的。 然而Employee却不是这样。当把Employee赋值给Animal的时候,得到一个错误,说它们的类型不兼容。 尽管Employee里也有一个私有成员name,但它明显不是Animal里面定义的那个。

理解protected

protected修饰符与private修饰符的行为很相似,但有一点不同,protected成员在派生类中仍然可以访问。例如:

class Person {
  protected name: string;
  constructor(name: string) {
    this.name = name;
  }
}

class Employee extends Person {
  private department: string;

  constructor(name: string, department: string) {
    super(name);
    this.department = department;
  }

  public getElevatorPitch() {
    return `Hello, my name is ${this.name} and I work in ${this.department}.`;
  }
}

let howard = new Employee('Howard', 'Sales');
console.log(howard.getElevatorPitch());
console.log(howard.name); // 错误

注意,我们不能在Person类外使用name,但是我们仍然可以通过Employee类的实例方法访问,因为Employee是由Person派生而来的。

构造函数也可以被标记成protected。 这意味着这个类不能在包含它的类外被实例化,但是能被继承。比如,

class Person {
  protected name: string;
  protected constructor(theName: string) {
    this.name = theName;
  }
}

// Employee 能够继承 Person
class Employee extends Person {
  private department: string;

  constructor(name: string, department: string) {
    super(name);
    this.department = department;
  }

  public getElevatorPitch() {
    return `Hello, my name is ${this.name} and I work in ${this.department}.`;
  }
}

let howard = new Employee('Howard', 'Sales');
let john = new Person('John'); // 错误: 'Person' 的构造函数是被保护的.

readonly 修饰符

你可以使用readonly关键字将属性设置为只读的。 只读属性必须在声明时或构造函数里被初始化。

class Octopus {
  readonly name: string;
  readonly numberOfLegs: number = 8;
  constructor(theName: string) {
    this.name = theName;
  }
}
let dad = new Octopus('Man with the 8 strong legs');
dad.name = 'Man with the 3-piece suit'; // 错误! name 是只读的.

参数属性

在上面的例子中,我们不得不在在Person类里定义一个只读成员name和一个构造函数参数theName。这样做是为了在Octopus构造函数被执行后,就可以访问theName的值。 这种情况经常会遇到。参数属性可以方便地让我们在一个地方定义并初始化一个成员。 下面的例子是对之前Animal类的修改版,使用了参数属性:

class Animal {
  constructor(private name: string) {}
  move(distanceInMeters: number) {
    console.log(`${this.name} moved ${distanceInMeters}m.`);
  }
}

注意看我们是如何舍弃了theName,仅在构造函数里使用private name: string参数来创建和初始化name成员。 我们把声明和赋值合并至一处。

参数属性通过给构造函数参数添加一个访问限定符来声明。 使用private限定一个参数属性会声明并初始化一个私有成员;对于publicprotected来说也是一样。

存取器

TypeScript 支持通过 getters/setters 来截取对对象成员的访问。 它能帮助你有效的控制对对象成员的访问。

下面来看如何把一个简单的类改写成使用getset。 首先,我们从一个没有使用存取器的例子开始。

class Employee {
  fullName: string;
}

let employee = new Employee();
employee.fullName = 'Bob Smith';
if (employee.fullName) {
  console.log(employee.fullName);
}

允许随意设置fullName虽然方便,但是我们仍想在设置fullName强制执行某些约束。

在这个版本里,我们添加一个setter来检查newName的长度,以确保它满足数据库字段的最大长度限制。若它不满足,那么我们就抛一个错误来告诉客户端出错了。

为保留原有的功能,我们同时添加一个getter用来读取fullName

const fullNameMaxLength = 10;

class Employee {
  private _fullName: string;

  get fullName(): string {
    return this._fullName;
  }

  set fullName(newName: string) {
    if (newName && newName.length > fullNameMaxLength) {
      throw new Error('fullName has a max length of ' + fullNameMaxLength);
    }

    this._fullName = newName;
  }
}

let employee = new Employee();
employee.fullName = 'Bob Smith';
if (employee.fullName) {
  alert(employee.fullName);
}

为证明我们写的存取器现在能检查长度,我们可以给名字赋一个长度大于10字符的值,并验证是否得到一个错误。

对于存取器有下面几点需要注意的:

首先,存取器要求你将编译器设置为输出 ECMAScript 5 或更高。 不支持降级到 ECMAScript 3。 其次,只带有get不带有set的存取器自动被推断为readonly。 这在从代码生成.d.ts文件时是有帮助的,因为利用这个属性的用户会看到不允许够改变它的值。

静态属性

到目前为止,我们只讨论了类的实例成员,那些仅当类被实例化的时候才会被初始化的属性。 我们也可以创建类的静态成员,这些属性存在于类本身上面而不是类的实例上。 在这个例子里,我们使用static定义origin,因为它是所有网格都会用到的属性。 每个实例想要访问这个属性的时候,都要在origin前面加上类名。 如同在实例属性上使用this.前缀来访问属性一样,这里我们使用Grid.来访问静态属性。

class Grid {
  static origin = { x: 0, y: 0 };
  calculateDistanceFromOrigin(point: { x: number; y: number }) {
    let xDist = point.x - Grid.origin.x;
    let yDist = point.y - Grid.origin.y;
    return Math.sqrt(xDist * xDist + yDist * yDist) / this.scale;
  }
  constructor(public scale: number) {}
}

let grid1 = new Grid(1.0); // 1x scale
let grid2 = new Grid(5.0); // 5x scale

console.log(grid1.calculateDistanceFromOrigin({ x: 10, y: 10 }));
console.log(grid2.calculateDistanceFromOrigin({ x: 10, y: 10 }));

抽象类

抽象类做为其它派生类的基类使用。 它们一般不会直接被实例化。 不同于接口,抽象类可以包含成员的实现细节(抽象类中除抽象函数之外,其他函数可以包含具体实现)。 abstract关键字是用于定义抽象类和在抽象类内部定义抽象方法。

abstract class Animal {
  abstract makeSound(): void;
  move(): void {
    console.log('roaming the earth...');
  }
}

抽象类中的抽象方法不包含具体实现并且必须在派生类中实现。 抽象方法的语法与接口方法相似。 两者都是定义方法签名但不包含方法体。 然而,抽象方法必须包含abstract关键字并且可以包含访问修饰符。

abstract class Department {
  constructor(public name: string) {}

  printName(): void {
    console.log('Department name: ' + this.name);
  }

  abstract printMeeting(): void; // 必须在派生类中实现
}

class AccountingDepartment extends Department {
  constructor() {
    super('Accounting and Auditing'); // 在派生类的构造函数中必须调用 super()
  }

  printMeeting(): void {
    console.log('The Accounting Department meets each Monday at 10am.');
  }

  generateReports(): void {
    console.log('Generating accounting reports...');
  }
}

let department: Department; // 允许创建一个对抽象类型的引用
department = new Department(); // 错误: 不能创建一个抽象类的实例
department = new AccountingDepartment(); // 允许对一个抽象子类进行实例化和赋值
department.printName();
department.printMeeting();
department.generateReports(); // 错误: 方法在声明的抽象类中不存在

高级技巧

构造函数

当你在 TypeScript 里声明了一个类的时候,实际上同时声明了很多东西。 首先就是类的实例的类型。

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  greet() {
    return 'Hello, ' + this.greeting;
  }
}

let greeter: Greeter;
greeter = new Greeter('world');
console.log(greeter.greet());

这里,我们写了let greeter: Greeter,意思是Greeter类的实例的类型是Greeter。 这对于用过其它面向对象语言的程序员来讲已经是老习惯了。

我们也创建了一个叫做构造函数的值。 这个函数会在我们使用new创建类实例的时候被调用。 下面我们来看看,上面的代码被编译成 JavaScript 后是什么样子的:

let Greeter = (function () {
  function Greeter(message) {
    this.greeting = message;
  }
  Greeter.prototype.greet = function () {
    return 'Hello, ' + this.greeting;
  };
  return Greeter;
})();

let greeter;
greeter = new Greeter('world');
console.log(greeter.greet());

上面的代码里,let Greeter将被赋值为构造函数。 当我们调用new并执行了这个函数后,便会得到一个类的实例。 这个构造函数也包含了类的所有静态属性。 换个角度说,我们可以认为类具有实例部分静态部分这两个部分。

让我们稍微改写一下这个例子,看看它们之间的区别:

class Greeter {
  static standardGreeting = 'Hello, there';
  greeting: string;
  greet() {
    if (this.greeting) {
      return 'Hello, ' + this.greeting;
    } else {
      return Greeter.standardGreeting;
    }
  }
}

let greeter1: Greeter;
greeter1 = new Greeter();
console.log(greeter1.greet());

let greeterMaker: typeof Greeter = Greeter;
greeterMaker.standardGreeting = 'Hey there!';

let greeter2: Greeter = new greeterMaker();
console.log(greeter2.greet());

这个例子里,greeter1与之前看到的一样。 我们实例化Greeter类,并使用这个对象。 与我们之前看到的一样。

再之后,我们直接使用类。 我们创建了一个叫做greeterMaker的变量。 这个变量保存了这个类或者说保存了类构造函数。 然后我们使用typeof Greeter,意思是取 Greeter 类的类型,而不是实例的类型。 或者更确切的说,"告诉我Greeter标识符的类型",也就是构造函数的类型。 这个类型包含了类的所有静态成员和构造函数。 之后,就和前面一样,我们在greeterMaker上使用new,创建Greeter的实例。

把类当做接口使用

如上一节里所讲的,类定义会创建两个东西:类的实例类型和一个构造函数。 因为类可以创建出类型,所以你能够在允许使用接口的地方使用类。

class Point {
  x: number;
  y: number;
}

interface Point3d extends Point {
  z: number;
}

let point3d: Point3d = { x: 1, y: 2, z: 3 };

枚举

枚举

使用枚举我们可以定义一些带名字的常量。 使用枚举可以清晰地表达意图或创建一组有区别的用例。 TypeScript 支持数字的和基于字符串的枚举。

数字枚举

首先我们看看数字枚举,如果你使用过其它编程语言应该会很熟悉。

enum Direction {
  Up = 1,
  Down,
  Left,
  Right,
}

如上,我们定义了一个数字枚举,Up使用初始化为1。 其余的成员会从1开始自动增长。 换句话说,Direction.Up的值为1Down2Left3Right4

我们还可以完全不使用初始化器:

enum Direction {
  Up,
  Down,
  Left,
  Right,
}

现在,Up的值为0Down的值为1等等。 当我们不在乎成员的值的时候,这种自增长的行为是很有用处的,但是要注意每个枚举成员的值都是不同的。

使用枚举很简单:通过枚举的属性来访问枚举成员,和枚举的名字来访问枚举类型:

enum Response {
  No = 0,
  Yes = 1,
}

function respond(recipient: string, message: Response): void {
  // ...
}

respond('Princess Caroline', Response.Yes);

数字枚举可以被混入到计算过的和常量成员(如下所示)。 简短地说,没有初始化器的成员要么在首位,要么必须在用数值常量或其他常量枚举成员初始化的数值枚举之后。 换句话说,下面的情况是不被允许的:

enum E {
  A = getSomeValue(),
  B, // Error! Enum member must have initializer.
}

字符串枚举

字符串枚举的概念很简单,但是有细微的运行时的差别。 在一个字符串枚举里,每个成员都必须用字符串字面量,或另外一个字符串枚举成员进行初始化。

enum Direction {
  Up = 'UP',
  Down = 'DOWN',
  Left = 'LEFT',
  Right = 'RIGHT',
}

由于字符串枚举没有自增长的行为,字符串枚举可以很好的序列化。 换句话说,如果你正在调试并且必须要读一个数字枚举的运行时的值,这个值通常是很难读的 - 它并不能表达有用的信息(尽管反向映射会有所帮助),字符串枚举允许你提供一个运行时有意义的并且可读的值,独立于枚举成员的名字。

异构枚举(Heterogeneous enums)

从技术的角度来说,枚举可以混合字符串和数字成员,但是似乎你并不会这么做:

enum BooleanLikeHeterogeneousEnum {
  No = 0,
  Yes = 'YES',
}

除非你真的想要利用 JavaScript 运行时的行为,否则我们不建议这样做。

计算的和常量成员

每个枚举成员都带有一个值,它可以是常量计算出来的。 当满足如下条件时,枚举成员被当作是常量:

  • 它是枚举的第一个成员且没有初始化器,这种情况下它被赋予值0

    // E.X is constant:
    enum E {
      X,
    }
    
  • 它不带有初始化器且它之前的枚举成员是一个数字常量。 这种情况下,当前枚举成员的值为它上一个枚举成员的值加 1。

    // All enum members in 'E1' and 'E2' are constant.
    
    enum E1 {
      X,
      Y,
      Z,
    }
    
    enum E2 {
      A = 1,
      B,
      C,
    }
    
  • 枚举成员使用常量枚举表达式初始化。 常量枚举表达式是 TypeScript 表达式的子集,它可以在编译阶段求值。 当一个表达式满足下面条件之一时,它就是一个常量枚举表达式:

    1. 一个枚举表达式字面量(主要是字符串字面量或数字字面量)
    2. 一个对之前定义的常量枚举成员的引用(可以是在不同的枚举类型中定义的)
    3. 带括号的常量枚举表达式
    4. 一元运算符+, -, ~其中之一应用在了常量枚举表达式
    5. 常量枚举表达式做为二元运算符+, -, *, /, %, <<, >>, >>>, &, |, ^的操作对象。

    若常量枚举表达式求值后为NaNInfinity,则会在编译阶段报错。

所有其它情况的枚举成员被当作是需要计算得出的值。

enum FileAccess {
  // constant members
  None,
  Read = 1 << 1,
  Write = 1 << 2,
  ReadWrite = Read | Write,
  // computed member
  G = '123'.length,
}

联合枚举与枚举成员的类型

存在一种特殊的非计算的常量枚举成员的子集:字面量枚举成员。 字面量枚举成员是指不带有初始值的常量枚举成员,或者是值被初始化为

  • 任何字符串字面量(例如:"foo""bar""baz"
  • 任何数字字面量(例如:1, 100
  • 应用了一元-符号的数字字面量(例如:-1, -100

当所有枚举成员都拥有字面量枚举值时,它就带有了一种特殊的语义。

首先,枚举成员成为了类型! 例如,我们可以说某些成员只能是枚举成员的值:

enum ShapeKind {
  Circle,
  Square,
}

interface Circle {
  kind: ShapeKind.Circle;
  radius: number;
}

interface Square {
  kind: ShapeKind.Square;
  sideLength: number;
}

let c: Circle = {
  kind: ShapeKind.Square, // Error! Type 'ShapeKind.Square' is not assignable to type 'ShapeKind.Circle'.
  radius: 100,
};

另一个变化是枚举类型本身变成了每个枚举成员的联合。 虽然我们还没有讨论联合类型,但你只要知道通过联合枚举,类型系统能够利用这样一个事实,它可以知道枚举里的值的集合。 因此,TypeScript 能够捕获在比较值的时候犯的愚蠢的错误。 例如:

enum E {
  Foo,
  Bar,
}

function f(x: E) {
  if (x !== E.Foo || x !== E.Bar) {
    //             ~~~~~~~~~~~
    // Error! This condition will always return 'true' since the types 'E.Foo' and 'E.Bar' have no overlap.
  }
}

这个例子里,我们先检查x是否不是E.Foo。 如果通过了这个检查,然后||会发生短路效果,if语句体里的内容会被执行。 然而,这个检查没有通过,那么x只能E.Foo,因此没理由再去检查它是否为E.Bar

运行时的枚举

枚举是在运行时真正存在的对象。 例如下面的枚举:

enum E {
  X,
  Y,
  Z,
}

可以传递给函数

function f(obj: { X: number }) {
  return obj.X;
}

// 没问题,因为 'E'包含一个数值型属性'X'。
f(E);

编译时的枚举

尽管一个枚举是在运行时真正存在的对象,但keyof关键字的行为与其作用在对象上时有所不同。应该使用keyof typeof来获取一个表示枚举里所有字符串key的类型。

enum LogLevel {
  ERROR,
  WARN,
  INFO,
  DEBUG,
}

/**
 * 等同于:
 * type LogLevelStrings = 'ERROR' | 'WARN' | 'INFO' | 'DEBUG';
 */
type LogLevelStrings = keyof typeof LogLevel;

function printImportant(key: LogLevelStrings, message: string) {
  const num = LogLevel[key];
  if (num <= LogLevel.WARN) {
    console.log('Log level key is: ', key);
    console.log('Log level value is: ', num);
    console.log('Log level message is: ', message);
  }
}
printImportant('ERROR', 'This is a message');

反向映射

除了创建一个以属性名做为对象成员的对象之外,数字枚举成员还具有了反向映射,从枚举值到枚举名字。 例如,在下面的例子中:

enum Enum {
  A,
}
let a = Enum.A;
let nameOfA = Enum[a]; // "A"

TypeScript 可能会将这段代码编译为下面的 JavaScript:

var Enum;
(function (Enum) {
  Enum[(Enum['A'] = 0)] = 'A';
})(Enum || (Enum = {}));
var a = Enum.A;
var nameOfA = Enum[a]; // "A"

生成的代码中,枚举类型被编译成一个对象,它包含了正向映射(name -> value)和反向映射(value -> name)。 引用枚举成员总会生成为对属性访问并且永远也不会内联代码。

要注意的是不会为字符串枚举成员生成反向映射。

const枚举

大多数情况下,枚举是十分有效的方案。 然而在某些情况下需求很严格。 为了避免在额外生成的代码上的开销和额外的非直接的对枚举成员的访问,我们可以使用const枚举。 常量枚举通过在枚举上使用const修饰符来定义。

const enum Enum {
  A = 1,
  B = A * 2,
}

常量枚举只能使用常量枚举表达式,并且不同于常规的枚举,它们在编译阶段会被删除。 常量枚举成员在使用的地方会被内联进来。 之所以可以这么做是因为,常量枚举不允许包含计算成员。

const enum Directions {
  Up,
  Down,
  Left,
  Right,
}

let directions = [
  Directions.Up,
  Directions.Down,
  Directions.Left,
  Directions.Right,
];

生成后的代码为:

var directions = [0 /* Up */, 1 /* Down */, 2 /* Left */, 3 /* Right */];

外部枚举

外部枚举用来描述已经存在的枚举类型的形状。

declare enum Enum {
  A = 1,
  B,
  C = 2,
}

外部枚举和非外部枚举之间有一个重要的区别,在正常的枚举里,没有初始化方法的成员被当成常量成员。 对于非常量的外部枚举而言,没有初始化方法时被当做需要经过计算的。

泛型

介绍

软件工程中,我们不仅要创建一致的定义良好的 API,同时也要考虑可重用性。 组件不仅能够支持当前的数据类型,同时也能支持未来的数据类型,这在创建大型系统时为你提供了十分灵活的功能。

在像 C#和 Java 这样的语言中,可以使用泛型来创建可重用的组件,一个组件可以支持多种类型的数据。 这样用户就可以以自己的数据类型来使用组件。

泛型之 Hello World

下面来创建第一个使用泛型的例子:identity 函数。 这个函数会返回任何传入它的值。 你可以把这个函数当成是echo命令。

不用泛型的话,这个函数可能是下面这样:

function identity(arg: number): number {
  return arg;
}

或者,我们使用any类型来定义函数:

function identity(arg: any): any {
  return arg;
}

使用any类型会导致这个函数可以接收任何类型的arg参数,这样就丢失了一些信息:传入的类型与返回的类型应该是相同的。 如果我们传入一个数字,我们只知道任何类型的值都有可能被返回。

因此,我们需要一种方法使返回值的类型与传入参数的类型是相同的。 这里,我们使用了类型变量,它是一种特殊的变量,只用于表示类型而不是值。

function identity<T>(arg: T): T {
  return arg;
}

我们给 identity 添加了类型变量TT帮助我们捕获用户传入的类型(比如:number),之后我们就可以使用这个类型。 之后我们再次使用了T当做返回值类型。现在我们可以知道参数类型与返回值类型是相同的了。 这允许我们跟踪函数里使用的类型的信息。

我们把这个版本的identity函数叫做泛型,因为它可以适用于多个类型。 不同于使用any,它不会丢失信息,像第一个例子那像保持准确性,传入数值类型并返回数值类型。

我们定义了泛型函数后,可以用两种方法使用。 第一种是,传入所有的参数,包含类型参数:

let output = identity<string>('myString'); // type of output will be 'string'

这里我们明确的指定了Tstring类型,并做为一个参数传给函数,使用了<>括起来而不是()

第二种方法更普遍。利用了类型推论 -- 即编译器会根据传入的参数自动地帮助我们确定 T 的类型:

let output = identity('myString'); // type of output will be 'string'

注意我们没必要使用尖括号(<>)来明确地传入类型;编译器可以查看myString的值,然后把T设置为它的类型。 类型推论帮助我们保持代码精简和高可读性。如果编译器不能够自动地推断出类型的话,只能像上面那样明确的传入 T 的类型,在一些复杂的情况下,这是可能出现的。

使用泛型变量

使用泛型创建像identity这样的泛型函数时,编译器要求你在函数体必须正确的使用这个通用的类型。 换句话说,你必须把这些参数当做是任意或所有类型。

看下之前identity例子:

function identity<T>(arg: T): T {
  return arg;
}

如果我们想同时打印出arg的长度。 我们很可能会这样做:

function loggingIdentity<T>(arg: T): T {
  console.log(arg.length); // Error: T doesn't have .length
  return arg;
}

如果这么做,编译器会报错说我们使用了arg.length属性,但是没有地方指明arg具有这个属性。 记住,这些类型变量代表的是任意类型,所以使用这个函数的人可能传入的是个数字,而数字是没有.length属性的。

现在假设我们想操作T类型的数组而不直接是T。由于我们操作的是数组,所以.length属性是应该存在的。 我们可以像创建其它数组一样创建这个数组:

function loggingIdentity<T>(arg: T[]): T[] {
  console.log(arg.length); // Array has a .length, so no more error
  return arg;
}

你可以这样理解loggingIdentity的类型:泛型函数loggingIdentity,接收类型参数T和参数arg,它是个元素类型是T的数组,并返回元素类型是T的数组。 如果我们传入数字数组,将返回一个数字数组,因为此时T的类型为number。 这可以让我们把泛型变量 T 当做类型的一部分使用,而不是整个类型,增加了灵活性。

我们也可以这样实现上面的例子:

function loggingIdentity<T>(arg: Array<T>): Array<T> {
  console.log(arg.length); // Array has a .length, so no more error
  return arg;
}

使用过其它语言的话,你可能对这种语法已经很熟悉了。 在下一节,会介绍如何创建自定义泛型像Array<T>一样。

泛型类型

上一节,我们创建了 identity 通用函数,可以适用于不同的类型。 在这节,我们研究一下函数本身的类型,以及如何创建泛型接口。

泛型函数的类型与非泛型函数的类型没什么不同,只是有一个类型参数在最前面,像函数声明一样:

function identity<T>(arg: T): T {
  return arg;
}

let myIdentity: <T>(arg: T) => T = identity;

我们也可以使用不同的泛型参数名,只要在数量上和使用方式上能对应上就可以。

function identity<T>(arg: T): T {
  return arg;
}

let myIdentity: <U>(arg: U) => U = identity;

我们还可以使用带有调用签名的对象字面量来定义泛型函数:

function identity<T>(arg: T): T {
  return arg;
}

let myIdentity: { <T>(arg: T): T } = identity;

这引导我们去写第一个泛型接口了。 我们把上面例子里的对象字面量拿出来做为一个接口:

interface GenericIdentityFn {
  <T>(arg: T): T;
}

function identity<T>(arg: T): T {
  return arg;
}

let myIdentity: GenericIdentityFn = identity;

一个相似的例子,我们可能想把泛型参数当作整个接口的一个参数。 这样我们就能清楚的知道使用的具体是哪个泛型类型(比如:Dictionary<string>而不只是Dictionary)。 这样接口里的其它成员也能知道这个参数的类型了。

interface GenericIdentityFn<T> {
  (arg: T): T;
}

function identity<T>(arg: T): T {
  return arg;
}

let myIdentity: GenericIdentityFn<number> = identity;

注意,我们的示例做了少许改动。 不再描述泛型函数,而是把非泛型函数签名作为泛型类型一部分。 当我们使用GenericIdentityFn的时候,还得传入一个类型参数来指定泛型类型(这里是:number),锁定了之后代码里使用的类型。 对于描述哪部分类型属于泛型部分来说,理解何时把参数放在调用签名里和何时放在接口上是很有帮助的。

除了泛型接口,我们还可以创建泛型类。 注意,无法创建泛型枚举和泛型命名空间。

泛型类

泛型类看上去与泛型接口差不多。 泛型类使用(<>)括起泛型类型,跟在类名后面。

class GenericNumber<T> {
  zeroValue: T;
  add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
  return x + y;
};

GenericNumber类的使用是十分直观的,并且你可能已经注意到了,没有什么去限制它只能使用number类型。 也可以使用字符串或其它更复杂的类型。

let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = '';
stringNumeric.add = function (x, y) {
  return x + y;
};

console.log(stringNumeric.add(stringNumeric.zeroValue, 'test'));

与接口一样,直接把泛型类型放在类后面,可以帮助我们确认类的所有属性都在使用相同的类型。

我们在那节说过,类有两部分:静态部分和实例部分。 泛型类指的是实例部分的类型,所以类的静态属性不能使用这个泛型类型。

泛型约束

你应该会记得之前的一个例子,我们有时候想操作某类型的一组值,并且我们知道这组值具有什么样的属性。 在loggingIdentity例子中,我们想访问arglength属性,但是编译器并不能证明每种类型都有length属性,所以就报错了。

function loggingIdentity<T>(arg: T): T {
  console.log(arg.length); // Error: T doesn't have .length
  return arg;
}

相比于操作 any 所有类型,我们想要限制函数去处理任意带有.length属性的所有类型。 只要传入的类型有这个属性,我们就允许,就是说至少包含这一属性。 为此,我们需要列出对于 T 的约束要求。

为此,我们定义一个接口来描述约束条件。 创建一个包含.length属性的接口,使用这个接口和extends关键字来实现约束:

interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length); // Now we know it has a .length property, so no more error
  return arg;
}

现在这个泛型函数被定义了约束,因此它不再是适用于任意类型:

loggingIdentity(3); // Error, number doesn't have a .length property

我们需要传入符合约束类型的值,必须包含必须的属性:

loggingIdentity({ length: 10, value: 3 });

在泛型约束中使用类型参数

你可以声明一个类型参数,且它被另一个类型参数所约束。 比如,现在我们想要用属性名从对象里获取这个属性。 并且我们想要确保这个属性存在于对象obj上,因此我们需要在这两个类型之间使用约束。

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, 'a'); // okay
getProperty(x, 'm'); // error: Argument of type 'm' isn't assignable to 'a' | 'b' | 'c' | 'd'.

在泛型里使用类类型

在 TypeScript 使用泛型创建工厂函数时,需要引用构造函数的类类型。比如,

function create<T>(c: { new (): T }): T {
  return new c();
}

一个更高级的例子,使用原型属性推断并约束构造函数与类实例的关系。

class BeeKeeper {
  hasMask: boolean;
}

class ZooKeeper {
  nametag: string;
}

class Animal {
  numLegs: number;
}

class Bee extends Animal {
  keeper: BeeKeeper;
}

class Lion extends Animal {
  keeper: ZooKeeper;
}

function createInstance<A extends Animal>(c: new () => A): A {
  return new c();
}

createInstance(Lion).keeper.nametag; // typechecks!
createInstance(Bee).keeper.hasMask; // typechecks!

手册(进阶)

高级类型

交叉类型(Intersection Types)

交叉类型是将多个类型合并为一个类型。 这让我们可以把现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。 例如,Person & Serializable & Loggable同时是PersonSerializableLoggable。 就是说这个类型的对象同时拥有了这三种类型的成员。

我们大多是在混入(mixins)或其它不适合典型面向对象模型的地方看到交叉类型的使用。 (在 JavaScript 里发生这种情况的场合很多!) 下面是如何创建混入的一个简单例子("target": "es5"):

function extend<First, Second>(first: First, second: Second): First & Second {
  const result: Partial<First & Second> = {};
  for (const prop in first) {
    if (first.hasOwnProperty(prop)) {
      (result as First)[prop] = first[prop];
    }
  }
  for (const prop in second) {
    if (second.hasOwnProperty(prop)) {
      (result as Second)[prop] = second[prop];
    }
  }
  return result as First & Second;
}

class Person {
  constructor(public name: string) {}
}

interface Loggable {
  log(name: string): void;
}

class ConsoleLogger implements Loggable {
  log(name) {
    console.log(`Hello, I'm ${name}.`);
  }
}

const jim = extend(new Person('Jim'), ConsoleLogger.prototype);
jim.log(jim.name);

联合类型(Union Types)

联合类型与交叉类型很有关联,但是使用上却完全不同。 偶尔你会遇到这种情况,一个代码库希望传入numberstring类型的参数。 例如下面的函数:

/**
 * Takes a string and adds "padding" to the left.
 * If 'padding' is a string, then 'padding' is appended to the left side.
 * If 'padding' is a number, then that number of spaces is added to the left side.
 */
function padLeft(value: string, padding: any) {
  if (typeof padding === 'number') {
    return Array(padding + 1).join(' ') + value;
  }
  if (typeof padding === 'string') {
    return padding + value;
  }
  throw new Error(`Expected string or number, got '${padding}'.`);
}

padLeft('Hello world', 4); // returns "    Hello world"

padLeft存在一个问题,padding参数的类型指定成了any。 这就是说我们可以传入一个既不是number也不是string类型的参数,但是 TypeScript 却不报错。

let indentedString = padLeft('Hello world', true); // 编译阶段通过,运行时报错

在传统的面向对象语言里,我们可能会将这两种类型抽象成有层级的类型。 这么做显然是非常清晰的,但同时也存在了过度设计。 padLeft原始版本的好处之一是允许我们传入原始类型。 这样做的话使用起来既简单又方便。 如果我们就是想使用已经存在的函数的话,这种新的方式就不适用了。

代替any, 我们可以使用联合类型做为padding的参数:

/**
 * Takes a string and adds "padding" to the left.
 * If 'padding' is a string, then 'padding' is appended to the left side.
 * If 'padding' is a number, then that number of spaces is added to the left side.
 */
function padLeft(value: string, padding: string | number) {
  // ...
}

let indentedString = padLeft('Hello world', true); // errors during compilation

联合类型表示一个值可以是几种类型之一。 我们用竖线(|)分隔每个类型,所以number | string | boolean表示一个值可以是numberstring,或boolean

如果一个值是联合类型,我们只能访问此联合类型的所有类型里共有的成员。

interface Bird {
  fly();
  layEggs();
}

interface Fish {
  swim();
  layEggs();
}

function getSmallPet(): Fish | Bird {
  // ...
}

let pet = getSmallPet();
pet.layEggs(); // okay
pet.swim(); // errors

这里的联合类型可能有点复杂,但是你很容易就习惯了。 如果一个值的类型是A | B,我们能够确定的是它包含了AB中共有的成员。 这个例子里,Bird具有一个fly成员。 我们不能确定一个Bird | Fish类型的变量是否有fly方法。 如果变量在运行时是Fish类型,那么调用pet.fly()就出错了。

类型守卫与类型区分(Type Guards and Differentiating Types)

联合类型适合于那些值可以为不同类型的情况。 但当我们想确切地了解是否为Fish时怎么办? JavaScript 里常用来区分 2 个可能值的方法是检查成员是否存在。 如之前提及的,我们只能访问联合类型中共同拥有的成员。

let pet = getSmallPet();

// 每一个成员访问都会报错
if (pet.swim) {
  pet.swim();
} else if (pet.fly) {
  pet.fly();
}

为了让这段代码工作,我们要使用类型断言:

let pet = getSmallPet();

if ((pet as Fish).swim) {
  (pet as Fish).swim();
} else if ((pet as Bird).fly) {
  (pet as Bird).fly();
}

用户自定义的类型守卫

这里可以注意到我们不得不多次使用类型断言。 假若我们一旦检查过类型,就能在之后的每个分支里清楚地知道pet的类型的话就好了。

TypeScript 里的类型守卫机制让它成为了现实。 类型守卫就是一些表达式,它们会在运行时检查以确保在某个作用域里的类型。

使用类型判定

要定义一个类型守卫,我们只要简单地定义一个函数,它的返回值是一个类型谓词

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

在这个例子里,pet is Fish就是类型谓词。 谓词为parameterName is Type这种形式,parameterName必须是来自于当前函数签名里的一个参数名。

每当使用一些变量调用isFish时,TypeScript 会将变量缩减为那个具体的类型,只要这个类型与变量的原始类型是兼容的。

// 'swim' 和 'fly' 调用都没有问题了

if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

注意 TypeScript 不仅知道在if分支里petFish类型; 它还清楚在else分支里,一定不是Fish类型,一定是Bird类型。

使用in操作符

in操作符可以作为类型细化表达式来使用。

对于n in x表达式,其中n是字符串字面量或字符串字面量类型且x是个联合类型,那么true分支的类型细化为有一个可选的或必须的属性nfalse分支的类型细化为有一个可选的或不存在属性n

function move(pet: Fish | Bird) {
  if ('swim' in pet) {
    return pet.swim();
  }
  return pet.fly();
}

typeof类型守卫

现在我们回过头来看看怎么使用联合类型书写padLeft代码。 我们可以像下面这样利用类型断言来写:

function isNumber(x: any): x is number {
  return typeof x === 'number';
}

function isString(x: any): x is string {
  return typeof x === 'string';
}

function padLeft(value: string, padding: string | number) {
  if (isNumber(padding)) {
    return Array(padding + 1).join(' ') + value;
  }
  if (isString(padding)) {
    return padding + value;
  }
  throw new Error(`Expected string or number, got '${padding}'.`);
}

然而,必须要定义一个函数来判断类型是否是原始类型,这太痛苦了。 幸运的是,现在我们不必将typeof x === "number"抽象成一个函数,因为 TypeScript 可以将它识别为一个类型守卫。 也就是说我们可以直接在代码里检查类型了。

function padLeft(value: string, padding: string | number) {
  if (typeof padding === 'number') {
    return Array(padding + 1).join(' ') + value;
  }
  if (typeof padding === 'string') {
    return padding + value;
  }
  throw new Error(`Expected string or number, got '${padding}'.`);
}

这些*typeof类型守卫*只有两种形式能被识别:typeof v === "typename"typeof v !== "typename""typename"必须是"number""string""boolean""symbol"。 但是 TypeScript 并不会阻止你与其它字符串比较,语言不会把那些表达式识别为类型守卫。

instanceof类型守卫

如果你已经阅读了typeof类型守卫并且对 JavaScript 里的instanceof操作符熟悉的话,你可能已经猜到了这节要讲的内容。

instanceof类型守卫是通过构造函数来细化类型的一种方式。 比如,我们借鉴一下之前字符串填充的例子:

interface Padder {
  getPaddingString(): string;
}

class SpaceRepeatingPadder implements Padder {
  constructor(private numSpaces: number) {}
  getPaddingString() {
    return Array(this.numSpaces + 1).join(' ');
  }
}

class StringPadder implements Padder {
  constructor(private value: string) {}
  getPaddingString() {
    return this.value;
  }
}

function getRandomPadder() {
  return Math.random() < 0.5
    ? new SpaceRepeatingPadder(4)
    : new StringPadder('  ');
}

// 类型为SpaceRepeatingPadder | StringPadder
let padder: Padder = getRandomPadder();

if (padder instanceof SpaceRepeatingPadder) {
  padder; // 类型细化为'SpaceRepeatingPadder'
}
if (padder instanceof StringPadder) {
  padder; // 类型细化为'StringPadder'
}

instanceof的右侧要求是一个构造函数,TypeScript 将细化为:

  1. 此构造函数的prototype属性的类型,如果它的类型不为any的话
  2. 构造签名所返回的类型的联合

以此顺序。

可以为null的类型

TypeScript 具有两种特殊的类型,nullundefined,它们分别具有值nullundefined. 我们在基础类型一节里已经做过简要说明。 默认情况下,类型检查器认为nullundefined可以赋值给任何类型。 nullundefined是所有其它类型的一个有效值。 这也意味着,你阻止不了将它们赋值给其它类型,就算是你想要阻止这种情况也不行。 null的发明者,Tony Hoare,称它为价值亿万美金的错误

--strictNullChecks标记可以解决此错误:当你声明一个变量时,它不会自动地包含nullundefined。 你可以使用联合类型明确的包含它们:

let s = 'foo';
s = null; // 错误, 'null'不能赋值给'string'
let sn: string | null = 'bar';
sn = null; // 可以

sn = undefined; // error, 'undefined'不能赋值给'string | null'

注意,按照 JavaScript 的语义,TypeScript 会把nullundefined区别对待。 string | nullstring | undefinedstring | undefined | null是不同的类型。

可选参数和可选属性

使用了--strictNullChecks,可选参数会被自动地加上| undefined:

function f(x: number, y?: number) {
  return x + (y || 0);
}
f(1, 2);
f(1);
f(1, undefined);
f(1, null); // error, 'null' is not assignable to 'number | undefined'

可选属性也会有同样的处理:

class C {
  a: number;
  b?: number;
}
let c = new C();
c.a = 12;
c.a = undefined; // error, 'undefined' is not assignable to 'number'
c.b = 13;
c.b = undefined; // ok
c.b = null; // error, 'null' is not assignable to 'number | undefined'

类型守卫和类型断言

由于可以为null的类型是通过联合类型实现,那么你需要使用类型守卫来去除null。 幸运地是这与在 JavaScript 里写的代码一致:

function f(sn: string | null): string {
  if (sn == null) {
    return 'default';
  } else {
    return sn;
  }
}

这里很明显地去除了null,你也可以使用短路运算符:

function f(sn: string | null): string {
  return sn || 'default';
}

如果编译器不能够去除nullundefined,你可以使用类型断言手动去除。 语法是添加!后缀:identifier!identifier的类型里去除了nullundefined

function broken(name: string | null): string {
  function postfix(epithet: string) {
    return name.charAt(0) + '.  the ' + epithet; // error, 'name' is possibly null
  }
  name = name || 'Bob';
  return postfix('great');
}

function fixed(name: string | null): string {
  function postfix(epithet: string) {
    return name!.charAt(0) + '.  the ' + epithet; // ok
  }
  name = name || 'Bob';
  return postfix('great');
}

本例使用了嵌套函数,因为编译器无法去除嵌套函数的null(除非是立即调用的函数表达式)。 因为它无法跟踪所有对嵌套函数的调用,尤其是你将内层函数做为外层函数的返回值。 如果无法知道函数在哪里被调用,就无法知道调用时name的类型。

类型别名

类型别名会给一个类型起个新名字。 类型别名有时和接口很像,但是可以作用于原始值,联合类型,元组以及其它任何你需要手写的类型。

type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
  if (typeof n === 'string') {
    return n;
  } else {
    return n();
  }
}

起别名不会新建一个类型 - 它创建了一个新名字来引用那个类型。 给原始类型起别名通常没什么用,尽管可以做为文档的一种形式使用。

同接口一样,类型别名也可以是泛型 - 我们可以添加类型参数并且在别名声明的右侧传入:

type Container<T> = { value: T };

我们也可以使用类型别名来在属性里引用自己:

type Tree<T> = {
  value: T;
  left: Tree<T>;
  right: Tree<T>;
};

与交叉类型一起使用,我们可以创建出一些十分稀奇古怪的类型。

type LinkedList<T> = T & { next: LinkedList<T> };

interface Person {
  name: string;
}

var people: LinkedList<Person>;
var s = people.name;
var s = people.next.name;
var s = people.next.next.name;
var s = people.next.next.next.name;

然而,类型别名不能出现在声明右侧的任何地方。

type Yikes = Array<Yikes>; // error

接口 vs. 类型别名

像我们提到的,类型别名可以像接口一样;然而,仍有一些细微差别。

其一,接口创建了一个新的名字,可以在其它任何地方使用。 类型别名并不创建新名字—比如,错误信息就不会使用别名。 在下面的示例代码里,在编译器中将鼠标悬停在interfaced上,显示它返回的是Interface,但悬停在aliased上时,显示的却是对象字面量类型。

type Alias = { num: number };
interface Interface {
  num: number;
}
declare function aliased(arg: Alias): Alias;
declare function interfaced(arg: Interface): Interface;

在旧版本的 TypeScript 里,类型别名不能被继承和实现(它们也不能继承和实现其它类型)。从 TypeScript 2.7 开始,类型别名可以被继承并生成新的交叉类型。例如:type Cat = Animal & { purrs: true }

因为软件中的对象应该对于扩展是开放的,但是对于修改是封闭的,你应该尽量去使用接口代替类型别名。

另一方面,如果你无法通过接口来描述一个类型并且需要使用联合类型或元组类型,这时通常会使用类型别名。

字符串字面量类型

字符串字面量类型允许你指定字符串必须的固定值。 在实际应用中,字符串字面量类型可以与联合类型,类型守卫和类型别名很好的配合。 通过结合使用这些特性,你可以实现类似枚举类型的字符串。

type Easing = 'ease-in' | 'ease-out' | 'ease-in-out';
class UIElement {
  animate(dx: number, dy: number, easing: Easing) {
    if (easing === 'ease-in') {
      // ...
    } else if (easing === 'ease-out') {
    } else if (easing === 'ease-in-out') {
    } else {
      // error! should not pass null or undefined.
    }
  }
}

let button = new UIElement();
button.animate(0, 0, 'ease-in');
button.animate(0, 0, 'uneasy'); // error: "uneasy" is not allowed here

你只能从三种允许的字符中选择其一来做为参数传递,传入其它值则会产生错误。

Argument of type '"uneasy"' is not assignable to parameter of type '"ease-in" | "ease-out" | "ease-in-out"'

字符串字面量类型还可以用于区分函数重载:

function createElement(tagName: 'img'): HTMLImageElement;
function createElement(tagName: 'input'): HTMLInputElement;
// ... more overloads ...
function createElement(tagName: string): Element {
  // ... code goes here ...
}

数字字面量类型

TypeScript 还具有数字字面量类型。

function rollDice(): 1 | 2 | 3 | 4 | 5 | 6 {
  // ...
}

我们很少直接这样使用,但它们可以用在缩小范围调试 bug 的时候:

function foo(x: number) {
  if (x !== 1 || x !== 2) {
    //         ~~~~~~~
    // Operator '!==' cannot be applied to types '1' and '2'.
  }
}

换句话说,当x2进行比较的时候,它的值必须为1,这就意味着上面的比较检查是非法的。

枚举成员类型

如我们在枚举一节里提到的,当每个枚举成员都是用字面量初始化的时候枚举成员是具有类型的。

在我们谈及“单例类型”的时候,多数是指枚举成员类型和数字/字符串字面量类型,尽管大多数用户会互换使用“单例类型”和“字面量类型”。

可辨识联合(Discriminated Unions)

你可以合并单例类型,联合类型,类型守卫和类型别名来创建一个叫做可辨识联合的高级模式,它也称做标签联合代数数据类型。 可辨识联合在函数式编程里很有用处。 一些语言会自动地为你辨识联合;而 TypeScript 则基于已有的 JavaScript 模式。 它具有 3 个要素:

  1. 具有普通的单例类型属性—可辨识的特征
  2. 一个类型别名包含了那些类型的联合—联合
  3. 此属性上的类型守卫。
interface Square {
  kind: 'square';
  size: number;
}
interface Rectangle {
  kind: 'rectangle';
  width: number;
  height: number;
}
interface Circle {
  kind: 'circle';
  radius: number;
}

首先我们声明了将要联合的接口。 每个接口都有kind属性但有不同的字符串字面量类型。 kind属性称做可辨识的特征标签。 其它的属性则特定于各个接口。 注意,目前各个接口间是没有联系的。 下面我们把它们联合到一起:

type Shape = Square | Rectangle | Circle;

现在我们使用可辨识联合:

function area(s: Shape) {
  switch (s.kind) {
    case 'square':
      return s.size * s.size;
    case 'rectangle':
      return s.height * s.width;
    case 'circle':
      return Math.PI * s.radius ** 2;
  }
}

完整性检查

当没有涵盖所有可辨识联合的变化时,我们想让编译器可以通知我们。 比如,如果我们添加了TriangleShape,我们同时还需要更新area:

type Shape = Square | Rectangle | Circle | Triangle;
function area(s: Shape) {
  switch (s.kind) {
    case 'square':
      return s.size * s.size;
    case 'rectangle':
      return s.height * s.width;
    case 'circle':
      return Math.PI * s.radius ** 2;
  }
  // should error here - we didn't handle case "triangle"
}

有两种方式可以实现。 首先是启用--strictNullChecks并且指定一个返回值类型:

function area(s: Shape): number {
  // error: returns number | undefined
  switch (s.kind) {
    case 'square':
      return s.size * s.size;
    case 'rectangle':
      return s.height * s.width;
    case 'circle':
      return Math.PI * s.radius ** 2;
  }
}

因为switch没有包含所有情况,所以 TypeScript 认为这个函数有时候会返回undefined。 如果你明确地指定了返回值类型为number,那么你会看到一个错误,因为实际上返回值的类型为number | undefined。 然而,这种方法存在些微妙之处且--strictNullChecks对旧代码支持不好。

第二种方法使用never类型,编译器用它来进行完整性检查:

function assertNever(x: never): never {
  throw new Error('Unexpected object: ' + x);
}
function area(s: Shape) {
  switch (s.kind) {
    case 'square':
      return s.size * s.size;
    case 'rectangle':
      return s.height * s.width;
    case 'circle':
      return Math.PI * s.radius ** 2;
    default:
      return assertNever(s); // error here if there are missing cases
  }
}

这里,assertNever检查s是否为never类型—即为除去所有可能情况后剩下的类型。 如果你忘记了某个 case,那么s将具有一个真实的类型并且你会得到一个错误。 这种方式需要你定义一个额外的函数,但是在你忘记某个 case 的时候也更加明显。

多态的this类型

多态的this类型表示的是某个包含类或接口的子类型。 这被称做F-bounded 多态性。 它能很容易的表现连贯接口间的继承,比如。 在计算器的例子里,在每个操作之后都返回this类型:

class BasicCalculator {
  public constructor(protected value: number = 0) {}
  public currentValue(): number {
    return this.value;
  }
  public add(operand: number): this {
    this.value += operand;
    return this;
  }
  public multiply(operand: number): this {
    this.value *= operand;
    return this;
  }
  // ... other operations go here ...
}

let v = new BasicCalculator(2).multiply(5).add(1).currentValue();

由于这个类使用了this类型,你可以继承它,新的类可以直接使用之前的方法,不需要做任何的改变。

class ScientificCalculator extends BasicCalculator {
  public constructor(value = 0) {
    super(value);
  }
  public sin() {
    this.value = Math.sin(this.value);
    return this;
  }
  // ... other operations go here ...
}

let v = new ScientificCalculator(2).multiply(5).sin().add(1).currentValue();

如果没有this类型,ScientificCalculator就不能够在继承BasicCalculator的同时还保持接口的连贯性。 multiply将会返回BasicCalculator,它并没有sin方法。 然而,使用this类型,multiply会返回this,在这里就是ScientificCalculator

索引类型(Index types)

使用索引类型,编译器就能够检查使用了动态属性名的代码。 例如,一个常见的 JavaScript 模式是从对象中选取属性的子集。

function pluck(o, propertyNames) {
  return propertyNames.map(n => o[n]);
}

下面是如何在 TypeScript 里使用此函数,通过索引类型查询索引访问操作符:

function pluck<T, K extends keyof T>(o: T, propertyNames: K[]): T[K][] {
  return propertyNames.map(n => o[n]);
}

interface Car {
  manufacturer: string;
  model: string;
  year: number;
}
let taxi: Car = {
  manufacturer: 'Toyota',
  model: 'Camry',
  year: 2014,
};

// Manufacturer and model are both of type string,
// so we can pluck them both into a typed string array
let makeAndModel: string[] = pluck(taxi, ['manufacturer', 'model']);

// If we try to pluck model and year, we get an
// array of a union type: (string | number)[]
let modelYear = pluck(taxi, ['model', 'year']);

编译器会检查manufacturermodel是否真的是Car上的一个属性。 本例还引入了几个新的类型操作符。 首先是keyof T索引类型查询操作符。 对于任何类型Tkeyof T的结果为T上已知的公共属性名的联合。 例如:

let carProps: keyof Car; // the union of ('manufacturer' | 'model' | 'year')

keyof Car是完全可以与'manufacturer' | 'model' | 'year'互相替换的。 不同的是如果你添加了其它的属性到Car,例如ownersAddress: string,那么keyof Car会自动变为'manufacturer' | 'model' | 'year' | 'ownersAddress'。 你可以在像pluck函数这类上下文里使用keyof,因为在使用之前你并不清楚可能出现的属性名。 但编译器会检查你是否传入了正确的属性名给pluck

// error, 'unknown' is not in 'manufacturer' | 'model' | 'year'
pluck(taxi, ['year', 'unknown']);

第二个操作符是T[K]索引访问操作符。 在这里,类型语法反映了表达式语法。 这意味着person['name']具有类型Person['name'] — 在我们的例子里则为string类型。 然而,就像索引类型查询一样,你可以在普通的上下文里使用T[K],这正是它的强大所在。 你只要确保类型变量K extends keyof T就可以了。 例如下面getProperty函数的例子:

function getProperty<T, K extends keyof T>(o: T, propertyName: K): T[K] {
  return o[propertyName]; // o[propertyName] is of type T[K]
}

getProperty里的o: TpropertyName: K,意味着o[propertyName]: T[K]。 当你返回T[K]的结果,编译器会实例化键的真实类型,因此getProperty的返回值类型会随着你需要的属性改变。

let name: string = getProperty(taxi, 'manufacturer');
let year: number = getProperty(taxi, 'year');

// error, 'unknown' is not in 'manufacturer' | 'model' | 'year'
let unknown = getProperty(taxi, 'unknown');

索引类型和字符串索引签名

keyofT[K]与字符串索引签名进行交互。索引签名的参数类型必须为numberstring。 如果你有一个带有字符串索引签名的类型,那么keyof T会是string | number。 (并非只有string,因为在 JavaScript 里,你可以使用字符串object['42']或 数字object[42]索引来访问对象属性)。 并且T[string]为索引签名的类型:

interface Dictionary<T> {
  [key: string]: T;
}
let keys: keyof Dictionary<number>; // string | number
let value: Dictionary<number>['foo']; // number

如果一个类型带有数字索引签名,那么keyof Tnumber

interface Dictionary<T> {
  [key: number]: T;
}
let keys: keyof Dictionary<number>; // number
let value: Dictionary<number>['foo']; // Error, Property 'foo' does not exist on type 'Dictionary<number>'.
let value: Dictionary<number>[42]; // number

映射类型

一个常见的任务是将一个已知的类型每个属性都变为可选的:

interface PersonPartial {
  name?: string;
  age?: number;
}

或者我们想要一个只读版本:

interface PersonReadonly {
  readonly name: string;
  readonly age: number;
}

这在 JavaScript 里经常出现,TypeScript 提供了从旧类型中创建新类型的一种方式 — 映射类型。 在映射类型里,新类型以相同的形式去转换旧类型里每个属性。 例如,你可以令每个属性成为readonly类型或可选的。 下面是一些例子:

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};
type Partial<T> = {
  [P in keyof T]?: T[P];
};

像下面这样使用:

type PersonPartial = Partial<Person>;
type ReadonlyPerson = Readonly<Person>;

需要注意的是这个语法描述的是类型而非成员。 若想添加成员,则可以使用交叉类型:

// 这样使用
type PartialWithNewMember<T> = {
  [P in keyof T]?: T[P];
} & { newMember: boolean };
// 不要这样使用
// 这会报错!
type PartialWithNewMember<T> = {
  [P in keyof T]?: T[P];
};

下面来看看最简单的映射类型和它的组成部分:

type Keys = 'option1' | 'option2';
type Flags = { [K in Keys]: boolean };

它的语法与索引签名的语法类型,内部使用了for .. in。 具有三个部分:

  1. 类型变量K,它会依次绑定到每个属性。
  2. 字符串字面量联合的Keys,它包含了要迭代的属性名的集合。
  3. 属性的结果类型。

在个简单的例子里,Keys是硬编码的属性名列表并且属性类型永远是boolean,因此这个映射类型等同于:

type Flags = {
  option1: boolean;
  option2: boolean;
};

在真正的应用里,可能不同于上面的ReadonlyPartial。 它们会基于一些已存在的类型,且按照一定的方式转换字段。 这就是keyof和索引访问类型要做的事情:

type NullablePerson = { [P in keyof Person]: Person[P] | null };
type PartialPerson = { [P in keyof Person]?: Person[P] };

但它更有用的地方是可以有一些通用版本。

type Nullable<T> = { [P in keyof T]: T[P] | null };
type Partial<T> = { [P in keyof T]?: T[P] };

在这些例子里,属性列表是keyof T且结果类型是T[P]的变体。 这是使用通用映射类型的一个好模版。 因为这类转换是同态的,映射只作用于T的属性而没有其它的。 编译器知道在添加任何新属性之前可以拷贝所有存在的属性修饰符。 例如,假设Person.name是只读的,那么Partial<Person>.name也将是只读的且为可选的。

下面是另一个例子,T[P]被包装在Proxy<T>类里:

type Proxy<T> = {
  get(): T;
  set(value: T): void;
};
type Proxify<T> = {
  [P in keyof T]: Proxy<T[P]>;
};
function proxify<T>(o: T): Proxify<T> {
  // ... wrap proxies ...
}
let proxyProps = proxify(props);

注意Readonly<T>Partial<T>用处不小,因此它们与PickRecord一同被包含进了 TypeScript 的标准库里:

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};
type Record<K extends keyof any, T> = {
  [P in K]: T;
};

ReadonlyPartialPick是同态的,但Record不是。 因为Record并不需要输入类型来拷贝属性,所以它不属于同态:

type ThreeStringProps = Record<'prop1' | 'prop2' | 'prop3', string>;

非同态类型本质上会创建新的属性,因此它们不会从它处拷贝属性修饰符。

由映射类型进行推断

现在你了解了如何包装一个类型的属性,那么接下来就是如何拆包。 其实这也非常容易:

function unproxify<T>(t: Proxify<T>): T {
  let result = {} as T;
  for (const k in t) {
    result[k] = t[k].get();
  }
  return result;
}

let originalProps = unproxify(proxyProps);

注意这个拆包推断只适用于同态的映射类型。 如果映射类型不是同态的,那么需要给拆包函数一个明确的类型参数。

有条件类型

TypeScript 2.8 引入了有条件类型,它能够表示非统一的类型。 有条件的类型会以一个条件表达式进行类型关系检测,从而在两种类型中选择其一:

T extends U ? X : Y

上面的类型意思是,若T能够赋值给U,那么类型是X,否则为Y

有条件的类型T extends U ? X : Y或者解析X,或者解析Y,再或者延迟解析,因为它可能依赖一个或多个类型变量。 若TU包含类型参数,那么是否解析为XY或推迟,取决于类型系统是否有足够的信息来确定T总是可以赋值给U

下面是一些类型可以被立即解析的例子:

declare function f<T extends boolean>(x: T): T extends true ? string : number;

// Type is 'string | number
let x = f(Math.random() < 0.5);

另外一个例子涉及TypeName类型别名,它使用了嵌套了有条件类型:

type TypeName<T> = T extends string
  ? 'string'
  : T extends number
  ? 'number'
  : T extends boolean
  ? 'boolean'
  : T extends undefined
  ? 'undefined'
  : T extends Function
  ? 'function'
  : 'object';

type T0 = TypeName<string>; // "string"
type T1 = TypeName<'a'>; // "string"
type T2 = TypeName<true>; // "boolean"
type T3 = TypeName<() => void>; // "function"
type T4 = TypeName<string[]>; // "object"

下面是一个有条件类型被推迟解析的例子:

interface Foo {
  propA: boolean;
  propB: boolean;
}

declare function f<T>(x: T): T extends Foo ? string : number;

function foo<U>(x: U) {
  // Has type 'U extends Foo ? string : number'
  let a = f(x);

  // This assignment is allowed though!
  let b: string | number = a;
}

这里,a变量含有未确定的有条件类型。 当有另一段代码调用foo,它会用其它类型替换U,TypeScript 将重新计算有条件类型,决定它是否可以选择一个分支。

与此同时,我们可以将有条件类型赋值给其它类型,只要有条件类型的每个分支都可以赋值给目标类型。 因此在我们的例子里,我们可以将U extends Foo ? string : number赋值给string | number,因为不管这个有条件类型最终结果是什么,它只能是stringnumber

分布式有条件类型

如果有条件类型里待检查的类型是naked type parameter,那么它也被称为“分布式有条件类型”。 分布式有条件类型在实例化时会自动分发成联合类型。 例如,实例化T extends U ? X : YT的类型为A | B | C,会被解析为(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

例子

type T10 = TypeName<string | (() => void)>; // "string" | "function"
type T12 = TypeName<string | string[] | undefined>; // "string" | "object" | "undefined"
type T11 = TypeName<string[] | number[]>; // "object"

T extends U ? X : Y的实例化里,对T的引用被解析为联合类型的一部分(比如,T指向某一单个部分,在有条件类型分布到联合类型之后)。 此外,在X内对T的引用有一个附加的类型参数约束U(例如,T被当成在X内可赋值给U)。

例子

type BoxedValue<T> = { value: T };
type BoxedArray<T> = { array: T[] };
type Boxed<T> = T extends any[] ? BoxedArray<T[number]> : BoxedValue<T>;

type T20 = Boxed<string>; // BoxedValue<string>;
type T21 = Boxed<number[]>; // BoxedArray<number>;
type T22 = Boxed<string | number[]>; // BoxedValue<string> | BoxedArray<number>;

注意在Boxed<T>true分支里,T有个额外的约束any[],因此它适用于T[number]数组元素类型。同时也注意一下有条件类型是如何分布成联合类型的。

有条件类型的分布式的属性可以方便地用来过滤联合类型:

type Diff<T, U> = T extends U ? never : T; // Remove types from T that are assignable to U
type Filter<T, U> = T extends U ? T : never; // Remove types from T that are not assignable to U

type T30 = Diff<'a' | 'b' | 'c' | 'd', 'a' | 'c' | 'f'>; // "b" | "d"
type T31 = Filter<'a' | 'b' | 'c' | 'd', 'a' | 'c' | 'f'>; // "a" | "c"
type T32 = Diff<string | number | (() => void), Function>; // string | number
type T33 = Filter<string | number | (() => void), Function>; // () => void

type NonNullable<T> = Diff<T, null | undefined>; // Remove null and undefined from T

type T34 = NonNullable<string | number | undefined>; // string | number
type T35 = NonNullable<string | string[] | null | undefined>; // string | string[]

function f1<T>(x: T, y: NonNullable<T>) {
  x = y; // Ok
  y = x; // Error
}

function f2<T extends string | undefined>(x: T, y: NonNullable<T>) {
  x = y; // Ok
  y = x; // Error
  let s1: string = x; // Error
  let s2: string = y; // Ok
}

有条件类型与映射类型结合时特别有用:

type FunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;

type NonFunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;

interface Part {
  id: number;
  name: string;
  subparts: Part[];
  updatePart(newName: string): void;
}

type T40 = FunctionPropertyNames<Part>; // "updatePart"
type T41 = NonFunctionPropertyNames<Part>; // "id" | "name" | "subparts"
type T42 = FunctionProperties<Part>; // { updatePart(newName: string): void }
type T43 = NonFunctionProperties<Part>; // { id: number, name: string, subparts: Part[] }

与联合类型和交叉类型相似,有条件类型不允许递归地引用自己。比如下面的错误。

例子

// 在 TypeScript 4.1 之前的版本会报错。
// TypeScript 4.1 改进了对递归的有条件类型的支持,详情参考 4.1 版本发布说明
type ElementType<T> = T extends any[] ? ElementType<T[number]> : T;

有条件类型中的类型推断

现在在有条件类型的extends子语句中,允许出现infer声明,它会引入一个待推断的类型变量。 这个推断的类型变量可以在有条件类型的 true 分支中被引用。 允许出现多个同类型变量的infer

例如,下面代码会提取函数类型的返回值类型:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

有条件类型可以嵌套来构成一系列的匹配模式,按顺序进行求值:

type Unpacked<T> = T extends (infer U)[]
  ? U
  : T extends (...args: any[]) => infer U
  ? U
  : T extends Promise<infer U>
  ? U
  : T;

type T0 = Unpacked<string>; // string
type T1 = Unpacked<string[]>; // string
type T2 = Unpacked<() => string>; // string
type T3 = Unpacked<Promise<string>>; // string
type T4 = Unpacked<Promise<string>[]>; // Promise<string>
type T5 = Unpacked<Unpacked<Promise<string>[]>>; // string

下面的例子解释了在协变位置上,同一个类型变量的多个候选类型会被推断为联合类型:

type Foo<T> = T extends { a: infer U; b: infer U } ? U : never;
type T10 = Foo<{ a: string; b: string }>; // string
type T11 = Foo<{ a: string; b: number }>; // string | number

相似地,在抗变位置上,同一个类型变量的多个候选类型会被推断为交叉类型:

type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void }
  ? U
  : never;
type T20 = Bar<{ a: (x: string) => void; b: (x: string) => void }>; // string
type T21 = Bar<{ a: (x: string) => void; b: (x: number) => void }>; // string & number

当推断具有多个调用签名(例如函数重载类型)的类型时,用最后的签名(大概是最自由的包含所有情况的签名)进行推断。 无法根据参数类型列表来解析重载。

declare function foo(x: string): number;
declare function foo(x: number): string;
declare function foo(x: string | number): string | number;
type T30 = ReturnType<typeof foo>; // string | number

无法在正常类型参数的约束子语句中使用infer声明:

type ReturnType<T extends (...args: any[]) => infer R> = R; // 错误,不支持

但是,可以这样达到同样的效果,在约束里删掉类型变量,用有条件类型替换:

type AnyFunction = (...args: any[]) => any;
type ReturnType<T extends AnyFunction> = T extends (...args: any[]) => infer R
  ? R
  : any;

预定义的有条件类型

TypeScript 2.8 在lib.d.ts里增加了一些预定义的有条件类型:

  • Exclude<T, U> -- 从T中剔除可以赋值给U的类型。
  • Extract<T, U> -- 提取T中可以赋值给U的类型。
  • NonNullable<T> -- 从T中剔除nullundefined
  • ReturnType<T> -- 获取函数返回值类型。
  • InstanceType<T> -- 获取构造函数类型的实例类型。

Example

type T00 = Exclude<'a' | 'b' | 'c' | 'd', 'a' | 'c' | 'f'>; // "b" | "d"
type T01 = Extract<'a' | 'b' | 'c' | 'd', 'a' | 'c' | 'f'>; // "a" | "c"

type T02 = Exclude<string | number | (() => void), Function>; // string | number
type T03 = Extract<string | number | (() => void), Function>; // () => void

type T04 = NonNullable<string | number | undefined>; // string | number
type T05 = NonNullable<(() => string) | string[] | null | undefined>; // (() => string) | string[]

function f1(s: string) {
  return { a: 1, b: s };
}

class C {
  x = 0;
  y = 0;
}

type T10 = ReturnType<() => string>; // string
type T11 = ReturnType<(s: string) => void>; // void
type T12 = ReturnType<<T>() => T>; // {}
type T13 = ReturnType<<T extends U, U extends number[]>() => T>; // number[]
type T14 = ReturnType<typeof f1>; // { a: number, b: string }
type T15 = ReturnType<any>; // any
type T16 = ReturnType<never>; // never
type T17 = ReturnType<string>; // Error
type T18 = ReturnType<Function>; // Error

type T20 = InstanceType<typeof C>; // C
type T21 = InstanceType<any>; // any
type T22 = InstanceType<never>; // never
type T23 = InstanceType<string>; // Error
type T24 = InstanceType<Function>; // Error

注意:Exclude类型是建议的Diff类型的一种实现。我们使用Exclude这个名字是为了避免破坏已经定义了Diff的代码,并且我们感觉这个名字能更好地表达类型的语义。

实用工具类型

TypeScript 提供一些工具类型来帮助常见的类型转换。这些类型是全局可见的。

目录

Partial<Type>

构造类型Type,并将它所有的属性设置为可选的。它的返回类型表示输入类型的所有子类型。

例子

interface Todo {
  title: string;
  description: string;
}

function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
  return { ...todo, ...fieldsToUpdate };
}

const todo1 = {
  title: 'organize desk',
  description: 'clear clutter',
};

const todo2 = updateTodo(todo1, {
  description: 'throw out trash',
});

Readonly<Type>

构造类型Type,并将它所有的属性设置为readonly,也就是说构造出的类型的属性不能被再次赋值。

例子

interface Todo {
  title: string;
}

const todo: Readonly<Todo> = {
  title: 'Delete inactive users',
};

todo.title = 'Hello'; // Error: cannot reassign a readonly property

这个工具可用来表示在运行时会失败的赋值表达式(比如,当尝试给冻结对象的属性再次赋值时)。

Object.freeze

function freeze<T>(obj: T): Readonly<T>;

Record<Keys, Type>

构造一个类型,其属性名的类型为K,属性值的类型为T。这个工具可用来将某个类型的属性映射到另一个类型上。

例子

interface PageInfo {
  title: string;
}

type Page = 'home' | 'about' | 'contact';

const x: Record<Page, PageInfo> = {
  about: { title: 'about' },
  contact: { title: 'contact' },
  home: { title: 'home' },
};

Pick<Type, Keys>

从类型Type中挑选部分属性Keys来构造类型。

例子

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Pick<Todo, 'title' | 'completed'>;

const todo: TodoPreview = {
  title: 'Clean room',
  completed: false,
};

Omit<Type, Keys>

从类型Type中获取所有属性,然后从中剔除Keys属性后构造一个类型。

例子

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Omit<Todo, 'description'>;

const todo: TodoPreview = {
  title: 'Clean room',
  completed: false,
};

Exclude<Type, ExcludedUnion>

从类型Type中剔除所有可以赋值给ExcludedUnion的属性,然后构造一个类型。

例子

type T0 = Exclude<'a' | 'b' | 'c', 'a'>; // "b" | "c"
type T1 = Exclude<'a' | 'b' | 'c', 'a' | 'b'>; // "c"
type T2 = Exclude<string | number | (() => void), Function>; // string | number

Extract<Type, Union>

从类型Type中提取所有可以赋值给Union的类型,然后构造一个类型。

例子

type T0 = Extract<'a' | 'b' | 'c', 'a' | 'f'>; // "a"
type T1 = Extract<string | number | (() => void), Function>; // () => void

NonNullable<Type>

从类型Type中剔除nullundefined,然后构造一个类型。

例子

type T0 = NonNullable<string | number | undefined>; // string | number
type T1 = NonNullable<string[] | null | undefined>; // string[]

Parameters<Type>

由函数类型Type的参数类型来构建出一个元组类型。

例子

declare function f1(arg: { a: number; b: string }): void;

type T0 = Parameters<() => string>;
//    []
type T1 = Parameters<(s: string) => void>;
//    [s: string]
type T2 = Parameters<<T>(arg: T) => T>;
//    [arg: unknown]
type T3 = Parameters<typeof f1>;
//    [arg: { a: number; b: string; }]
type T4 = Parameters<any>;
//    unknown[]
type T5 = Parameters<never>;
//    never
type T6 = Parameters<string>;
//   never
//   Type 'string' does not satisfy the constraint '(...args: any) => any'.
type T7 = Parameters<Function>;
//   never
//   Type 'Function' does not satisfy the constraint '(...args: any) => any'.

ConstructorParameters<Type>

由构造函数类型来构建出一个元组类型或数组类型。 由构造函数类型Type的参数类型来构建出一个元组类型。(若Type不是构造函数类型,则返回never)。

例子

type T0 = ConstructorParameters<ErrorConstructor>;
//    [message?: string | undefined]
type T1 = ConstructorParameters<FunctionConstructor>;
//    string[]
type T2 = ConstructorParameters<RegExpConstructor>;
//    [pattern: string | RegExp, flags?: string | undefined]
type T3 = ConstructorParameters<any>;
//   unknown[]

type T4 = ConstructorParameters<Function>;
//    never
// Type 'Function' does not satisfy the constraint 'new (...args: any) => any'.

ReturnType<Type>

由函数类型Type的返回值类型构建一个新类型。

例子

type T0 = ReturnType<() => string>;  // string
type T1 = ReturnType<(s: string) => void>;  // void
type T2 = ReturnType<(<T>() => T)>;  // {}
type T3 = ReturnType<(<T extends U, U extends number[]>() => T)>;  // number[]
type T4 = ReturnType<typeof f1>;  // { a: number, b: string }
type T5 = ReturnType<any>;  // any
type T6 = ReturnType<never>;  // any
type T7 = ReturnType<string>;  // Error
type T8 = ReturnType<Function>;  // Error

InstanceType<Type>

由构造函数类型Type的实例类型来构建一个新类型。

例子

class C {
  x = 0;
  y = 0;
}

type T0 = InstanceType<typeof C>; // C
type T1 = InstanceType<any>; // any
type T2 = InstanceType<never>; // any
type T3 = InstanceType<string>; // Error
type T4 = InstanceType<Function>; // Error

Required<Type>

构建一个类型,使类型Type的所有属性为required。 与此相反的是Partial

例子

interface Props {
  a?: number;
  b?: string;
}

const obj: Props = { a: 5 }; // OK

const obj2: Required<Props> = { a: 5 }; // Error: property 'b' missing

ThisParameterType<Type>

从函数类型中提取 this 参数的类型。 若函数类型不包含 this 参数,则返回 unknown 类型。

例子

function toHex(this: Number) {
  return this.toString(16);
}

function numberToString(n: ThisParameterType<typeof toHex>) {
  return toHex.apply(n);
}

OmitThisParameter<Type>

Type类型中剔除 this 参数。 若未声明 this 参数,则结果类型为 Type 。 否则,由Type类型来构建一个不带this参数的类型。 泛型会被忽略,并且只有最后的重载签名会被采用。

例子

function toHex(this: Number) {
  return this.toString(16);
}

const fiveToHex: OmitThisParameter<typeof toHex> = toHex.bind(5);

console.log(fiveToHex());

ThisType<Type>

这个工具不会返回一个转换后的类型。 它做为上下文的this类型的一个标记。 注意,若想使用此类型,必须启用--noImplicitThis

例子

// Compile with --noImplicitThis

type ObjectDescriptor<D, M> = {
  data?: D;
  methods?: M & ThisType<D & M>; // Type of 'this' in methods is D & M
};

function makeObject<D, M>(desc: ObjectDescriptor<D, M>): D & M {
  let data: object = desc.data || {};
  let methods: object = desc.methods || {};
  return { ...data, ...methods } as D & M;
}

let obj = makeObject({
  data: { x: 0, y: 0 },
  methods: {
    moveBy(dx: number, dy: number) {
      this.x += dx; // Strongly typed this
      this.y += dy; // Strongly typed this
    },
  },
});

obj.x = 10;
obj.y = 20;
obj.moveBy(5, 5);

上面例子中,makeObject参数里的methods对象具有一个上下文类型ThisType<D & M>,因此methods对象的方法里this的类型为{ x: number, y: number } & { moveBy(dx: number, dy: number): number }

lib.d.ts里,ThisType<T>标识接口是个简单的空接口声明。除了在被识别为对象字面量的上下文类型之外,这个接口与一般的空接口没有什么不同。

操作字符串的类型

为了便于操作模版字符串字面量,TypeScript 引入了一些能够操作字符串的类型。 更多详情,请阅读模版字面量类型

Decorators

介绍

随着 TypeScript 和 ES6 里引入了类,在一些场景下我们需要额外的特性来支持标注或修改类及其成员。 装饰器(Decorators)为我们在类的声明及成员上通过元编程语法添加标注提供了一种方式。 Javascript 里的装饰器目前处在建议征集的第二阶段,但在 TypeScript 里已做为一项实验性特性予以支持。

注意   装饰器是一项实验性特性,在未来的版本中可能会发生改变。

若要启用实验性的装饰器特性,你必须在命令行或tsconfig.json里启用experimentalDecorators编译器选项:

命令行:

tsc --target ES5 --experimentalDecorators

tsconfig.json:

{
    "compilerOptions": {
        "target": "ES5",
        "experimentalDecorators": true
    }
}

装饰器

装饰器是一种特殊类型的声明,它能够被附加到类声明方法访问符属性参数上。 装饰器使用@expression这种形式,expression求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。

例如,有一个@sealed装饰器,我们会这样定义sealed函数:

function sealed(target) {
  // do something with "target" ...
}

注意   后面类装饰器小节里有一个更加详细的例子。

装饰器工厂

如果我们要定制一个修饰器如何应用到一个声明上,我们得写一个装饰器工厂函数。 装饰器工厂就是一个简单的函数,它返回一个表达式,以供装饰器在运行时调用。

我们可以通过下面的方式来写一个装饰器工厂函数:

function color(value: string) {
  // 这是一个装饰器工厂
  return function (target) {
    //  这是装饰器
    // do something with "target" and "value"...
  };
}

注意   下面方法装饰器小节里有一个更加详细的例子。

装饰器组合

多个装饰器可以同时应用到一个声明上,就像下面的示例:

  • 书写在同一行上:
@f @g x
  • 书写在多行上:
@f
@g
x

当多个装饰器应用于一个声明上,它们求值方式与复合函数相似。在这个模型下,当复合fg时,复合的结果(fg)(x)等同于f(g(x))。

同样的,在 TypeScript 里,当多个装饰器应用在一个声明上时会进行如下步骤的操作:

  1. 由上至下依次对装饰器表达式求值。
  2. 求值的结果会被当作函数,由下至上依次调用。

如果我们使用装饰器工厂的话,可以通过下面的例子来观察它们求值的顺序:

function f() {
  console.log('f(): evaluated');
  return function (
    target,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    console.log('f(): called');
  };
}

function g() {
  console.log('g(): evaluated');
  return function (
    target,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    console.log('g(): called');
  };
}

class C {
  @f()
  @g()
  method() {}
}

在控制台里会打印出如下结果:

f(): evaluated
g(): evaluated
g(): called
f(): called

装饰器求值

类中不同声明上的装饰器将按以下规定的顺序应用:

  1. 参数装饰器,然后依次是方法装饰器访问符装饰器,或属性装饰器应用到每个实例成员。
  2. 参数装饰器,然后依次是方法装饰器访问符装饰器,或属性装饰器应用到每个静态成员。
  3. 参数装饰器应用到构造函数。
  4. 类装饰器应用到类。

类装饰器

类装饰器在类声明之前被声明(紧靠着类声明)。 类装饰器应用于类构造函数,可以用来监视,修改或替换类定义。 类装饰器不能用在声明文件中(.d.ts),也不能用在任何外部上下文中(比如declare的类)。

类装饰器表达式会在运行时当作函数被调用,类的构造函数作为其唯一的参数。

如果类装饰器返回一个值,它会使用提供的构造函数来替换类的声明。

注意   如果你要返回一个新的构造函数,你必须注意处理好原来的原型链。 在运行时的装饰器调用逻辑中不会为你做这些。

下面是使用类装饰器(@sealed)的例子,应用在Greeter类:

@sealed
class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  greet() {
    return 'Hello, ' + this.greeting;
  }
}

我们可以这样定义@sealed装饰器:

function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

@sealed被执行的时候,它将密封此类的构造函数和原型。(注:参见Object.seal)

下面是一个重载构造函数的例子。

function classDecorator<T extends { new (...args: any[]): {} }>(
  constructor: T
) {
  return class extends constructor {
    newProperty = 'new property';
    hello = 'override';
  };
}

@classDecorator
class Greeter {
  property = 'property';
  hello: string;
  constructor(m: string) {
    this.hello = m;
  }
}

console.log(new Greeter('world'));

方法装饰器

方法装饰器声明在一个方法的声明之前(紧靠着方法声明)。 它会被应用到方法的属性描述符上,可以用来监视,修改或者替换方法定义。 方法装饰器不能用在声明文件(.d.ts),重载或者任何外部上下文(比如declare的类)中。

方法装饰器表达式会在运行时当作函数被调用,传入下列 3 个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 成员的名字。
  3. 成员的属性描述符

注意   如果代码输出目标版本小于ES5属性描述符将会是undefined

如果方法装饰器返回一个值,它会被用作方法的属性描述符

注意   如果代码输出目标版本小于ES5返回值会被忽略。

下面是一个方法装饰器(@enumerable)的例子,应用于Greeter类的方法上:

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }

  @enumerable(false)
  greet() {
    return 'Hello, ' + this.greeting;
  }
}

我们可以用下面的函数声明来定义@enumerable装饰器:

function enumerable(value: boolean) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    descriptor.enumerable = value;
  };
}

这里的@enumerable(false)是一个装饰器工厂。 当装饰器@enumerable(false)被调用时,它会修改属性描述符的enumerable属性。

访问器装饰器

访问器装饰器声明在一个访问器的声明之前(紧靠着访问器声明)。 访问器装饰器应用于访问器的属性描述符并且可以用来监视,修改或替换一个访问器的定义。 访问器装饰器不能用在声明文件中(.d.ts),或者任何外部上下文(比如declare的类)里。

注意   TypeScript 不允许同时装饰一个成员的getset访问器。取而代之的是,一个成员的所有装饰的必须应用在文档顺序的第一个访问器上。这是因为,在装饰器应用于一个属性描述符时,它联合了getset访问器,而不是分开声明的。

访问器装饰器表达式会在运行时当作函数被调用,传入下列 3 个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 成员的名字。
  3. 成员的属性描述符

注意   如果代码输出目标版本小于ES5Property Descriptor将会是undefined

如果访问器装饰器返回一个值,它会被用作方法的属性描述符

注意   如果代码输出目标版本小于ES5返回值会被忽略。

下面是使用了访问器装饰器(@configurable)的例子,应用于Point类的成员上:

class Point {
  private _x: number;
  private _y: number;
  constructor(x: number, y: number) {
    this._x = x;
    this._y = y;
  }

  @configurable(false)
  get x() {
    return this._x;
  }

  @configurable(false)
  get y() {
    return this._y;
  }
}

我们可以通过如下函数声明来定义@configurable装饰器:

function configurable(value: boolean) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    descriptor.configurable = value;
  };
}

属性装饰器

属性装饰器声明在一个属性声明之前(紧靠着属性声明)。 属性装饰器不能用在声明文件中(.d.ts),或者任何外部上下文(比如declare的类)里。

属性装饰器表达式会在运行时当作函数被调用,传入下列 2 个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 成员的名字。

注意   属性描述符不会做为参数传入属性装饰器,这与 TypeScript 是如何初始化属性装饰器的有关。 因为目前没有办法在定义一个原型对象的成员时描述一个实例属性,并且没办法监视或修改一个属性的初始化方法。返回值也会被忽略。 因此,属性描述符只能用来监视类中是否声明了某个名字的属性。

如果访问符装饰器返回一个值,它会被用作方法的属性描述符

我们可以用它来记录这个属性的元数据,如下例所示:

class Greeter {
  @format('Hello, %s')
  greeting: string;

  constructor(message: string) {
    this.greeting = message;
  }
  greet() {
    let formatString = getFormat(this, 'greeting');
    return formatString.replace('%s', this.greeting);
  }
}

然后定义@format装饰器和getFormat函数:

import 'reflect-metadata';

const formatMetadataKey = Symbol('format');

function format(formatString: string) {
  return Reflect.metadata(formatMetadataKey, formatString);
}

function getFormat(target: any, propertyKey: string) {
  return Reflect.getMetadata(formatMetadataKey, target, propertyKey);
}

这个@format("Hello, %s")装饰器是个 装饰器工厂。 当@format("Hello, %s")被调用时,它添加一条这个属性的元数据,通过reflect-metadata库里的Reflect.metadata函数。 当getFormat被调用时,它读取格式的元数据。

注意   这个例子需要使用reflect-metadata库。 查看元数据了解reflect-metadata库更详细的信息。

参数装饰器

参数装饰器声明在一个参数声明之前(紧靠着参数声明)。 参数装饰器应用于类构造函数或方法声明。 参数装饰器不能用在声明文件(.d.ts),重载或其它外部上下文(比如declare的类)里。

参数装饰器表达式会在运行时当作函数被调用,传入下列 3 个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 成员的名字。
  3. 参数在函数参数列表中的索引。

注意   参数装饰器只能用来监视一个方法的参数是否被传入。

参数装饰器的返回值会被忽略。

下例定义了参数装饰器(@required)并应用于Greeter类方法的一个参数:

class Greeter {
  greeting: string;

  constructor(message: string) {
    this.greeting = message;
  }

  @validate
  greet(@required name: string) {
    return 'Hello ' + name + ', ' + this.greeting;
  }
}

然后我们使用下面的函数定义 @required@validate 装饰器:

import 'reflect-metadata';

const requiredMetadataKey = Symbol('required');

function required(
  target: Object,
  propertyKey: string | symbol,
  parameterIndex: number
) {
  let existingRequiredParameters: number[] =
    Reflect.getOwnMetadata(requiredMetadataKey, target, propertyKey) || [];
  existingRequiredParameters.push(parameterIndex);
  Reflect.defineMetadata(
    requiredMetadataKey,
    existingRequiredParameters,
    target,
    propertyKey
  );
}

function validate(
  target: any,
  propertyName: string,
  descriptor: TypedPropertyDescriptor<Function>
) {
  let method = descriptor.value;
  descriptor.value = function () {
    let requiredParameters: number[] = Reflect.getOwnMetadata(
      requiredMetadataKey,
      target,
      propertyName
    );
    if (requiredParameters) {
      for (let parameterIndex of requiredParameters) {
        if (
          parameterIndex >= arguments.length ||
          arguments[parameterIndex] === undefined
        ) {
          throw new Error('Missing required argument.');
        }
      }
    }

    return method.apply(this, arguments);
  };
}

@required装饰器添加了元数据实体把参数标记为必需的。 @validate装饰器把greet方法包裹在一个函数里在调用原先的函数前验证函数参数。

注意   这个例子使用了reflect-metadata库。 查看元数据了解reflect-metadata库的更多信息。

元数据

一些例子使用了reflect-metadata库来支持实验性的 metadata API。 这个库还不是 ECMAScript (JavaScript)标准的一部分。 然而,当装饰器被 ECMAScript 官方标准采纳后,这些扩展也将被推荐给 ECMAScript 以采纳。

你可以通过 npm 安装这个库:

npm i reflect-metadata --save

TypeScript 支持为带有装饰器的声明生成元数据。 你需要在命令行或tsconfig.json里启用emitDecoratorMetadata编译器选项。

Command Line:

tsc --target ES5 --experimentalDecorators --emitDecoratorMetadata

tsconfig.json:

{
    "compilerOptions": {
        "target": "ES5",
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
    }
}

当启用后,只要reflect-metadata库被引入了,设计阶段添加的类型信息可以在运行时使用。

如下例所示:

import 'reflect-metadata';

class Point {
  x: number;
  y: number;
}

class Line {
  private _p0: Point;
  private _p1: Point;

  @validate
  set p0(value: Point) {
    this._p0 = value;
  }
  get p0() {
    return this._p0;
  }

  @validate
  set p1(value: Point) {
    this._p1 = value;
  }
  get p1() {
    return this._p1;
  }
}

function validate<T>(
  target: any,
  propertyKey: string,
  descriptor: TypedPropertyDescriptor<T>
) {
  let set = descriptor.set;
  descriptor.set = function (value: T) {
    let type = Reflect.getMetadata('design:type', target, propertyKey);
    if (!(value instanceof type)) {
      throw new TypeError('Invalid type.');
    }
    set.call(target, value);
  };
}

TypeScript 编译器可以通过@Reflect.metadata装饰器注入设计阶段的类型信息。 你可以认为它相当于下面的 TypeScript:

class Line {
  private _p0: Point;
  private _p1: Point;

  @validate
  @Reflect.metadata('design:type', Point)
  set p0(value: Point) {
    this._p0 = value;
  }
  get p0() {
    return this._p0;
  }

  @validate
  @Reflect.metadata('design:type', Point)
  set p1(value: Point) {
    this._p1 = value;
  }
  get p1() {
    return this._p1;
  }
}

注意   装饰器元数据是个实验性的特性并且可能在以后的版本中发生破坏性的改变(breaking changes)。

声明合并

介绍

TypeScript 中有些独特的概念可以在类型层面上描述 JavaScript 对象的模型。 这其中尤其独特的一个例子是“声明合并”的概念。 理解了这个概念,将有助于操作现有的 JavaScript 代码。 同时,也会有助于理解更多高级抽象的概念。

对本文件来讲,“声明合并”是指编译器将针对同一个名字的两个独立声明合并为单一声明。 合并后的声明同时拥有原先两个声明的特性。 任何数量的声明都可被合并;不局限于两个声明。

基础概念

TypeScript 中的声明会创建以下三种实体之一:命名空间,类型或值。 创建命名空间的声明会新建一个命名空间,它包含了用(.)符号来访问时使用的名字。 创建类型的声明是:用声明的模型创建一个类型并绑定到给定的名字上。 最后,创建值的声明会创建在 JavaScript 输出中看到的值。

Declaration TypeNamespaceTypeValue
NamespaceXX
ClassXX
EnumXX
InterfaceX
Type AliasX
FunctionX
VariableX

理解每个声明创建了什么,有助于理解当声明合并时有哪些东西被合并了。

合并接口

最简单也最常见的声明合并类型是接口合并。 从根本上说,合并的机制是把双方的成员放到一个同名的接口里。

interface Box {
  height: number;
  width: number;
}

interface Box {
  scale: number;
}

let box: Box = { height: 5, width: 6, scale: 10 };

接口的非函数的成员应该是唯一的。 如果它们不是唯一的,那么它们必须是相同的类型。 如果两个接口中同时声明了同名的非函数成员且它们的类型不同,则编译器会报错。

对于函数成员,每个同名函数声明都会被当成这个函数的一个重载。 同时需要注意,当接口A与后来的接口A合并时,后面的接口具有更高的优先级。

如下例所示:

interface Cloner {
  clone(animal: Animal): Animal;
}

interface Cloner {
  clone(animal: Sheep): Sheep;
}

interface Cloner {
  clone(animal: Dog): Dog;
  clone(animal: Cat): Cat;
}

这三个接口合并成一个声明:

interface Cloner {
  clone(animal: Dog): Dog;
  clone(animal: Cat): Cat;
  clone(animal: Sheep): Sheep;
  clone(animal: Animal): Animal;
}

注意每组接口里的声明顺序保持不变,但各组接口之间的顺序是后来的接口重载出现在靠前位置。

这个规则有一个例外是当出现特殊的函数签名时。 如果签名里有一个参数的类型是单一的字符串字面量(比如,不是字符串字面量的联合类型),那么它将会被提升到重载列表的最顶端。

比如,下面的接口会合并到一起:

interface Document {
  createElement(tagName: any): Element;
}
interface Document {
  createElement(tagName: 'div'): HTMLDivElement;
  createElement(tagName: 'span'): HTMLSpanElement;
}
interface Document {
  createElement(tagName: string): HTMLElement;
  createElement(tagName: 'canvas'): HTMLCanvasElement;
}

合并后的Document将会像下面这样:

interface Document {
  createElement(tagName: 'canvas'): HTMLCanvasElement;
  createElement(tagName: 'div'): HTMLDivElement;
  createElement(tagName: 'span'): HTMLSpanElement;
  createElement(tagName: string): HTMLElement;
  createElement(tagName: any): Element;
}

合并命名空间

与接口相似,同名的命名空间也会合并其成员。 命名空间会创建出命名空间和值,我们需要知道这两者都是怎么合并的。

对于命名空间的合并,模块导出的同名接口进行合并,构成单一命名空间内含合并后的接口。

对于命名空间里值的合并,如果当前已经存在给定名字的命名空间,那么后来的命名空间的导出成员会被加到已经存在的那个模块里。

Animals声明合并示例:

namespace Animals {
  export class Zebra {}
}

namespace Animals {
  export interface Legged {
    numberOfLegs: number;
  }
  export class Dog {}
}

等同于:

namespace Animals {
  export interface Legged {
    numberOfLegs: number;
  }

  export class Zebra {}
  export class Dog {}
}

除了这些合并外,你还需要了解非导出成员是如何处理的。 非导出成员仅在其原有的(合并前的)命名空间内可见。这就是说合并之后,从其它命名空间合并进来的成员无法访问非导出成员。

下例提供了更清晰的说明:

namespace Animal {
  let haveMuscles = true;

  export function animalsHaveMuscles() {
    return haveMuscles;
  }
}

namespace Animal {
  export function doAnimalsHaveMuscles() {
    return haveMuscles; // Error, because haveMuscles is not accessible here
  }
}

因为haveMuscles并没有导出,只有animalsHaveMuscles函数共享了原始未合并的命名空间可以访问这个变量。 doAnimalsHaveMuscles函数虽是合并命名空间的一部分,但是访问不了未导出的成员。

命名空间与类和函数和枚举类型合并

命名空间可以与其它类型的声明进行合并。 只要命名空间的定义符合将要合并类型的定义。合并结果包含两者的声明类型。 TypeScript 使用这个功能去实现一些 JavaScript 里的设计模式。

合并命名空间和类

这让我们可以表示内部类。

class Album {
  label: Album.AlbumLabel;
}
namespace Album {
  export class AlbumLabel {}
}

合并规则与上面合并命名空间小节里讲的规则一致,我们必须导出AlbumLabel类,好让合并的类能访问。 合并结果是一个类并带有一个内部类。 你也可以使用命名空间为类增加一些静态属性。

除了内部类的模式,你在 JavaScript 里,创建一个函数稍后扩展它增加一些属性也是很常见的。 TypeScript 使用声明合并来达到这个目的并保证类型安全。

function buildLabel(name: string): string {
  return buildLabel.prefix + name + buildLabel.suffix;
}

namespace buildLabel {
  export let suffix = '';
  export let prefix = 'Hello, ';
}

console.log(buildLabel('Sam Smith'));

相似的,命名空间可以用来扩展枚举型:

enum Color {
  red = 1,
  green = 2,
  blue = 4,
}

namespace Color {
  export function mixColor(colorName: string) {
    if (colorName == 'yellow') {
      return Color.red + Color.green;
    } else if (colorName == 'white') {
      return Color.red + Color.green + Color.blue;
    } else if (colorName == 'magenta') {
      return Color.red + Color.blue;
    } else if (colorName == 'cyan') {
      return Color.green + Color.blue;
    }
  }
}

非法的合并

TypeScript 并非允许所有的合并。 目前,类不能与其它类或变量合并。 想要了解如何模仿类的合并,请参考TypeScript 的混入

模块扩展

虽然 JavaScript 不支持合并,但你可以为导入的对象打补丁以更新它们。让我们考察一下这个玩具性的示例:

// observable.ts
export class Observable<T> {
  // ... implementation left as an exercise for the reader ...
}

// map.ts
import { Observable } from './observable';
Observable.prototype.map = function (f) {
  // ... another exercise for the reader
};

它也可以很好地工作在 TypeScript 中, 但编译器对 Observable.prototype.map一无所知。 你可以使用扩展模块来将它告诉编译器:

// observable.ts
export class Observable<T> {
  // ... implementation left as an exercise for the reader ...
}

// map.ts
import { Observable } from './observable';
declare module './observable' {
  interface Observable<T> {
    map<U>(f: (x: T) => U): Observable<U>;
  }
}
Observable.prototype.map = function (f) {
  // ... another exercise for the reader
};

// consumer.ts
import { Observable } from './observable';
import './map';
let o: Observable<number>;
o.map(x => x.toFixed());

模块名的解析和用import/export解析模块标识符的方式是一致的。 更多信息请参考 Modules。 当这些声明在扩展中合并时,就如同在原始位置被声明一样。 但是,有两点限制需要注意:

  1. 你不能在扩展中声明新的顶级声明-仅可以扩展模块中已经存在的声明。
  2. 默认导出也不能扩展,只有命名的导出才可以(因为你需要使用导出的名字来进行扩展,并且default是保留关键字 - 详情查看#14080

全局扩展

你也以在模块内部添加声明到全局作用域中。

// observable.ts
export class Observable<T> {
  // ... still no implementation ...
}

declare global {
  interface Array<T> {
    toObservable(): Observable<T>;
  }
}

Array.prototype.toObservable = function () {
  // ...
};

全局扩展与模块扩展的行为和限制是相同的。

Iterators 和 Generators

当一个对象实现了Symbol.iterator属性时,我们认为它是可迭代的。 一些内置的类型如ArrayMapSetStringInt32ArrayUint32Array等都已经实现了各自的Symbol.iterator。 对象上的Symbol.iterator函数负责返回供迭代的值。

for..of 语句

for..of会遍历可迭代的对象,调用对象上的Symbol.iterator方法。 下面是在数组上使用for..of的简单例子:

let someArray = [1, 'string', false];

for (let entry of someArray) {
  console.log(entry); // 1, "string", false
}

for..of vs. for..in 语句

for..offor..in均可迭代一个列表;但是用于迭代的值却不同,for..in迭代的是对象的 的列表,而for..of则迭代对象的键对应的值。

下面的例子展示了两者之间的区别:

let list = [4, 5, 6];

for (let i in list) {
  console.log(i); // "0", "1", "2",
}

for (let i of list) {
  console.log(i); // "4", "5", "6"
}

另一个区别是for..in可以操作任何对象;它提供了查看对象属性的一种方法。 但是for..of关注于迭代对象的值。内置对象MapSet已经实现了Symbol.iterator方法,让我们可以访问它们保存的值。

let pets = new Set(['Cat', 'Dog', 'Hamster']);
pets['species'] = 'mammals';

for (let pet in pets) {
  console.log(pet); // "species"
}

for (let pet of pets) {
  console.log(pet); // "Cat", "Dog", "Hamster"
}

代码生成

目标为 ES5 和 ES3

当生成目标为 ES5 或 ES3,迭代器只允许在Array类型上使用。 在非数组值上使用for..of语句会得到一个错误,就算这些非数组值已经实现了Symbol.iterator属性。

编译器会生成一个简单的for循环做为for..of循环,比如:

let numbers = [1, 2, 3];
for (let num of numbers) {
  console.log(num);
}

生成的代码为:

var numbers = [1, 2, 3];
for (var _i = 0; _i < numbers.length; _i++) {
  var num = numbers[_i];
  console.log(num);
}

目标为 ECMAScript 2015 或更高

当目标为兼容 ECMAScipt 2015 的引擎时,编译器会生成相应引擎的for..of内置迭代器实现方式。

JSX

介绍

JSX是一种嵌入式的类似 XML 的语法。 它可以被转换成合法的 JavaScript,尽管转换的语义是依据不同的实现而定的。 JSX 因React框架而流行,但也存在其它的实现。 TypeScript 支持内嵌,类型检查以及将 JSX 直接编译为 JavaScript。

基本用法

想要使用 JSX 必须做两件事:

  1. 给文件一个.tsx扩展名
  2. 启用jsx选项

TypeScript 具有三种 JSX 模式:preservereactreact-native。 这些模式只在代码生成阶段起作用 - 类型检查并不受影响。 在preserve模式下生成代码中会保留 JSX 以供后续的转换操作使用(比如:Babel)。 另外,输出文件会带有.jsx扩展名。 react模式会生成React.createElement,在使用前不需要再进行转换操作了,输出文件的扩展名为.jsreact-native相当于preserve,它也保留了所有的 JSX,但是输出文件的扩展名是.js

模式输入输出输出文件扩展名
preserve<div /><div />.jsx
react<div />React.createElement("div").js
react-native<div /><div />.js

你可以通过在命令行里使用--jsx标记或tsconfig.json里的选项来指定模式。

*注意:当输出目标为react JSX时,你可以使用--jsxFactory指定 JSX 工厂函数(默认值为React.createElement

as操作符

回想一下怎么写类型断言:

var foo = <foo>bar;

这里断言bar变量是foo类型的。 因为 TypeScript 也使用尖括号来表示类型断言,在结合 JSX 的语法后将带来解析上的困难。因此,TypeScript 在.tsx文件里禁用了使用尖括号的类型断言。

由于不能够在.tsx文件里使用上述语法,因此我们应该使用另一个类型断言操作符:as。 上面的例子可以很容易地使用as操作符改写:

var foo = bar as foo;

as操作符在.ts.tsx里都可用,并且与尖括号类型断言行为是等价的。

类型检查

为了理解 JSX 的类型检查,你必须首先理解固有元素与基于值的元素之间的区别。 假设有这样一个 JSX 表达式<expr />expr可能引用环境自带的某些东西(比如,在 DOM 环境里的divspan)或者是你自定义的组件。 这是非常重要的,原因有如下两点:

  1. 对于 React,固有元素会生成字符串(React.createElement("div")),然而由你自定义的组件却不会生成(React.createElement(MyComponent))。

  2. 传入 JSX 元素里的属性类型的查找方式不同。

    固有元素属性本身就支持,然而自定义的组件会自己去指定它们具有哪个属性。

TypeScript 使用与 React 相同的规范 来区别它们。 固有元素总是以一个小写字母开头,基于值的元素总是以一个大写字母开头。

固有元素

固有元素使用特殊的接口JSX.IntrinsicElements来查找。 默认地,如果这个接口没有指定,会全部通过,不对固有元素进行类型检查。 然而,如果这个接口存在,那么固有元素的名字需要在JSX.IntrinsicElements接口的属性里查找。 例如:

declare namespace JSX {
  interface IntrinsicElements {
    foo: any;
  }
}

<foo />; // 正确
<bar />; // 错误

在上例中,<foo />没有问题,但是<bar />会报错,因为它没在JSX.IntrinsicElements里指定。

注意:你也可以在JSX.IntrinsicElements上指定一个用来捕获所有字符串索引:

declare namespace JSX {
  interface IntrinsicElements {
    [elemName: string]: any;
  }
}

基于值的元素

基于值的元素会简单的在它所在的作用域里按标识符查找。

import MyComponent from './myComponent';

<MyComponent />; // 正确
<SomeOtherComponent />; // 错误

有两种方式可以定义基于值的元素:

  1. 函数组件 (FC)
  2. 类组件

由于这两种基于值的元素在 JSX 表达式里无法区分,因此 TypeScript 首先会尝试将表达式做为函数组件进行解析。如果解析成功,那么 TypeScript 就完成了表达式到其声明的解析操作。如果按照函数组件解析失败,那么 TypeScript 会继续尝试以类组件的形式进行解析。如果依旧失败,那么将输出一个错误。

函数组件

正如其名,组件被定义成 JavaScript 函数,它的第一个参数是props对象。 TypeScript 会强制它的返回值可以赋值给JSX.Element

interface FooProp {
  name: string;
  X: number;
  Y: number;
}

declare function AnotherComponent(prop: {name: string});
function ComponentFoo(prop: FooProp) {
  return <AnotherComponent name={prop.name} />;
}

const Button = (prop: {value: string}, context: { color: string }) => <button>

由于函数组件是简单的 JavaScript 函数,所以我们还可以利用函数重载。

interface ClickableProps {
  children: JSX.Element[] | JSX.Element
}

interface HomeProps extends ClickableProps {
  home: JSX.Element;
}

interface SideProps extends ClickableProps {
  side: JSX.Element | string;
}

function MainButton(prop: HomeProps): JSX.Element;
function MainButton(prop: SideProps): JSX.Element {
  ...
}

注意:函数组件之前叫做无状态函数组件(SFC)。由于在当前 React 版本里,函数组件不再被当作是无状态的,因此类型SFC和它的别名StatelessComponent被废弃了。

类组件

我们可以定义类组件的类型。 然而,我们首先最好弄懂两个新的术语:元素类的类型元素实例的类型

现在有<Expr />元素类的类型Expr的类型。 所以在上面的例子里,如果MyComponent是 ES6 的类,那么类类型就是类的构造函数和静态部分。 如果MyComponent是个工厂函数,类类型为这个函数。

一旦建立起了类类型,实例类型由类构造器或调用签名(如果存在的话)的返回值的联合构成。 再次说明,在 ES6 类的情况下,实例类型为这个类的实例的类型,并且如果是工厂函数,实例类型为这个函数返回值类型。

class MyComponent {
  render() {}
}

// 使用构造签名
var myComponent = new MyComponent();

// 元素类的类型 => MyComponent
// 元素实例的类型 => { render: () => void }

function MyFactoryFunction() {
  return {
    render: () => {},
  };
}

// 使用调用签名
var myComponent = MyFactoryFunction();

// 元素类的类型 => MyFactoryFunction
// 元素实例的类型 => { render: () => void }

元素的实例类型很有趣,因为它必须赋值给JSX.ElementClass或抛出一个错误。 默认的JSX.ElementClass{},但是它可以被扩展用来限制 JSX 的类型以符合相应的接口。

declare namespace JSX {
  interface ElementClass {
    render: any;
  }
}

class MyComponent {
  render() {}
}
function MyFactoryFunction() {
  return { render: () => {} };
}

<MyComponent />; // 正确
<MyFactoryFunction />; // 正确

class NotAValidComponent {}
function NotAValidFactoryFunction() {
  return {};
}

<NotAValidComponent />; // 错误
<NotAValidFactoryFunction />; // 错误

属性类型检查

属性类型检查的第一步是确定元素属性类型。 这在固有元素和基于值的元素之间稍有不同。

对于固有元素,这是JSX.IntrinsicElements属性的类型。

declare namespace JSX {
  interface IntrinsicElements {
    foo: { bar?: boolean };
  }
}

// `foo`的元素属性类型为`{bar?: boolean}`
<foo bar />;

对于基于值的元素,就稍微复杂些。 它取决于先前确定的在元素实例类型上的某个属性的类型。 至于该使用哪个属性来确定类型取决于JSX.ElementAttributesProperty。 它应该使用单一的属性来定义。 这个属性名之后会被使用。 TypeScript 2.8,如果未指定JSX.ElementAttributesProperty,那么将使用类元素构造函数或函数组件调用的第一个参数的类型。

declare namespace JSX {
  interface ElementAttributesProperty {
    props; // 指定用来使用的属性名
  }
}

class MyComponent {
  // 在元素实例类型上指定属性
  props: {
    foo?: string;
  };
}

// `MyComponent`的元素属性类型为`{foo?: string}`
<MyComponent foo="bar" />;

元素属性类型用于的 JSX 里进行属性的类型检查。 支持可选属性和必须属性。

declare namespace JSX {
  interface IntrinsicElements {
    foo: { requiredProp: string; optionalProp?: number };
  }
}

<foo requiredProp="bar" />; // 正确
<foo requiredProp="bar" optionalProp={0} />; // 正确
<foo />; // 错误, 缺少 requiredProp
<foo requiredProp={0} />; // 错误, requiredProp 应该是字符串
<foo requiredProp="bar" unknownProp />; // 错误, unknownProp 不存在
<foo requiredProp="bar" some-unknown-prop />; // 正确, `some-unknown-prop`不是个合法的标识符

注意:如果一个属性名不是个合法的 JS 标识符(像data-*属性),并且它没出现在元素属性类型里时不会当做一个错误。

另外,JSX 还会使用JSX.IntrinsicAttributes接口来指定额外的属性,这些额外的属性通常不会被组件的 props 或 arguments 使用 - 比如 React 里的key。还有,JSX.IntrinsicClassAttributes<T>泛型类型也可以用来为类组件(非函数组件)指定相同种类的额外属性。这里的泛型参数表示类实例类型。在 React 里,它用来允许Ref<T>类型上的ref属性。通常来讲,这些接口上的所有属性都是可选的,除非你想要用户在每个 JSX 标签上都提供一些属性。

延展操作符也可以使用:

var props = { requiredProp: 'bar' };
<foo {...props} />; // 正确

var badProps = {};
<foo {...badProps} />; // 错误

子孙类型检查

从 TypeScript 2.3 开始,我们引入了children类型检查。children元素属性(attribute)类型的一个特殊属性(property),子JSXExpression将会被插入到属性里。 与使用JSX.ElementAttributesProperty来决定props名类似,我们可以利用JSX.ElementChildrenAttribute来决定children名。 JSX.ElementChildrenAttribute应该被声明在单一的属性(property)里。

declare namespace JSX {
  interface ElementChildrenAttribute {
    children: {}; // specify children name to use
  }
}

如不特殊指定子孙的类型,我们将使用React typings里的默认类型。

<div>
  <h1>Hello</h1>
</div>;

<div>
  <h1>Hello</h1>
  World
</div>;

const CustomComp = (props) => <div>{props.children}</div>
<CustomComp>
  <div>Hello World</div>
  {"This is just a JS expression..." + 1000}
</CustomComp>
interface PropsType {
  children: JSX.Element
  name: string
}

class Component extends React.Component<PropsType, {}> {
  render() {
    return (
      <h2>
        {this.props.children}
      </h2>
    )
  }
}

// OK
<Component name="foo">
  <h1>Hello World</h1>
</Component>

// Error: children is of type JSX.Element not array of JSX.Element
<Component name="bar">
  <h1>Hello World</h1>
  <h2>Hello World</h2>
</Component>

// Error: children is of type JSX.Element not array of JSX.Element or string.
<Component name="baz">
  <h1>Hello</h1>
  World
</Component>

JSX 结果类型

默认地 JSX 表达式结果的类型为any。 你可以自定义这个类型,通过指定JSX.Element接口。 然而,不能够从接口里检索元素,属性或 JSX 的子元素的类型信息。 它是一个黑盒。

嵌入的表达式

JSX 允许你使用{ }标签来内嵌表达式。

var a = (
  <div>
    {['foo', 'bar'].map(i => (
      <span>{i / 2}</span>
    ))}
  </div>
);

上面的代码产生一个错误,因为你不能用数字来除以一个字符串。 输出如下,若你使用了preserve选项:

var a = (
  <div>
    {['foo', 'bar'].map(function (i) {
      return <span>{i / 2}</span>;
    })}
  </div>
);

React 整合

要想一起使用 JSX 和 React,你应该使用React 类型定义。 这些类型声明定义了JSX合适命名空间来使用 React。

/// <reference path="react.d.ts" />

interface Props {
  foo: string;
}

class MyComponent extends React.Component<Props, {}> {
  render() {
    return <span>{this.props.foo}</span>;
  }
}

<MyComponent foo="bar" />; // 正确
<MyComponent foo={0} />; // 错误

工厂函数

jsx: react编译选项使用的工厂函数是可以配置的。可以使用jsxFactory命令行选项,或内联的@jsx注释指令在每个文件上设置。比如,给createElement设置jsxFactory<div />会使用createElement("div")来生成,而不是React.createElement("div")

注释指令可以像下面这样使用(在 TypeScript 2.8 里):

import preact = require('preact');
/* @jsx preact.h */
const x = <div />;

生成:

const preact = require('preact');
const x = preact.h('div', null);

工厂函数的选择同样会影响JSX命名空间的查找(类型检查)。如果工厂函数使用React.createElement定义(默认),编译器会先检查React.JSX,之后才检查全局的JSX。如果工厂函数定义为h,那么在检查全局的JSX之前先检查h.JSX

混入

Table of contents

介绍

混入示例

理解示例

介绍

↥ 回到顶端

除了传统的面向对象继承方式,还流行一种通过可重用组件创建类的方式,就是联合另一个简单类的代码。 你可能在 Scala 等语言里对 mixins 及其特性已经很熟悉了,但它在 JavaScript 中也是很流行的。

混入示例

↥ 回到顶端

下面的代码演示了如何在 TypeScript 里使用混入。 后面我们还会解释这段代码是怎么工作的。

// Disposable Mixin
class Disposable {
  isDisposed: boolean;
  dispose() {
    this.isDisposed = true;
  }
}

// Activatable Mixin
class Activatable {
  isActive: boolean;
  activate() {
    this.isActive = true;
  }
  deactivate() {
    this.isActive = false;
  }
}

class SmartObject {
  constructor() {
    setInterval(
      () => console.log(this.isActive + ' : ' + this.isDisposed),
      500
    );
  }

  interact() {
    this.activate();
  }
}

interface SmartObject extends Disposable, Activatable {}
applyMixins(SmartObject, [Disposable, Activatable]);

let smartObj = new SmartObject();
setTimeout(() => smartObj.interact(), 1000);

////////////////////////////////////////
// In your runtime library somewhere
////////////////////////////////////////

function applyMixins(derivedCtor: any, baseCtors: any[]) {
  baseCtors.forEach(baseCtor => {
    Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
      Object.defineProperty(
        derivedCtor.prototype,
        name,
        Object.getOwnPropertyDescriptor(baseCtor.prototype, name)
      );
    });
  });
}

理解示例

↥ 回到顶端

代码里首先定义了两个类,它们将做为 mixins。 可以看到每个类都只定义了一个特定的行为或功能。 稍后我们使用它们来创建一个新类,同时具有这两种功能。

// Disposable Mixin
class Disposable {
  isDisposed: boolean;
  dispose() {
    this.isDisposed = true;
  }
}

// Activatable Mixin
class Activatable {
  isActive: boolean;
  activate() {
    this.isActive = true;
  }
  deactivate() {
    this.isActive = false;
  }
}

下面创建一个类,结合了这两个 mixins。 下面来看一下具体是怎么操作的:

class SmartObject {
    ...
}

interface SmartObject extends Disposable, Activatable {}

首先注意到的是,我们没有在SmartObject类里面继承DisposableActivatable,而是在SmartObject接口里面继承的。由于声明合并的存在,SmartObject接口会被混入到SmartObject类里面。

它将类视为接口,且只会混入 Disposable 和 Activatable 背后的类型到 SmartObject 类型里,不会混入实现。也就是说,我们要在类里面去实现。 这正是我们想要在混入时避免的行为。

最后,我们将混入融入到了类的实现中去。

// Disposable
isDisposed: boolean = false;
dispose: () => void;
// Activatable
isActive: boolean = false;
activate: () => void;
deactivate: () => void;

最后,把 mixins 混入定义的类,完成全部实现部分。

applyMixins(SmartObject, [Disposable, Activatable]);

最后,创建这个帮助函数,帮我们做混入操作。 它会遍历 mixins 上的所有属性,并复制到目标上去,把之前的占位属性替换成真正的实现代码。

function applyMixins(derivedCtor: any, baseCtors: any[]) {
  baseCtors.forEach(baseCtor => {
    Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
      Object.defineProperty(
        derivedCtor.prototype,
        name,
        Object.getOwnPropertyDescriptor(baseCtor.prototype, name)
      );
    });
  });
}

模块

关于术语的一点说明: 请务必注意一点,TypeScript 1.5 里术语名已经发生了变化。 “内部模块”现在称做“命名空间”。 “外部模块”现在则简称为“模块”,这是为了与ECMAScript 2015里的术语保持一致,(也就是说 module X { 相当于现在推荐的写法 namespace X {)。

介绍

从 ECMAScript 2015 开始,JavaScript 引入了模块的概念。TypeScript 也沿用这个概念。

模块在其自身的作用域里执行,而不是在全局作用域里;这意味着定义在一个模块里的变量,函数,类等等在模块外部是不可见的,除非你明确地使用export形式之一导出它们。 相反,如果想使用其它模块导出的变量,函数,类,接口等的时候,你必须要导入它们,可以使用import形式之一。

模块是自声明的;两个模块之间的关系是通过在文件级别上使用 imports 和 exports 建立的。

模块使用模块加载器去导入其它的模块。 在运行时,模块加载器的作用是在执行此模块代码前去查找并执行这个模块的所有依赖。 众所周知的 JavaScript 模块加载器有:作用于CommonJS模块的 Node.js 加载器和在 Web 应用里作用于AMD模块的RequireJS加载器。

TypeScript 与 ECMAScript 2015 一样,任何包含顶级import或者export的文件都被当成一个模块。 相反地,如果一个文件不带有顶级的import或者export声明,那么它的内容被视为全局可见的(因此对模块也是可见的)。

导出

导出声明

任何声明(比如变量,函数,类,类型别名或接口)都能够通过添加export关键字来导出。

StringValidator.ts

export interface StringValidator {
  isAcceptable(s: string): boolean;
}

ZipCodeValidator.ts

import { StringValidator } from './StringValidator';

export const numberRegexp = /^[0-9]+$/;

export class ZipCodeValidator implements StringValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s);
  }
}

导出语句

导出语句很便利,因为我们可能需要对导出的部分重命名,所以上面的例子可以这样改写:

class ZipCodeValidator implements StringValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s);
  }
}
export { ZipCodeValidator };
export { ZipCodeValidator as mainValidator };

重新导出

我们经常会去扩展其它模块,并且只导出那个模块的部分内容。 重新导出功能并不会在当前模块导入那个模块或定义一个新的局部变量。

ParseIntBasedZipCodeValidator.ts

export class ParseIntBasedZipCodeValidator {
  isAcceptable(s: string) {
    return s.length === 5 && parseInt(s).toString() === s;
  }
}

// 导出原先的验证器但做了重命名
export { ZipCodeValidator as RegExpBasedZipCodeValidator } from './ZipCodeValidator';

或者一个模块可以包裹多个模块,并把他们导出的内容联合在一起通过语法:export * from "module"

AllValidators.ts

export * from './StringValidator'; // exports 'StringValidator' interface
export * from './ZipCodeValidator'; // exports 'ZipCodeValidator' and const 'numberRegexp' class
export * from './ParseIntBasedZipCodeValidator'; //  exports the 'ParseIntBasedZipCodeValidator' class
// and re-exports 'RegExpBasedZipCodeValidator' as alias
// of the 'ZipCodeValidator' class from 'ZipCodeValidator.ts'

导入

模块的导入操作与导出一样简单。 可以使用以下import形式之一来导入其它模块中的导出内容。

导入一个模块中的某个导出内容

import { ZipCodeValidator } from './ZipCodeValidator';

let myValidator = new ZipCodeValidator();

可以对导入内容重命名

import { ZipCodeValidator as ZCV } from './ZipCodeValidator';
let myValidator = new ZCV();

将整个模块导入到一个变量,并通过它来访问模块的导出部分

import * as validator from './ZipCodeValidator';
let myValidator = new validator.ZipCodeValidator();

具有副作用的导入模块

尽管不推荐这么做,一些模块会设置一些全局状态供其它模块使用。 这些模块可能没有任何的导出或用户根本就不关注它的导出。 使用下面的方法来导入这类模块:

import './my-module.js';

默认导出

每个模块都可以有一个default导出。 默认导出使用default关键字标记;并且一个模块只能够有一个default导出。 需要使用一种特殊的导入形式来导入default导出。

default导出十分便利。 比如,像 jQuery 这样的类库可能有一个默认导出jQuery$,并且我们基本上也会使用同样的名字jQuery$导出 jQuery。

jQuery.d.ts

declare let $: jQuery;
export default $;

App.ts

import $ from 'jQuery';

$('button.continue').html('Next Step...');

类和函数声明可以直接被标记为默认导出。 标记为默认导出的类和函数的名字是可以省略的。

ZipCodeValidator.ts

export default class ZipCodeValidator {
  static numberRegexp = /^[0-9]+$/;
  isAcceptable(s: string) {
    return s.length === 5 && ZipCodeValidator.numberRegexp.test(s);
  }
}

Test.ts

import validator from './ZipCodeValidator';

let myValidator = new validator();

或者

StaticZipCodeValidator.ts

const numberRegexp = /^[0-9]+$/;

export default function (s: string) {
  return s.length === 5 && numberRegexp.test(s);
}

Test.ts

import validate from './StaticZipCodeValidator';

let strings = ['Hello', '98052', '101'];

// Use function validate
strings.forEach(s => {
  console.log(`"${s}" ${validate(s) ? 'matches' : 'does not match'}`);
});

default导出也可以是一个值

OneTwoThree.ts

export default '123';

Log.ts

import num from './OneTwoThree';

console.log(num); // "123"

export =import = require()

CommonJS 和 AMD 的环境里都有一个exports变量,这个变量包含了一个模块的所有导出内容。

CommonJS 和 AMD 的exports都可以被赋值为一个对象, 这种情况下其作用就类似于 es6 语法里的默认导出,即 export default语法了。虽然作用相似,但是 export default 语法并不能兼容 CommonJS 和 AMD 的exports

为了支持 CommonJS 和 AMD 的exports, TypeScript 提供了export =语法。

export =语法定义一个模块的导出对象。 这里的对象一词指的是类,接口,命名空间,函数或枚举。

若使用export =导出一个模块,则必须使用 TypeScript 的特定语法import module = require("module")来导入此模块。

ZipCodeValidator.ts

let numberRegexp = /^[0-9]+$/;
class ZipCodeValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s);
  }
}
export = ZipCodeValidator;

Test.ts

import zip = require('./ZipCodeValidator');

// Some samples to try
let strings = ['Hello', '98052', '101'];

// Validators to use
let validator = new zip();

// Show whether each string passed each validator
strings.forEach(s => {
  console.log(
    `"${s}" - ${validator.isAcceptable(s) ? 'matches' : 'does not match'}`
  );
});

生成模块代码

根据编译时指定的模块目标参数,编译器会生成相应的供 Node.js (CommonJS),Require.js (AMD),UMD, SystemJSECMAScript 2015 native modules (ES6)模块加载系统使用的代码。 想要了解生成代码中definerequireregister的意义,请参考相应模块加载器的文档。

下面的例子说明了导入导出语句里使用的名字是怎么转换为相应的模块加载器代码的。

SimpleModule.ts

import m = require('mod');
export let t = m.something + 1;

AMD / RequireJS SimpleModule.js

define(['require', 'exports', './mod'], function (require, exports, mod_1) {
  exports.t = mod_1.something + 1;
});

CommonJS / Node SimpleModule.js

let mod_1 = require('./mod');
exports.t = mod_1.something + 1;

UMD SimpleModule.js

(function (factory) {
  if (typeof module === 'object' && typeof module.exports === 'object') {
    let v = factory(require, exports);
    if (v !== undefined) module.exports = v;
  } else if (typeof define === 'function' && define.amd) {
    define(['require', 'exports', './mod'], factory);
  }
})(function (require, exports) {
  let mod_1 = require('./mod');
  exports.t = mod_1.something + 1;
});

System SimpleModule.js

System.register(['./mod'], function (exports_1) {
  let mod_1;
  let t;
  return {
    setters: [
      function (mod_1_1) {
        mod_1 = mod_1_1;
      },
    ],
    execute: function () {
      exports_1('t', (t = mod_1.something + 1));
    },
  };
});

Native ECMAScript 2015 modules SimpleModule.js

import { something } from './mod';
export let t = something + 1;

简单示例

下面我们来整理一下前面的验证器实现,每个模块只有一个命名的导出。

为了编译,我们必需要在命令行上指定一个模块目标。对于 Node.js 来说,使用--module commonjs; 对于 Require.js 来说,使用--module amd。比如:

tsc --module commonjs Test.ts

编译完成后,每个模块会生成一个单独的.js文件。 好比使用了 reference 标签,编译器会根据import语句编译相应的文件。

Validation.ts

export interface StringValidator {
  isAcceptable(s: string): boolean;
}

LettersOnlyValidator.ts

import { StringValidator } from './Validation';

const lettersRegexp = /^[A-Za-z]+$/;

export class LettersOnlyValidator implements StringValidator {
  isAcceptable(s: string) {
    return lettersRegexp.test(s);
  }
}

ZipCodeValidator.ts

import { StringValidator } from './Validation';

const numberRegexp = /^[0-9]+$/;

export class ZipCodeValidator implements StringValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s);
  }
}

Test.ts

import { StringValidator } from './Validation';
import { ZipCodeValidator } from './ZipCodeValidator';
import { LettersOnlyValidator } from './LettersOnlyValidator';

// Some samples to try
let strings = ['Hello', '98052', '101'];

// Validators to use
let validators: { [s: string]: StringValidator } = {};
validators['ZIP code'] = new ZipCodeValidator();
validators['Letters only'] = new LettersOnlyValidator();

// Show whether each string passed each validator
strings.forEach(s => {
  for (let name in validators) {
    console.log(
      `"${s}" - ${
        validators[name].isAcceptable(s) ? 'matches' : 'does not match'
      } ${name}`
    );
  }
});

可选的模块加载和其它高级加载场景

有时候,你只想在某种条件下才加载某个模块。 在 TypeScript 里,使用下面的方式来实现它和其它的高级加载场景,我们可以直接调用模块加载器并且可以保证类型完全。

编译器会检测是否每个模块都会在生成的 JavaScript 中用到。 如果一个模块标识符只在类型注解部分使用,并且完全没有在表达式中使用时,就不会生成require这个模块的代码。 省略掉没有用到的引用对性能提升是很有益的,并同时提供了选择性加载模块的能力。

这种模式的核心是import id = require("...")语句可以让我们访问模块导出的类型。 模块加载器会被动态调用(通过require),就像下面if代码块里那样。 它利用了省略引用的优化,所以模块只在被需要时加载。 为了让这个模块工作,一定要注意import定义的标识符只能在表示类型处使用(不能在会转换成 JavaScript 的地方)。

为了确保类型安全性,我们可以使用typeof关键字。 typeof关键字,当在表示类型的地方使用时,会得出一个类型值,这里就表示模块的类型。

示例:Node.js 里的动态模块加载

declare function require(moduleName: string): any;

import { ZipCodeValidator as Zip } from './ZipCodeValidator';

if (needZipValidation) {
  let ZipCodeValidator: typeof Zip = require('./ZipCodeValidator');
  let validator = new ZipCodeValidator();
  if (validator.isAcceptable('...')) {
    /* ... */
  }
}

示例:require.js 里的动态模块加载

declare function require(
  moduleNames: string[],
  onLoad: (...args: any[]) => void
): void;

import * as Zip from './ZipCodeValidator';

if (needZipValidation) {
  require(['./ZipCodeValidator'], (ZipCodeValidator: typeof Zip) => {
    let validator = new ZipCodeValidator.ZipCodeValidator();
    if (validator.isAcceptable('...')) {
      /* ... */
    }
  });
}

示例:System.js 里的动态模块加载

declare const System: any;

import { ZipCodeValidator as Zip } from './ZipCodeValidator';

if (needZipValidation) {
  System.import('./ZipCodeValidator').then((ZipCodeValidator: typeof Zip) => {
    var x = new ZipCodeValidator();
    if (x.isAcceptable('...')) {
      /* ... */
    }
  });
}

使用其它的 JavaScript 库

要想描述非 TypeScript 编写的类库的类型,我们需要声明类库所暴露出的 API。

我们叫它声明因为它不是“外部程序”的具体实现。 它们通常是在.d.ts文件里定义的。 如果你熟悉 C/C++,你可以把它们当做.h文件。 让我们看一些例子。

外部模块

在 Node.js 里大部分工作是通过加载一个或多个模块实现的。 我们可以使用顶级的export声明来为每个模块都定义一个.d.ts文件,但最好还是写在一个大的.d.ts文件里。 我们使用与构造一个外部命名空间相似的方法,但是这里使用module关键字并且把名字用引号括起来,方便之后import。 例如:

node.d.ts (simplified excerpt)

declare module 'url' {
  export interface Url {
    protocol?: string;
    hostname?: string;
    pathname?: string;
  }

  export function parse(
    urlStr: string,
    parseQueryString?,
    slashesDenoteHost?
  ): Url;
}

declare module 'path' {
  export function normalize(p: string): string;
  export function join(...paths: any[]): string;
  export let sep: string;
}

现在我们可以/// <reference> node.d.ts并且使用import url = require("url");import * as URL from "url"加载模块。

/// <reference path="node.d.ts"/>
import * as URL from 'url';
let myUrl = URL.parse('http://www.typescriptlang.org');

外部模块简写

假如你不想在使用一个新模块之前花时间去编写声明,你可以采用声明的简写形式以便能够快速使用它。

declarations.d.ts

declare module 'hot-new-module';

简写模块里所有导出的类型将是any

import x, { y } from 'hot-new-module';
x(y);

模块声明通配符

某些模块加载器如SystemJSAMD支持导入非 JavaScript 内容。 它们通常会使用一个前缀或后缀来表示特殊的加载语法。 模块声明通配符可以用来表示这些情况。

declare module '*!text' {
  const content: string;
  export default content;
}
// Some do it the other way around.
declare module 'json!*' {
  const value: any;
  export default value;
}

现在你可以就导入匹配"*!text""json!*"的内容了。

import fileContent from './xyz.txt!text';
import data from 'json!http://example.com/data.json';
console.log(data, fileContent);

UMD 模块

有些模块被设计成兼容多个模块加载器,或者不使用模块加载器(全局变量)。 它们以UMD模块为代表。 这些库可以通过导入的形式或全局变量的形式访问。 例如:

math-lib.d.ts

export function isPrime(x: number): boolean;
export as namespace mathLib;

之后,这个库可以在某个模块里通过导入来使用:

import { isPrime } from 'math-lib';
isPrime(2);
mathLib.isPrime(2); // ERROR: can't use the global definition from inside a module

它同样可以通过全局变量的形式使用,但只能在某个脚本里。 (脚本是指一个不带有导入或导出的文件。)

mathLib.isPrime(2);

创建模块结构指导

尽可能地在顶层导出

用户应该更容易地使用你模块导出的内容。 嵌套层次过多会变得难以处理,因此仔细考虑一下如何组织你的代码。

从你的模块中导出一个命名空间就是一个增加嵌套的例子。 虽然命名空间有时候有它们的用处,在使用模块的时候它们额外地增加了一层。 这对用户来说是很不便的并且通常是多余的。

导出类的静态方法也有同样的问题 - 这个类本身就增加了一层嵌套。 除非它能方便表述或便于清晰使用,否则请考虑直接导出一个辅助方法。

如果仅导出单个 classfunction,使用 export default

就像“在顶层上导出”帮助减少用户使用的难度,一个默认的导出也能起到这个效果。 如果一个模块就是为了导出特定的内容,那么你应该考虑使用一个默认导出。 这会令模块的导入和使用变得些许简单。 比如:

MyClass.ts

export default class SomeType {
  constructor() { ... }
}

MyFunc.ts

export default function getThing() {
  return 'thing';
}

Consumer.ts

import t from './MyClass';
import f from './MyFunc';
let x = new t();
console.log(f());

对用户来说这是最理想的。他们可以随意命名导入模块的类型(本例为t)并且不需要多余的(.)来找到相关对象。

如果要导出多个对象,把它们放在顶层里导出

MyThings.ts

export class SomeType {
  /* ... */
}
export function someFunc() {
  /* ... */
}

相反地,当导入的时候:

明确地列出导入的名字

Consumer.ts

import { SomeType, SomeFunc } from './MyThings';
let x = new SomeType();
let y = someFunc();

使用命名空间导入模式当你要导出大量内容的时候

MyLargeModule.ts

export class Dog { ... }
export class Cat { ... }
export class Tree { ... }
export class Flower { ... }

Consumer.ts

import * as myLargeModule from './MyLargeModule.ts';
let x = new myLargeModule.Dog();

使用重新导出进行扩展

你可能经常需要去扩展一个模块的功能。 JS 里常用的一个模式是 jQuery 那样去扩展原对象。 如我们之前提到的,模块不会像全局命名空间对象那样去合并。 推荐的方案是不要去改变原来的对象,而是导出一个新的实体来提供新的功能。

假设Calculator.ts模块里定义了一个简单的计算器实现。 这个模块同样提供了一个辅助函数来测试计算器的功能,通过传入一系列输入的字符串并在最后给出结果。

Calculator.ts

export class Calculator {
  private current = 0;
  private memory = 0;
  private operator: string;

  protected processDigit(digit: string, currentValue: number) {
    if (digit >= '0' && digit <= '9') {
      return currentValue * 10 + (digit.charCodeAt(0) - '0'.charCodeAt(0));
    }
  }

  protected processOperator(operator: string) {
    if (['+', '-', '*', '/'].indexOf(operator) >= 0) {
      return operator;
    }
  }

  protected evaluateOperator(
    operator: string,
    left: number,
    right: number
  ): number {
    switch (this.operator) {
      case '+':
        return left + right;
      case '-':
        return left - right;
      case '*':
        return left * right;
      case '/':
        return left / right;
    }
  }

  private evaluate() {
    if (this.operator) {
      this.memory = this.evaluateOperator(
        this.operator,
        this.memory,
        this.current
      );
    } else {
      this.memory = this.current;
    }
    this.current = 0;
  }

  public handleChar(char: string) {
    if (char === '=') {
      this.evaluate();
      return;
    } else {
      let value = this.processDigit(char, this.current);
      if (value !== undefined) {
        this.current = value;
        return;
      } else {
        let value = this.processOperator(char);
        if (value !== undefined) {
          this.evaluate();
          this.operator = value;
          return;
        }
      }
    }
    throw new Error(`Unsupported input: '${char}'`);
  }

  public getResult() {
    return this.memory;
  }
}

export function test(c: Calculator, input: string) {
  for (let i = 0; i < input.length; i++) {
    c.handleChar(input[i]);
  }

  console.log(`result of '${input}' is '${c.getResult()}'`);
}

下面使用导出的test函数来测试计算器。

TestCalculator.ts

import { Calculator, test } from './Calculator';

let c = new Calculator();
test(c, '1+2*33/11='); // prints 9

现在扩展它,添加支持输入其它进制(十进制以外),让我们来创建ProgrammerCalculator.ts

ProgrammerCalculator.ts

import { Calculator } from './Calculator';

class ProgrammerCalculator extends Calculator {
  static digits = [
    '0',
    '1',
    '2',
    '3',
    '4',
    '5',
    '6',
    '7',
    '8',
    '9',
    'A',
    'B',
    'C',
    'D',
    'E',
    'F',
  ];

  constructor(public base: number) {
    super();
    const maxBase = ProgrammerCalculator.digits.length;
    if (base <= 0 || base > maxBase) {
      throw new Error(`base has to be within 0 to ${maxBase} inclusive.`);
    }
  }

  protected processDigit(digit: string, currentValue: number) {
    if (ProgrammerCalculator.digits.indexOf(digit) >= 0) {
      return (
        currentValue * this.base + ProgrammerCalculator.digits.indexOf(digit)
      );
    }
  }
}

// Export the new extended calculator as Calculator
export { ProgrammerCalculator as Calculator };

// Also, export the helper function
export { test } from './Calculator';

新的ProgrammerCalculator模块导出的 API 与原先的Calculator模块很相似,但却没有改变原模块里的对象。 下面是测试 ProgrammerCalculator 类的代码:

TestProgrammerCalculator.ts

import { Calculator, test } from './ProgrammerCalculator';

let c = new Calculator(2);
test(c, '001+010='); // prints 3

模块里不要使用命名空间

当初次进入基于模块的开发模式时,可能总会控制不住要将导出包裹在一个命名空间里。 模块具有其自己的作用域,并且只有导出的声明才会在模块外部可见。 记住这点,命名空间在使用模块时几乎没什么价值。

在组织方面,命名空间对于在全局作用域内对逻辑上相关的对象和类型进行分组是很便利的。 例如,在 C#里,你会从System.Collections里找到所有集合的类型。 通过将类型有层次地组织在命名空间里,可以方便用户找到与使用那些类型。 然而,模块本身已经存在于文件系统之中,这是必须的。 我们必须通过路径和文件名找到它们,这已经提供了一种逻辑上的组织形式。 我们可以创建/collections/generic/文件夹,把相应模块放在这里面。

命名空间对解决全局作用域里命名冲突来说是很重要的。 比如,你可以有一个My.Application.Customer.AddFormMy.Application.Order.AddForm -- 两个类型的名字相同,但命名空间不同。 然而,这对于模块来说却不是一个问题。 在一个模块里,没有理由两个对象拥有同一个名字。 从模块的使用角度来说,使用者会挑出他们用来引用模块的名字,所以也没有理由发生重名的情况。

更多关于模块和命名空间的资料查看命名空间和模块

危险信号

以下均为模块结构上的危险信号。重新检查以确保你没有在对模块使用命名空间:

  • 文件的顶层声明是export namespace Foo { ... } (删除Foo并把所有内容向上层移动一层)©
  • 多个文件的顶层具有同样的export namespace Foo { (不要以为这些会合并到一个Foo中!)

模块解析

这节假设你已经了解了模块的一些基本知识 请阅读模块文档了解更多信息。

模块解析是指编译器在查找导入模块内容时所遵循的流程。 假设有一个导入语句import { a } from "moduleA"; 为了去检查任何对a的使用,编译器需要准确的知道它表示什么,并且需要检查它的定义moduleA

这时候,编译器会有个疑问“moduleA的结构是怎样的?” 这听上去很简单,但moduleA可能在你写的某个.ts/.tsx文件里或者在你的代码所依赖的.d.ts里。

首先,编译器会尝试定位表示导入模块的文件。 编译器会遵循以下二种策略之一:ClassicNode。 这些策略会告诉编译器到哪里去查找moduleA

如果上面的解析失败了并且模块名是非相对的(且是在"moduleA"的情况下),编译器会尝试定位一个外部模块声明。 我们接下来会讲到非相对导入。

最后,如果编译器还是不能解析这个模块,它会记录一个错误。 在这种情况下,错误可能为error TS2307: Cannot find module 'moduleA'.

相对 vs. 非相对模块导入

根据模块引用是相对的还是非相对的,模块导入会以不同的方式解析。

相对导入是以/./../开头的。 下面是一些例子:

  • import Entry from "./components/Entry";
  • import { DefaultHeaders } from "../constants/http";
  • import "/mod";

所有其它形式的导入被当作非相对的。 下面是一些例子:

  • import * as $ from "jQuery";
  • import { Component } from "@angular/core";

相对导入在解析时是相对于导入它的文件,并且不能解析为一个外部模块声明。 你应该为你自己写的模块使用相对导入,这样能确保它们在运行时的相对位置。

非相对模块的导入可以相对于baseUrl或通过下文会讲到的路径映射来进行解析。 它们还可以被解析成外部模块声明。 使用非相对路径来导入你的外部依赖。

模块解析策略

共有两种可用的模块解析策略:NodeClassic。 你可以使用--moduleResolution标记来指定使用哪种模块解析策略。 若未指定,那么在使用了--module AMD | System | ES2015时的默认值为Classic,其它情况时则为Node

Classic

这种策略在以前是 TypeScript 默认的解析策略。 现在,它存在的理由主要是为了向后兼容。

相对导入的模块是相对于导入它的文件进行解析的。 因此/root/src/folder/A.ts文件里的import { b } from "./moduleB"会使用下面的查找流程:

  1. /root/src/folder/moduleB.ts
  2. /root/src/folder/moduleB.d.ts

对于非相对模块的导入,编译器则会从包含导入文件的目录开始依次向上级目录遍历,尝试定位匹配的声明文件。

比如:

有一个对moduleB的非相对导入import { b } from "moduleB",它是在/root/src/folder/A.ts文件里,会以如下的方式来定位"moduleB"

  1. /root/src/folder/moduleB.ts
  2. /root/src/folder/moduleB.d.ts
  3. /root/src/moduleB.ts
  4. /root/src/moduleB.d.ts
  5. /root/moduleB.ts
  6. /root/moduleB.d.ts
  7. /moduleB.ts
  8. /moduleB.d.ts

Node

这个解析策略试图在运行时模仿Node.js模块解析机制。 完整的 Node.js 解析算法可以在Node.js module documentation找到。

Node.js 如何解析模块

为了理解 TypeScript 编译依照的解析步骤,先弄明白 Node.js 模块是非常重要的。 通常,在 Node.js 里导入是通过require函数调用进行的。 Node.js 会根据require的是相对路径还是非相对路径做出不同的行为。

相对路径很简单。 例如,假设有一个文件路径为/root/src/moduleA.js,包含了一个导入var x = require("./moduleB"); Node.js 以下面的顺序解析这个导入:

  1. 检查/root/src/moduleB.js文件是否存在。
  2. 检查/root/src/moduleB目录是否包含一个package.json文件,且package.json文件指定了一个"main"模块。 在我们的例子里,如果 Node.js 发现文件/root/src/moduleB/package.json包含了{ "main": "lib/mainModule.js" },那么 Node.js 会引用/root/src/moduleB/lib/mainModule.js
  3. 检查/root/src/moduleB目录是否包含一个index.js文件。 这个文件会被隐式地当作那个文件夹下的"main"模块。

你可以阅读 Node.js 文档了解更多详细信息:file modulesfolder modules

但是,非相对模块名的解析是个完全不同的过程。 Node 会在一个特殊的文件夹node_modules里查找你的模块。 node_modules可能与当前文件在同一级目录下,或者在上层目录里。 Node 会向上级目录遍历,查找每个node_modules直到它找到要加载的模块。

还是用上面例子,但假设/root/src/moduleA.js里使用的是非相对路径导入var x = require("moduleB");。 Node 则会以下面的顺序去解析moduleB,直到有一个匹配上。

  1. /root/src/node_modules/moduleB.js
  2. /root/src/node_modules/moduleB/package.json (如果指定了"main"属性)
  3. /root/src/node_modules/moduleB/index.js
  4. /root/node_modules/moduleB.js
  5. /root/node_modules/moduleB/package.json (如果指定了"main"属性)
  6. /root/node_modules/moduleB/index.js
  7. /node_modules/moduleB.js
  8. /node_modules/moduleB/package.json (如果指定了"main"属性)
  9. /node_modules/moduleB/index.js

注意 Node.js 在步骤(4)和(7)会向上跳一级目录。

你可以阅读 Node.js 文档了解更多详细信息:loading modules from node_modules

TypeScript 如何解析模块

TypeScript 是模仿 Node.js 运行时的解析策略来在编译阶段定位模块定义文件。 因此,TypeScript 在 Node 解析逻辑基础上增加了 TypeScript 源文件的扩展名(.ts.tsx.d.ts)。 同时,TypeScript 在package.json里使用字段"types"来表示类似"main"的意义 - 编译器会使用它来找到要使用的"main"定义文件。

比如,有一个导入语句import { b } from "./moduleB"/root/src/moduleA.ts里,会以下面的流程来定位"./moduleB"

  1. /root/src/moduleB.ts
  2. /root/src/moduleB.tsx
  3. /root/src/moduleB.d.ts
  4. /root/src/moduleB/package.json (如果指定了"types"属性)
  5. /root/src/moduleB/index.ts
  6. /root/src/moduleB/index.tsx
  7. /root/src/moduleB/index.d.ts

回想一下 Node.js 先查找moduleB.js文件,然后是合适的package.json,再之后是index.js

类似地,非相对的导入会遵循 Node.js 的解析逻辑,首先查找文件,然后是合适的文件夹。 因此/root/src/moduleA.ts文件里的import { b } from "moduleB"会以下面的查找顺序解析:

  1. /root/src/node_modules/moduleB.ts
  2. /root/src/node_modules/moduleB.tsx
  3. /root/src/node_modules/moduleB.d.ts
  4. /root/src/node_modules/moduleB/package.json (如果指定了"types"属性)
  5. /root/src/node_modules/@types/moduleB.d.ts
  6. /root/src/node_modules/moduleB/index.ts
  7. /root/src/node_modules/moduleB/index.tsx
  8. /root/src/node_modules/moduleB/index.d.ts
  9. /root/node_modules/moduleB.ts
  10. /root/node_modules/moduleB.tsx
  11. /root/node_modules/moduleB.d.ts
  12. /root/node_modules/moduleB/package.json (如果指定了"types"属性)
  13. /root/node_modules/@types/moduleB.d.ts
  14. /root/node_modules/moduleB/index.ts
  15. /root/node_modules/moduleB/index.tsx
  16. /root/node_modules/moduleB/index.d.ts
  17. /node_modules/moduleB.ts
  18. /node_modules/moduleB.tsx
  19. /node_modules/moduleB.d.ts
  20. /node_modules/moduleB/package.json (如果指定了"types"属性)
  21. /node_modules/@types/moduleB.d.ts
  22. /node_modules/moduleB/index.ts
  23. /node_modules/moduleB/index.tsx
  24. /node_modules/moduleB/index.d.ts

不要被这里步骤的数量吓到 - TypeScript 只是在步骤(9)和(17)向上跳了两次目录。 这并不比 Node.js 里的流程复杂。

附加的模块解析标记

有时工程源码结构与输出结构不同。 通常是要经过一系统的构建步骤最后生成输出。 它们包括将.ts编译成.js,将不同位置的依赖拷贝至一个输出位置。 最终结果就是运行时的模块名与包含它们声明的源文件里的模块名不同。 或者最终输出文件里的模块路径与编译时的源文件路径不同了。

TypeScript 编译器有一些额外的标记用来通知编译器在源码编译成最终输出的过程中都发生了哪个转换。

有一点要特别注意的是编译器不会进行这些转换操作; 它只是利用这些信息来指导模块的导入。

Base URL

在利用 AMD 模块加载器的应用里使用baseUrl是常见做法,它要求在运行时模块都被放到了一个文件夹里。 这些模块的源码可以在不同的目录下,但是构建脚本会将它们集中到一起。

设置baseUrl来告诉编译器到哪里去查找模块。 所有非相对模块导入都会被当做相对于baseUrl

baseUrl的值由以下两者之一决定:

  • 命令行中baseUrl的值(如果给定的路径是相对的,那么将相对于当前路径进行计算)
  • ‘tsconfig.json’里的baseUrl属性(如果给定的路径是相对的,那么将相对于‘tsconfig.json’路径进行计算)

注意相对模块的导入不会被设置的baseUrl所影响,因为它们总是相对于导入它们的文件。

阅读更多关于baseUrl的信息RequireJSSystemJS

路径映射

有时模块不是直接放在baseUrl下面。 比如,充分"jquery"模块地导入,在运行时可能被解释为"node_modules/jquery/dist/jquery.slim.min.js"。 加载器使用映射配置来将模块名映射到运行时的文件,查看RequireJs documentationSystemJS documentation

TypeScript 编译器通过使用tsconfig.json文件里的"paths"来支持这样的声明映射。 下面是一个如何指定jquery"paths"的例子。

{
  "compilerOptions": {
    "baseUrl": ".", // This must be specified if "paths" is.
    "paths": {
      "jquery": ["node_modules/jquery/dist/jquery"] // 此处映射是相对于"baseUrl"
    }
  }
}

请注意"paths"是相对于"baseUrl"进行解析。 如果"baseUrl"被设置成了除"."外的其它值,比如tsconfig.json所在的目录,那么映射必须要做相应的改变。 如果你在上例中设置了"baseUrl": "./src",那么 jquery 应该映射到"../node_modules/jquery/dist/jquery"

通过"paths"我们还可以指定复杂的映射,包括指定多个回退位置。 假设在一个工程配置里,有一些模块位于一处,而其它的则在另个的位置。 构建过程会将它们集中至一处。 工程结构可能如下:

projectRoot
├── folder1
│   ├── file1.ts (imports 'folder1/file2' and 'folder2/file3')
│   └── file2.ts
├── generated
│   ├── folder1
│   └── folder2
│       └── file3.ts
└── tsconfig.json

相应的tsconfig.json文件如下:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "*": [
        "*",
        "generated/*"
      ]
    }
  }
}

它告诉编译器所有匹配"*"(所有的值)模式的模块导入会在以下两个位置查找:

  1. "*": 表示名字不发生改变,所以映射为<moduleName> => <baseUrl>/<moduleName>
  2. "generated/*"表示模块名添加了“generated”前缀,所以映射为<moduleName> => <baseUrl>/generated/<moduleName>

按照这个逻辑,编译器将会如下尝试解析这两个导入:

  • 导入'folder1/file2'
    1. 匹配'*'模式且通配符捕获到整个名字。
    2. 尝试列表里的第一个替换:'*' -> folder1/file2
    3. 替换结果为非相对名 - 与baseUrl合并 -> projectRoot/folder1/file2.ts
    4. 文件存在。完成。
  • 导入'folder2/file3'
    1. 匹配'*'模式且通配符捕获到整个名字。
    2. 尝试列表里的第一个替换:'*' -> folder2/file3
    3. 替换结果为非相对名 - 与baseUrl合并 -> projectRoot/folder2/file3.ts
    4. 文件不存在,跳到第二个替换。
    5. 第二个替换:'generated/*' -> generated/folder2/file3
    6. 替换结果为非相对名 - 与baseUrl合并 -> projectRoot/generated/folder2/file3.ts
    7. 文件存在。完成。

利用rootDirs指定虚拟目录

有时多个目录下的工程源文件在编译时会进行合并放在某个输出目录下。 这可以看做一些源目录创建了一个“虚拟”目录。

利用rootDirs,可以告诉编译器生成这个虚拟目录的roots; 因此编译器可以在“虚拟”目录下解析相对模块导入,就好像它们被合并在了一起一样。

比如,有下面的工程结构:

 src
 └── views
     └── view1.ts (imports './template1')
     └── view2.ts

 generated
 └── templates
         └── views
             └── template1.ts (imports './view2')

src/views里的文件是用于控制 UI 的用户代码。 generated/templates是 UI 模版,在构建时通过模版生成器自动生成。 构建中的一步会将/src/views/generated/templates/views的输出拷贝到同一个目录下。 在运行时,视图可以假设它的模版与它同在一个目录下,因此可以使用相对导入"./template"

可以使用"rootDirs"来告诉编译器。 "rootDirs"指定了一个roots列表,列表里的内容会在运行时被合并。 因此,针对这个例子,tsconfig.json如下:

{
  "compilerOptions": {
    "rootDirs": [
      "src/views",
      "generated/templates/views"
    ]
  }
}

每当编译器在某一rootDirs的子目录下发现了相对模块导入,它就会尝试从每一个rootDirs中导入。

rootDirs的灵活性不仅仅局限于其指定了要在逻辑上合并的物理目录列表。它提供的数组可以包含任意数量的任何名字的目录,不论它们是否存在。这允许编译器以类型安全的方式处理复杂捆绑(bundles)和运行时的特性,比如条件引入和工程特定的加载器插件。

设想这样一个国际化的场景,构建工具自动插入特定的路径记号来生成针对不同区域的捆绑,比如将#{locale}做为相对模块路径./#{locale}/messages的一部分。在这个假定的设置下,工具会枚举支持的区域,将抽像的路径映射成./zh/messages./de/messages等。

假设每个模块都会导出一个字符串的数组。比如./zh/messages可能包含:

export default ['您好吗', '很高兴认识你'];

利用rootDirs我们可以让编译器了解这个映射关系,从而也允许编译器能够安全地解析./#{locale}/messages,就算这个目录永远都不存在。比如,使用下面的tsconfig.json

{
  "compilerOptions": {
    "rootDirs": [
      "src/zh",
      "src/de",
      "src/#{locale}"
    ]
  }
}

编译器现在可以将import messages from './#{locale}/messages'解析为import messages from './zh/messages'用做工具支持的目的,并允许在开发时不必了解区域信息。

跟踪模块解析

如之前讨论,编译器在解析模块时可能访问当前文件夹外的文件。 这会导致很难诊断模块为什么没有被解析,或解析到了错误的位置。 通过--traceResolution启用编译器的模块解析跟踪,它会告诉我们在模块解析过程中发生了什么。

假设我们有一个使用了typescript模块的简单应用。 app.ts里有一个这样的导入import * as ts from "typescript"

│   tsconfig.json
├───node_modules
│   └───typescript
│       └───lib
│               typescript.d.ts
└───src
        app.ts

使用--traceResolution调用编译器。

tsc --traceResolution

输出结果如下:

======== Resolving module 'typescript' from 'src/app.ts'. ========
Module resolution kind is not specified, using 'NodeJs'.
Loading module 'typescript' from 'node_modules' folder.
File 'src/node_modules/typescript.ts' does not exist.
File 'src/node_modules/typescript.tsx' does not exist.
File 'src/node_modules/typescript.d.ts' does not exist.
File 'src/node_modules/typescript/package.json' does not exist.
File 'node_modules/typescript.ts' does not exist.
File 'node_modules/typescript.tsx' does not exist.
File 'node_modules/typescript.d.ts' does not exist.
Found 'package.json' at 'node_modules/typescript/package.json'.
'package.json' has 'types' field './lib/typescript.d.ts' that references 'node_modules/typescript/lib/typescript.d.ts'.
File 'node_modules/typescript/lib/typescript.d.ts' exist - use it as a module resolution result.
======== Module name 'typescript' was successfully resolved to 'node_modules/typescript/lib/typescript.d.ts'. ========

需要留意的地方

  • 导入的名字及位置

    ======== Resolving module 'typescript' from 'src/app.ts'. ========

  • 编译器使用的策略

    Module resolution kind is not specified, using 'NodeJs'.

  • 从 npm 加载 types

    'package.json' has 'types' field './lib/typescript.d.ts' that references 'node_modules/typescript/lib/typescript.d.ts'.

  • 最终结果

    ======== Module name 'typescript' was successfully resolved to 'node_modules/typescript/lib/typescript.d.ts'. ========

使用--noResolve

正常来讲编译器会在开始编译之前解析模块导入。 每当它成功地解析了对一个文件import,这个文件被会加到一个文件列表里,以供编译器稍后处理。

--noResolve编译选项告诉编译器不要添加任何不是在命令行上传入的文件到编译列表。 编译器仍然会尝试解析模块,但是只要没有指定这个文件,那么它就不会被包含在内。

比如

app.ts

import * as A from 'moduleA'; // OK, moduleA passed on the command-line
import * as B from 'moduleB'; // Error TS2307: Cannot find module 'moduleB'.
tsc app.ts moduleA.ts --noResolve

使用--noResolve编译app.ts

  • 可能正确找到moduleA,因为它在命令行上指定了。
  • 找不到moduleB,因为没有在命令行上传递。

常见问题

为什么在exclude列表里的模块还会被编译器使用

tsconfig.json将文件夹转变一个“工程” 如果不指定任何“exclude”“files”,文件夹里的所有文件包括tsconfig.json和所有的子目录都会在编译列表里。 如果你想利用“exclude”排除某些文件,甚至你想指定所有要编译的文件列表,请使用“files”

有些是被tsconfig.json自动加入的。 它不会涉及到上面讨论的模块解析。 如果编译器识别出一个文件是模块导入目标,它就会加到编译列表里,不管它是否被排除了。

因此,要从编译列表中排除一个文件,你需要在排除它的同时,还要排除所有对它进行import或使用了/// <reference path="..." />指令的文件。

命名空间

关于术语的一点说明: 请务必注意一点,TypeScript 1.5 里术语名已经发生了变化。 “内部模块”现在称做“命名空间”。 “外部模块”现在则简称为“模块”,这是为了与ECMAScript 2015里的术语保持一致,(也就是说 module X { 相当于现在推荐的写法 namespace X {)。

介绍

这篇文章描述了如何在 TypeScript 里使用命名空间(之前叫做“内部模块”)来组织你的代码。 就像我们在术语说明里提到的那样,“内部模块”现在叫做“命名空间”。 另外,任何使用module关键字来声明一个内部模块的地方都应该使用namespace关键字来替换。 这就避免了让新的使用者被相似的名称所迷惑。

第一步

我们先来写一段程序并将在整篇文章中都使用这个例子。 我们定义几个简单的字符串验证器,假设你会使用它们来验证表单里的用户输入或验证外部数据。

所有的验证器都放在一个文件里

interface StringValidator {
  isAcceptable(s: string): boolean;
}

let lettersRegexp = /^[A-Za-z]+$/;
let numberRegexp = /^[0-9]+$/;

class LettersOnlyValidator implements StringValidator {
  isAcceptable(s: string) {
    return lettersRegexp.test(s);
  }
}

class ZipCodeValidator implements StringValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s);
  }
}

// Some samples to try
let strings = ['Hello', '98052', '101'];

// Validators to use
let validators: { [s: string]: StringValidator } = {};
validators['ZIP code'] = new ZipCodeValidator();
validators['Letters only'] = new LettersOnlyValidator();

// Show whether each string passed each validator
for (let s of strings) {
  for (let name in validators) {
    let isMatch = validators[name].isAcceptable(s);
    console.log(`'${s}' ${isMatch ? 'matches' : 'does not match'} '${name}'.`);
  }
}

命名空间

随着更多验证器的加入,我们需要一种手段来组织代码,以便于在记录它们类型的同时还不用担心与其它对象产生命名冲突。 因此,我们把验证器包裹到一个命名空间内,而不是把它们放在全局命名空间下。

下面的例子里,把所有与验证器相关的类型都放到一个叫做Validation的命名空间里。 因为我们想让这些接口和类在命名空间之外也是可访问的,所以需要使用export。 相反的,变量lettersRegexpnumberRegexp是实现的细节,不需要导出,因此它们在命名空间外是不能访问的。 在文件末尾的测试代码里,由于是在命名空间之外访问,因此需要限定类型的名称,比如Validation.LettersOnlyValidator

使用命名空间的验证器

namespace Validation {
  export interface StringValidator {
    isAcceptable(s: string): boolean;
  }

  const lettersRegexp = /^[A-Za-z]+$/;
  const numberRegexp = /^[0-9]+$/;

  export class LettersOnlyValidator implements StringValidator {
    isAcceptable(s: string) {
      return lettersRegexp.test(s);
    }
  }

  export class ZipCodeValidator implements StringValidator {
    isAcceptable(s: string) {
      return s.length === 5 && numberRegexp.test(s);
    }
  }
}

// Some samples to try
let strings = ['Hello', '98052', '101'];

// Validators to use
let validators: { [s: string]: Validation.StringValidator } = {};
validators['ZIP code'] = new Validation.ZipCodeValidator();
validators['Letters only'] = new Validation.LettersOnlyValidator();

// Show whether each string passed each validator
for (let s of strings) {
  for (let name in validators) {
    console.log(
      `"${s}" - ${
        validators[name].isAcceptable(s) ? 'matches' : 'does not match'
      } ${name}`
    );
  }
}

分离到多文件

当应用变得越来越大时,我们需要将代码分离到不同的文件中以便于维护。

多文件中的命名空间

现在,我们把Validation命名空间分割成多个文件。 尽管是不同的文件,它们仍是同一个命名空间,并且在使用的时候就如同它们在一个文件中定义的一样。 因为不同文件之间存在依赖关系,所以我们加入了引用标签来告诉编译器文件之间的关联。 我们的测试代码保持不变。

Validation.ts

namespace Validation {
  export interface StringValidator {
    isAcceptable(s: string): boolean;
  }
}

LettersOnlyValidator.ts

/// <reference path="Validation.ts" />
namespace Validation {
  const lettersRegexp = /^[A-Za-z]+$/;
  export class LettersOnlyValidator implements StringValidator {
    isAcceptable(s: string) {
      return lettersRegexp.test(s);
    }
  }
}

ZipCodeValidator.ts

/// <reference path="Validation.ts" />
namespace Validation {
  const numberRegexp = /^[0-9]+$/;
  export class ZipCodeValidator implements StringValidator {
    isAcceptable(s: string) {
      return s.length === 5 && numberRegexp.test(s);
    }
  }
}

Test.ts

/// <reference path="Validation.ts" />
/// <reference path="LettersOnlyValidator.ts" />
/// <reference path="ZipCodeValidator.ts" />

// Some samples to try
let strings = ['Hello', '98052', '101'];

// Validators to use
let validators: { [s: string]: Validation.StringValidator } = {};
validators['ZIP code'] = new Validation.ZipCodeValidator();
validators['Letters only'] = new Validation.LettersOnlyValidator();

// Show whether each string passed each validator
for (let s of strings) {
  for (let name in validators) {
    console.log(
      `"${s}" - ${
        validators[name].isAcceptable(s) ? 'matches' : 'does not match'
      } ${name}`
    );
  }
}

当涉及到多文件时,我们必须确保所有编译后的代码都被加载了。 我们有两种方式。

第一种方式,把所有的输入文件编译为一个输出文件,需要使用--outFile标记:

tsc --outFile sample.js Test.ts

编译器会根据源码里的引用标签自动地对输出进行排序。你也可以单独地指定每个文件。

tsc --outFile sample.js Validation.ts LettersOnlyValidator.ts ZipCodeValidator.ts Test.ts

第二种方式,我们可以编译每一个文件(默认方式),那么每个源文件都会对应生成一个 JavaScript 文件。 然后,在页面上通过<script>标签把所有生成的 JavaScript 文件按正确的顺序引进来,比如:

MyTestPage.html (excerpt)

    <script src="Validation.js" type="text/javascript" />
    <script src="LettersOnlyValidator.js" type="text/javascript" />
    <script src="ZipCodeValidator.js" type="text/javascript" />
    <script src="Test.js" type="text/javascript" />

别名

另一种简化命名空间操作的方法是使用import q = x.y.z给常用的对象起一个短的名字。 不要与用来加载模块的import x = require('name')语法弄混了,这里的语法是为指定的符号创建一个别名。 你可以用这种方法为任意标识符创建别名,也包括导入的模块中的对象。

namespace Shapes {
  export namespace Polygons {
    export class Triangle {}
    export class Square {}
  }
}

import polygons = Shapes.Polygons;
let sq = new polygons.Square(); // Same as "new Shapes.Polygons.Square()"

注意,我们并没有使用require关键字,而是直接使用导入符号的限定名赋值。 这与使用var相似,但它还适用于类型和导入的具有命名空间含义的符号。 重要的是,对于值来讲,import会生成与原始符号不同的引用,所以改变别名的var值并不会影响原始变量的值。

使用其它的 JavaScript 库

为了描述不是用 TypeScript 编写的类库的类型,我们需要声明类库导出的 API。 由于大部分程序库只提供少数的顶级对象,命名空间是用来表示它们的一个好办法。

我们称其为声明是因为它不是外部程序的具体实现。 我们通常在.d.ts里写这些声明。 如果你熟悉 C/C++,你可以把它们当做.h文件。 让我们看一些例子。

外部命名空间

流行的程序库 D3 在全局对象d3里定义它的功能。 因为这个库通过一个<script>标签加载(不是通过模块加载器),它的声明文件使用内部模块来定义它的类型。 为了让 TypeScript 编译器识别它的类型,我们使用外部命名空间声明。 比如,我们可以像下面这样写:

D3.d.ts (部分摘录)

declare namespace D3 {
  export interface Selectors {
    select: {
      (selector: string): Selection;
      (element: EventTarget): Selection;
    };
  }

  export interface Event {
    x: number;
    y: number;
  }

  export interface Base extends Selectors {
    event: Event;
  }
}

declare var d3: D3.Base;

命名空间和模块

关于术语的一点说明: 请务必注意一点,TypeScript 1.5 里术语名已经发生了变化。 “内部模块”现在称做“命名空间”。 “外部模块”现在则简称为“模块”,这是为了与ECMAScript 2015里的术语保持一致,(也就是说 module X { 相当于现在推荐的写法 namespace X {)。

介绍

这篇文章将概括介绍在 TypeScript 里使用模块与命名空间来组织代码的方法。 我们也会谈及命名空间和模块的高级使用场景,和在使用它们的过程中常见的陷阱。

查看模块章节了解关于模块的更多信息。 查看命名空间章节了解关于命名空间的更多信息。

使用命名空间

命名空间是位于全局命名空间下的一个普通的带有名字的 JavaScript 对象。 这令命名空间十分容易使用。 它们可以在多文件中同时使用,并通过--outFile结合在一起。 命名空间是帮你组织 Web 应用不错的方式,你可以把所有依赖都放在 HTML 页面的<script>标签里。

但就像其它的全局命名空间污染一样,它很难去识别组件之间的依赖关系,尤其是在大型的应用中。

使用模块

像命名空间一样,模块可以包含代码和声明。 不同的是模块可以声明它的依赖。

模块会把依赖添加到模块加载器上(例如 CommonJs / Require.js)。 对于小型的 JS 应用来说可能没必要,但是对于大型应用,这一点点的花费会带来长久的模块化和可维护性上的便利。 模块也提供了更好的代码重用,更强的封闭性以及更好的使用工具进行优化。

对于 Node.js 应用来说,模块是默认并推荐的组织代码的方式。

从 ECMAScript 2015 开始,模块成为了语言内置的部分,应该会被所有正常的解释引擎所支持。 因此,对于新项目来说推荐使用模块做为组织代码的方式。

命名空间和模块的陷阱

这部分我们会描述常见的命名空间和模块的使用陷阱和如何去避免它们。

对模块使用/// <reference>

一个常见的错误是使用/// <reference>引用模块文件,应该使用import。 要理解这之间的区别,我们首先应该弄清编译器是如何根据import路径(例如,import x from "...";import x = require("...")里面的...,等等)来定位模块的类型信息的。

编译器首先尝试去查找相应路径下的.ts.tsx再或者.d.ts。 如果这些文件都找不到,编译器会查找外部模块声明。 回想一下,它们是在.d.ts文件里声明的。

  • myModules.d.ts
// In a .d.ts file or .ts file that is not a module:
declare module 'SomeModule' {
  export function fn(): string;
}
  • myOtherModule.ts
/// <reference path="myModules.d.ts" />
import * as m from 'SomeModule';

这里的引用标签指定了外来模块的位置。 这就是一些 TypeScript 例子中引用node.d.ts的方法。

不必要的命名空间

如果你想把命名空间转换为模块,它可能会像下面这个文件:

  • shapes.ts
export namespace Shapes {
  export class Triangle {
    /* ... */
  }
  export class Square {
    /* ... */
  }
}

顶层的模块Shapes包裹了TriangleSquare。 对于使用它的人来说这是令人迷惑和讨厌的:

  • shapeConsumer.ts
import * as shapes from './shapes';
let t = new shapes.Shapes.Triangle(); // shapes.Shapes?

TypeScript 里模块的一个特点是不同的模块永远也不会在相同的作用域内使用相同的名字。 因为使用模块的人会为它们命名,所以完全没有必要把导出的符号包裹在一个命名空间里。

再次重申,不应该对模块使用命名空间,使用命名空间是为了提供逻辑分组和避免命名冲突。 模块文件本身已经是一个逻辑分组,并且它的名字是由导入这个模块的代码指定,所以没有必要为导出的对象增加额外的模块层。

下面是改进的例子:

  • shapes.ts
export class Triangle {
  /* ... */
}
export class Square {
  /* ... */
}
  • shapeConsumer.ts
import * as shapes from './shapes';
let t = new shapes.Triangle();

模块的取舍

就像每个 JS 文件对应一个模块一样,TypeScript 里模块文件与生成的 JS 文件也是一一对应的。 这会产生一种影响,根据你指定的目标模块系统的不同,你可能无法连接多个模块源文件。 例如当目标模块系统为commonjsumd时,无法使用outFile选项,但是在 TypeScript 1.8 以上的版本能够使用outFile当目标为amdsystem

Symbols

介绍

自 ECMAScript 2015 起,symbol成为了一种新的原生类型,就像numberstring一样。

symbol类型的值是通过Symbol构造函数创建的。

let sym1 = Symbol();

let sym2 = Symbol('key'); // 可选的字符串key

Symbols 是不可改变且唯一的。

let sym2 = Symbol('key');
let sym3 = Symbol('key');

sym2 === sym3; // false, symbols是唯一的

像字符串一样,symbols 也可以被用做对象属性的键。

const sym = Symbol();

let obj = {
  [sym]: 'value',
};

console.log(obj[sym]); // "value"

Symbols 也可以与计算出的属性名声明相结合来声明对象的属性和类成员。

const getClassNameSymbol = Symbol();

class C {
  [getClassNameSymbol]() {
    return 'C';
  }
}

let c = new C();
let className = c[getClassNameSymbol](); // "C"

众所周知的 Symbols

除了用户定义的 symbols,还有一些已经众所周知的内置 symbols。 内置 symbols 用来表示语言内部的行为。

以下为这些 symbols 的列表:

Symbol.hasInstance

方法,会被instanceof运算符调用。构造器对象用来识别一个对象是否是其实例。

Symbol.isConcatSpreadable

布尔值,表示当在一个对象上调用Array.prototype.concat时,这个对象的数组元素是否可展开。

Symbol.iterator

方法,被for-of语句调用。返回对象的默认迭代器。

Symbol.match

方法,被String.prototype.match调用。正则表达式用来匹配字符串。

Symbol.replace

方法,被String.prototype.replace调用。正则表达式用来替换字符串中匹配的子串。

Symbol.search

方法,被String.prototype.search调用。正则表达式返回被匹配部分在字符串中的索引。

Symbol.species

函数值,为一个构造函数。用来创建派生对象。

Symbol.split

方法,被String.prototype.split调用。正则表达式来用分割字符串。

Symbol.toPrimitive

方法,被ToPrimitive抽象操作调用。把对象转换为相应的原始值。

Symbol.toStringTag

方法,被内置方法Object.prototype.toString调用。返回创建对象时默认的字符串描述。

Symbol.unscopables

对象,它自己拥有的属性会被with作用域排除在外。

三斜线指令

三斜线指令是包含单个 XML 标签的单行注释。 注释的内容会做为编译器指令使用。

三斜线指令可放在包含它的文件的最顶端。 一个三斜线指令的前面只能出现单行或多行注释,这包括其它的三斜线指令。 如果它们出现在一个语句或声明之后,那么它们会被当做普通的单行注释,并且不具有特殊的涵义。

/// <reference path="..." />

/// <reference path="..." />指令是三斜线指令中最常见的一种。 它用于声明文件间的依赖

三斜线引用告诉编译器在编译过程中要引入的额外的文件。

当使用--out--outFile时,它也可以做为调整输出内容顺序的一种方法。 文件在输出文件内容中的位置与经过预处理后的输入顺序一致。

预处理输入文件

编译器会对输入文件进行预处理来解析所有三斜线引用指令。 在这个过程中,额外的文件会加到编译过程中。

这个过程会以一些根文件开始; 它们是在命令行中指定的文件或是在tsconfig.json中的"files"列表里的文件。 这些根文件按指定的顺序进行预处理。 在一个文件被加入列表前,它包含的所有三斜线引用都要被处理,还有它们包含的目标。 三斜线引用以它们在文件里出现的顺序,使用深度优先的方式解析。

一个三斜线引用路径是相对于包含它的文件的,如果不是根文件。

错误

引用不存在的文件会报错。 一个文件用三斜线指令引用自己会报错。

使用 --noResolve

如果指定了--noResolve编译选项,三斜线引用会被忽略;它们不会增加新文件,也不会改变给定文件的顺序。

/// <reference types="..." />

/// <reference path="..." />指令相似(用于声明依赖),/// <reference types="..." />指令声明了对某个包的依赖。

对这些包的名字的解析与在import语句里对模块名的解析类似。 可以简单地把三斜线类型引用指令当做import声明的包。

例如,把/// <reference types="node" />引入到声明文件,表明这个文件使用了@types/node/index.d.ts里面声明的名字; 并且,这个包需要在编译阶段与声明文件一起被包含进来。

仅当在你需要写一个d.ts文件时才使用这个指令。

对于那些在编译阶段生成的声明文件,编译器会自动地添加/// <reference types="..." />当且仅当结果文件中使用了引用的包里的声明时才会在生成的声明文件里添加/// <reference types="..." />语句。

若要在.ts文件里声明一个对@types包的依赖,使用--types命令行选项或在tsconfig.json里指定。 查看tsconfig.json里使用@typestypeRootstypes了解详情。

/// <reference no-default-lib="true"/>

这个指令把一个文件标记成默认库。 你会在lib.d.ts文件和它不同的变体的顶端看到这个注释。

这个指令告诉编译器在编译过程中不要包含这个默认库(比如,lib.d.ts)。 这与在命令行上使用--noLib相似。

还要注意,当传递了--skipDefaultLibCheck时,编译器只会忽略检查带有/// <reference no-default-lib="true"/>的文件。

/// <amd-module />

默认情况下生成的 AMD 模块都是匿名的。 但是,当一些工具需要处理生成的模块时会产生问题,比如r.js

amd-module指令允许给编译器传入一个可选的模块名:

amdModule.ts

///<amd-module name='NamedModule'/>
export class C {}

这会将NamedModule传入到 AMD define函数里:

amdModule.js

define('NamedModule', ['require', 'exports'], function (require, exports) {
  var C = (function () {
    function C() {}
    return C;
  })();
  exports.C = C;
});

/// <amd-dependency />

注意:这个指令被废弃了。使用import "moduleName";语句代替。

/// <amd-dependency path="x" />告诉编译器有一个非 TypeScript 模块依赖需要被注入,做为目标模块require调用的一部分。

amd-dependency指令也可以带一个可选的name属性;它允许我们为 amd-dependency 传入一个可选名字:

/// <amd-dependency path="legacy/moduleA" name="moduleA"/>
declare var moduleA: MyType;
moduleA.callStuff();

生成的 JavaScript 代码:

define(['require', 'exports', 'legacy/moduleA'], function (
  require,
  exports,
  moduleA
) {
  moduleA.callStuff();
});

类型兼容性

介绍

TypeScript 里的类型兼容性是基于结构子类型的。 结构类型是一种只使用其成员来描述类型的方式。 它正好与名义(nominal)类型形成对比。(译者注:在基于名义类型的类型系统中,数据类型的兼容性或等价性是通过明确的声明和/或类型的名称来决定的。这与结构性类型系统不同,它是基于类型的组成结构,且不要求明确地声明。) 看下面的例子:

interface Named {
  name: string;
}

class Person {
  name: string;
}

let p: Named;
// OK, because of structural typing
p = new Person();

在使用基于名义类型的语言,比如 C#或 Java 中,这段代码会报错,因为 Person 类没有明确说明其实现了 Named 接口。

TypeScript 的结构性子类型是根据 JavaScript 代码的典型写法来设计的。 因为 JavaScript 里广泛地使用匿名对象,例如函数表达式和对象字面量,所以使用结构类型系统来描述这些类型比使用名义类型系统更好。

关于可靠性的注意事项

TypeScript 的类型系统允许某些在编译阶段无法确认其安全性的操作。当一个类型系统具此属性时,被当做是“不可靠”的。TypeScript 允许这种不可靠行为的发生是经过仔细考虑的。通过这篇文章,我们会解释什么时候会发生这种情况和其有利的一面。

开始

TypeScript 结构化类型系统的基本规则是,如果x要兼容y,那么y至少具有与x相同的属性。比如:

interface Named {
  name: string;
}

let x: Named;
// y's inferred type is { name: string; location: string; }
let y = { name: 'Alice', location: 'Seattle' };
x = y;

这里要检查y是否能赋值给x,编译器检查x中的每个属性,看是否能在y中也找到对应属性。 在这个例子中,y必须包含名字是namestring类型成员。y满足条件,因此赋值正确。

检查函数参数时使用相同的规则:

function greet(n: Named) {
  console.log('Hello, ' + n.name);
}
greet(y); // OK

注意,y有个额外的location属性,但这不会引发错误。 只有目标类型(这里是Named)的成员会被一一检查是否兼容。

这个比较过程是递归进行的,检查每个成员及子成员。

比较两个函数

相对来讲,在比较原始类型和对象类型的时候是比较容易理解的,问题是如何判断两个函数是兼容的。 下面我们从两个简单的函数入手,它们仅是参数列表略有不同:

let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

y = x; // OK
x = y; // Error

要查看x是否能赋值给y,首先看它们的参数列表。 x的每个参数必须能在y里找到对应类型的参数。 注意的是参数的名字相同与否无所谓,只看它们的类型。 这里,x的每个参数在y中都能找到对应的参数,所以允许赋值。

第二个赋值错误,因为y有个必需的第二个参数,但是x并没有,所以不允许赋值。

你可能会疑惑为什么允许忽略参数,像例子y = x中那样。 原因是忽略额外的参数在 JavaScript 里是很常见的。 例如,Array#forEach给回调函数传 3 个参数:数组元素,索引和整个数组。 尽管如此,传入一个只使用第一个参数的回调函数也是很有用的:

let items = [1, 2, 3];

// Don't force these extra arguments
items.forEach((item, index, array) => console.log(item));

// Should be OK!
items.forEach(item => console.log(item));

下面来看看如何处理返回值类型,创建两个仅是返回值类型不同的函数:

let x = () => ({ name: 'Alice' });
let y = () => ({ name: 'Alice', location: 'Seattle' });

x = y; // OK
y = x; // Error, because x() lacks a location property

类型系统强制源函数的返回值类型必须是目标函数返回值类型的子类型。

函数参数双向协变

当比较函数参数类型时,只有当源函数参数能够赋值给目标函数或者反过来时才能赋值成功。 这是不稳定的,因为调用者可能传入了一个具有更精确类型信息的函数,但是调用这个传入的函数的时候却使用了不是那么精确的类型信息。 实际上,这极少会发生错误,并且能够实现很多 JavaScript 里的常见模式。例如:

enum EventType {
  Mouse,
  Keyboard,
}

interface Event {
  timestamp: number;
}
interface MouseEvent extends Event {
  x: number;
  y: number;
}
interface KeyEvent extends Event {
  keyCode: number;
}

function listenEvent(eventType: EventType, handler: (n: Event) => void) {
  /* ... */
}

// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + ',' + e.y));

// Undesirable alternatives in presence of soundness
listenEvent(EventType.Mouse, (e: Event) =>
  console.log((e as MouseEvent).x + ',' + (e as MouseEvent).y)
);
listenEvent(EventType.Mouse, ((e: MouseEvent) =>
  console.log(e.x + ',' + e.y)) as (e: Event) => void);

// Still disallowed (clear error). Type safety enforced for wholly incompatible types
listenEvent(EventType.Mouse, (e: number) => console.log(e));

你可以使用strictFunctionTypes编译选项,使 TypeScript 在这种情况下报错。

可选参数及剩余参数

比较函数兼容性的时候,可选参数与必须参数是可互换的。 源类型上有额外的可选参数不是错误,目标类型的可选参数在源类型里没有对应的参数也不是错误。

当一个函数有剩余参数时,它被当做无限个可选参数。

这对于类型系统来说是不稳定的,但从运行时的角度来看,可选参数一般来说是不强制的,因为对于大多数函数来说相当于传递了一些undefinded

有一个好的例子,常见的函数接收一个回调函数并用对于程序员来说是可预知的参数但对类型系统来说是不确定的参数来调用:

function invokeLater(args: any[], callback: (...args: any[]) => void) {
  /* ... Invoke callback with 'args' ... */
}

// Unsound - invokeLater "might" provide any number of arguments
invokeLater([1, 2], (x, y) => console.log(x + ', ' + y));

// Confusing (x and y are actually required) and undiscoverable
invokeLater([1, 2], (x?, y?) => console.log(x + ', ' + y));

函数重载

对于有重载的函数,源函数的每个重载都要在目标函数上找到对应的函数签名。 这确保了目标函数可以在所有源函数可调用的地方调用。

枚举

枚举类型与数字类型兼容,并且数字类型与枚举类型兼容。不同枚举类型之间是不兼容的。比如,

enum Status {
  Ready,
  Waiting,
}
enum Color {
  Red,
  Blue,
  Green,
}

let status = Status.Ready;
status = Color.Green; // Error

类与对象字面量和接口差不多,但有一点不同:类有静态部分和实例部分的类型。 比较两个类类型的对象时,只有实例的成员会被比较。 静态成员和构造函数不在比较的范围内。

class Animal {
  feet: number;
  constructor(name: string, numFeet: number) {}
}

class Size {
  feet: number;
  constructor(numFeet: number) {}
}

let a: Animal;
let s: Size;

a = s; // OK
s = a; // OK

类的私有成员和受保护成员

类的私有成员和受保护成员会影响兼容性。 当检查类实例的兼容时,如果目标类型包含一个私有成员,那么源类型必须包含来自同一个类的这个私有成员。 同样地,这条规则也适用于包含受保护成员实例的类型检查。 这允许子类赋值给父类,但是不能赋值给其它有同样类型的类。

泛型

因为 TypeScript 是结构性的类型系统,类型参数只影响使用其做为类型一部分的结果类型。比如,

interface Empty<T> {}
let x: Empty<number>;
let y: Empty<string>;

x = y; // OK, because y matches structure of x

上面代码里,xy是兼容的,因为它们的结构使用类型参数时并没有什么不同。 把这个例子改变一下,增加一个成员,就能看出是如何工作的了:

interface NotEmpty<T> {
  data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;

x = y; // Error, because x and y are not compatible

在这里,泛型类型在使用时就好比不是一个泛型类型。

对于没指定泛型类型的泛型参数时,会把所有泛型参数当成any比较。 然后用结果类型进行比较,就像上面第一个例子。

比如,

let identity = function <T>(x: T): T {
  // ...
};

let reverse = function <U>(y: U): U {
  // ...
};

identity = reverse; // OK, because (x: any) => any matches (y: any) => any

高级主题

子类型与赋值

目前为止,我们使用了“兼容性”,它在语言规范里没有定义。 在 TypeScript 里,有两种兼容性:子类型和赋值。 它们的不同点在于,赋值扩展了子类型兼容性,增加了一些规则,允许和any来回赋值,以及enum和对应数字值之间的来回赋值。

语言里的不同地方分别使用了它们之中的机制。 实际上,类型兼容性是由赋值兼容性来控制的,即使在implementsextends语句也不例外。

更多信息,请参阅TypeScript 语言规范.

类型推论

介绍

这节介绍 TypeScript 里的类型推论。即,类型是在哪里如何被推断的。

基础

TypeScript 里,在有些没有明确指出类型的地方,类型推论会帮助提供类型。如下面的例子

let x = 3;

变量x的类型被推断为数字。 这种推断发生在初始化变量和成员,设置默认参数值和决定函数返回值时。

大多数情况下,类型推论是直截了当地。 后面的小节,我们会浏览类型推论时的细微差别。

最佳通用类型

当需要从几个表达式中推断类型时候,会使用这些表达式的类型来推断出一个最合适的通用类型。例如,

let x = [0, 1, null];

为了推断x的类型,我们必须考虑所有元素的类型。 这里有两种选择:numbernull。 计算通用类型算法会考虑所有的候选类型,并给出一个兼容所有候选类型的类型。

由于最终的通用类型取自候选类型,有些时候候选类型共享相同的通用类型,但是却没有一个类型能做为所有候选类型的类型。例如:

let zoo = [new Rhino(), new Elephant(), new Snake()];

这里,我们想让 zoo 被推断为Animal[]类型,但是这个数组里没有对象是Animal类型的,因此不能推断出这个结果。 为了更正,当候选类型不能使用的时候我们需要明确的指出类型:

let zoo: Animal[] = [new Rhino(), new Elephant(), new Snake()];

如果没有找到最佳通用类型的话,类型推断的结果为联合数组类型,(Rhino | Elephant | Snake)[]

上下文归类

TypeScript 类型推论也可能按照相反的方向进行。 这被叫做“上下文归类”。按上下文归类会发生在表达式的类型与所处的位置相关时。比如:

window.onmousedown = function (mouseEvent) {
  console.log(mouseEvent.button); //<- OK
  console.log(mouseEvent.kangaroo); //<- Error!
};

在这个例子里,TypeScript 类型检查器会使用Window.onmousedown函数的类型来推断右边函数表达式的类型。 所以它能够推断出mouseEvent参数的类型中包含了button属性而不包含kangaroo属性。

TypeScript 还能够很好地推断出其它上下文中的类型。

window.onscroll = function (uiEvent) {
  console.log(uiEvent.button); //<- Error!
};

上面的函数被赋值给window.onscrollTypeScript能够知道uiEventUIEvent,而不是MouseEventUIEvent对象不包含button属性,因此 TypeScript 会报错。

如果这个函数不是在上下文归类的位置上,那么这个函数的参数类型将隐式的成为any类型,而且也不会报错(除非你开启了--noImplicitAny选项):

const handler = function (uiEvent) {
  console.log(uiEvent.button); //<- OK
};

我们也可以明确地为函数参数类型赋值来覆写上下文类型:

window.onscroll = function (uiEvent: any) {
  console.log(uiEvent.button); //<- Now, no error is given
};

但这段代码会打印undefined,因为uiEvent并不包含button属性。

上下文归类会在很多情况下使用到。 通常包含函数的参数,赋值表达式的右边,类型断言,对象成员和数组字面量和返回值语句。 上下文类型也会做为最佳通用类型的候选类型。比如:

function createZoo(): Animal[] {
  return [new Rhino(), new Elephant(), new Snake()];
}

这个例子里,最佳通用类型有 4 个候选者:AnimalRhinoElephantSnake。 当然,Animal会被做为最佳通用类型。

变量声明

变量声明

letconst是 JavaScript 里相对较新的变量声明方式。 像我们之前提到过的let在很多方面与var是相似的,但是可以帮助大家避免在 JavaScript 里常见一些问题。 const是对let的一个增强,它能阻止对一个变量再次赋值。

因为 TypeScript 是 JavaScript 的超集,所以它本身就支持letconst。 下面我们会详细说明这些新的声明方式以及为什么推荐使用它们来代替var

如果你之前使用 JavaScript 时没有特别在意,那么这节内容会唤起你的回忆。 如果你已经对var声明的怪异之处了如指掌,那么你可以轻松地略过这节。

var 声明

一直以来我们都是通过var关键字定义 JavaScript 变量。

var a = 10;

大家都能理解,这里定义了一个名为a值为10的变量。

我们也可以在函数内部定义变量:

function f() {
  var message = 'Hello, world!';

  return message;
}

并且我们也可以在其它函数内部访问相同的变量。

function f() {
  var a = 10;
  return function g() {
    var b = a + 1;
    return b;
  };
}

var g = f();
g(); // returns 11;

上面的例子里,g可以获取到f函数里定义的a变量。 每当g被调用时,它都可以访问到f里的a变量。 即使当gf已经执行完后才被调用,它仍然可以访问及修改a

function f() {
  var a = 1;

  a = 2;
  var b = g();
  a = 3;

  return b;

  function g() {
    return a;
  }
}

f(); // returns 2

作用域规则

对于熟悉其它语言的人来说,var声明有些奇怪的作用域规则。 看下面的例子:

function f(shouldInitialize: boolean) {
  if (shouldInitialize) {
    var x = 10;
  }

  return x;
}

f(true); // returns '10'
f(false); // returns 'undefined'

有些读者可能要多看几遍这个例子。 变量x是定义在*if语句里面*,但是我们却可以在语句的外面访问它。 这是因为var声明可以在包含它的函数,模块,命名空间或全局作用域内部任何位置被访问(我们后面会详细介绍),包含它的代码块对此没有什么影响。 有些人称此为*var作用域函数作用域*。 函数参数也使用函数作用域。

这些作用域规则可能会引发一些错误。 其中之一就是,多次声明同一个变量并不会报错:

function sumMatrix(matrix: number[][]) {
  var sum = 0;
  for (var i = 0; i < matrix.length; i++) {
    var currentRow = matrix[i];
    for (var i = 0; i < currentRow.length; i++) {
      sum += currentRow[i];
    }
  }

  return sum;
}

这里很容易看出一些问题,里层的for循环会覆盖变量i,因为所有i都引用相同的函数作用域内的变量。 有经验的开发者们很清楚,这些问题可能在代码审查时漏掉,引发无穷的麻烦。

捕获变量怪异之处

快速的猜一下下面的代码会返回什么:

for (var i = 0; i < 10; i++) {
  setTimeout(function () {
    console.log(i);
  }, 100 * i);
}

介绍一下,setTimeout会在若干毫秒的延时后执行一个函数(等待其它代码执行完毕)。

好吧,看一下结果:

10
10
10
10
10
10
10
10
10
10

很多 JavaScript 程序员对这种行为已经很熟悉了,但如果你很不解,你并不是一个人。 大多数人期望输出结果是这样:

0
1
2
3
4
5
6
7
8
9

还记得我们上面提到的捕获变量吗? 我们传给setTimeout的每一个函数表达式实际上都引用了相同作用域里的同一个i

让我们花点时间思考一下这是为什么。 setTimeout在若干毫秒后执行一个函数,并且是在for循环结束后。 for循环结束后,i的值为10。 所以当函数被调用的时候,它会打印出10

一个通常的解决方法是使用立即执行的函数表达式(IIFE)来捕获每次迭代时i的值:

for (var i = 0; i < 10; i++) {
  // capture the current state of 'i'
  // by invoking a function with its current value
  (function (i) {
    setTimeout(function () {
      console.log(i);
    }, 100 * i);
  })(i);
}

这种奇怪的形式我们已经司空见惯了。 参数i会覆盖for循环里的i,但是因为我们起了同样的名字,所以我们不用怎么改for循环体里的代码。

let 声明

现在你已经知道了var存在一些问题,这恰好说明了为什么用let语句来声明变量。 除了名字不同外,letvar的写法一致。

let hello = 'Hello!';

主要的区别不在语法上,而是语义,我们接下来会深入研究。

块作用域

当用let声明一个变量,它使用的是词法作用域块作用域。 不同于使用var声明的变量那样可以在包含它们的函数外访问,块作用域变量在包含它们的块或for循环之外是不能访问的。

function f(input: boolean) {
  let a = 100;

  if (input) {
    // Still okay to reference 'a'
    let b = a + 1;
    return b;
  }

  // Error: 'b' doesn't exist here
  return b;
}

这里我们定义了 2 个变量aba的作用域是f函数体内,而b的作用域是if语句块里。

catch语句里声明的变量也具有同样的作用域规则。

try {
  throw 'oh no!';
} catch (e) {
  console.log('Oh well.');
}

// Error: 'e' doesn't exist here
console.log(e);

拥有块级作用域的变量的另一个特点是,它们不能在被声明之前读或写。 虽然这些变量始终“存在”于它们的作用域里,但在直到声明它的代码之前的区域都属于暂时性死区。 它只是用来说明我们不能在let语句之前访问它们,幸运的是 TypeScript 可以告诉我们这些信息。

a++; // illegal to use 'a' before it's declared;
let a;

注意一点,我们仍然可以在一个拥有块作用域变量被声明前获取它。 只是我们不能在变量声明前去调用那个函数。 如果生成代码目标为 ES2015,现代的运行时会抛出一个错误;然而,现今 TypeScript 是不会报错的。

function foo() {
  // okay to capture 'a'
  return a;
}

// 不能在'a'被声明前调用'foo'
// 运行时应该抛出错误
foo();

let a;

关于暂时性死区的更多信息,查看这里Mozilla Developer Network.

重声明及屏蔽

我们提过使用var声明时,它不在乎你声明多少次;你只会得到 1 个。

function f(x) {
  var x;
  var x;

  if (true) {
    var x;
  }
}

在上面的例子里,所有x的声明实际上都引用一个相同x,并且这是完全有效的代码。 这经常会成为 bug 的来源。 好的是,let声明就不会这么宽松了。

let x = 10;
let x = 20; // 错误,不能在1个作用域里多次声明`x`

并不是要求两个均是块级作用域的声明 TypeScript 才会给出一个错误的警告。

function f(x) {
  let x = 100; // error: interferes with parameter declaration
}

function g() {
  let x = 100;
  var x = 100; // error: can't have both declarations of 'x'
}

并不是说块级作用域变量不能用函数作用域变量来声明。 而是块级作用域变量需要在明显不同的块里声明。

function f(condition, x) {
  if (condition) {
    let x = 100;
    return x;
  }

  return x;
}

f(false, 0); // returns 0
f(true, 0); // returns 100

在一个嵌套作用域里引入一个新名字的行为称做屏蔽。 它是一把双刃剑,它可能会不小心地引入新问题,同时也可能会解决一些错误。 例如,假设我们现在用let重写之前的sumMatrix函数。

function sumMatrix(matrix: number[][]) {
  let sum = 0;
  for (let i = 0; i < matrix.length; i++) {
    var currentRow = matrix[i];
    for (let i = 0; i < currentRow.length; i++) {
      sum += currentRow[i];
    }
  }

  return sum;
}

这个版本的循环能得到正确的结果,因为内层循环的i可以屏蔽掉外层循环的i

通常来讲应该避免使用屏蔽,因为我们需要写出清晰的代码。 同时也有些场景适合利用它,你需要好好打算一下。

块级作用域变量的获取

在我们最初谈及获取用var声明的变量时,我们简略地探究了一下在获取到了变量之后它的行为是怎样的。 直观地讲,每次进入一个作用域时,它创建了一个变量的环境。 就算作用域内代码已经执行完毕,这个环境与其捕获的变量依然存在。

function theCityThatAlwaysSleeps() {
  let getCity;

  if (true) {
    let city = 'Seattle';
    getCity = function () {
      return city;
    };
  }

  return getCity();
}

因为我们已经在city的环境里获取到了city,所以就算if语句执行结束后我们仍然可以访问它。

回想一下前面setTimeout的例子,我们最后需要使用立即执行的函数表达式来获取每次for循环迭代里的状态。 实际上,我们做的是为获取到的变量创建了一个新的变量环境。 这样做挺痛苦的,但是幸运的是,你不必在 TypeScript 里这样做了。

let声明出现在循环体里时拥有完全不同的行为。 不仅是在循环里引入了一个新的变量环境,而是针对每次迭代都会创建这样一个新作用域。 这就是我们在使用立即执行的函数表达式时做的事,所以在setTimeout例子里我们仅使用let声明就可以了。

for (let i = 0; i < 10; i++) {
  setTimeout(function () {
    console.log(i);
  }, 100 * i);
}

会输出与预料一致的结果:

0
1
2
3
4
5
6
7
8
9

const 声明

const 声明是声明变量的另一种方式。

const numLivesForCat = 9;

它们与let声明相似,但是就像它的名字所表达的,它们被赋值后不能再改变。 换句话说,它们拥有与let相同的作用域规则,但是不能对它们重新赋值。

这很好理解,它们引用的值是不可变的

const numLivesForCat = 9;
const kitty = {
  name: 'Aurora',
  numLives: numLivesForCat,
};

// Error
kitty = {
  name: 'Danielle',
  numLives: numLivesForCat,
};

// all "okay"
kitty.name = 'Rory';
kitty.name = 'Kitty';
kitty.name = 'Cat';
kitty.numLives--;

除非你使用特殊的方法去避免,实际上const变量的内部状态是可修改的。 幸运的是,TypeScript 允许你将对象的成员设置成只读的。 接口一章有详细说明。

let vs. const

现在我们有两种作用域相似的声明方式,我们自然会问到底应该使用哪个。 与大多数泛泛的问题一样,答案是:依情况而定。

使用最小特权原则,所有变量除了你计划去修改的都应该使用const。 基本原则就是如果一个变量不需要对它写入,那么其它使用这些代码的人也不能够写入它们,并且要思考为什么会需要对这些变量重新赋值。 使用const也可以让我们更容易的推测数据的流动。

跟据你的自己判断,如果合适的话,与团队成员商议一下。

这个手册大部分地方都使用了let声明。

解构

TypeScript 包含的另一个 ECMAScript 2015 特性就是解构。完整列表请参见 the article on the Mozilla Developer Network。 本章,我们将给出一个简短的概述。

解构数组

最简单的解构莫过于数组的解构赋值了:

let input = [1, 2];
let [first, second] = input;
console.log(first); // outputs 1
console.log(second); // outputs 2

这创建了 2 个命名变量 firstsecond。 相当于使用了索引,但更为方便:

first = input[0];
second = input[1];

解构也可以作用于已声明的变量:

// swap variables
[first, second] = [second, first];

类似地,也可以作用于函数参数:

function f([first, second]: [number, number]) {
  console.log(first);
  console.log(second);
}
f([1, 2]);

你可以在数组里使用...语法创建剩余变量:

let [first, ...rest] = [1, 2, 3, 4];
console.log(first); // outputs 1
console.log(rest); // outputs [ 2, 3, 4 ]

当然,由于是 JavaScript, 你可以忽略你不关心的尾随元素:

let [first] = [1, 2, 3, 4];
console.log(first); // outputs 1

或其它元素:

let [, second, , fourth] = [1, 2, 3, 4];
console.log(second); // outputs 2
console.log(fourth); // outputs 4

解构元组

元组可以像数组一样解构;解构后的变量获得对应元组元素的类型:

let tuple: [number, string, boolean] = [7, 'hello', true];

let [a, b, c] = tuple; // a: number, b: string, c: boolean

当解构元组时,若超出元组索引范围将报错:

let [a, b, c, d] = tuple; // 错误,没有索引为3的元素

与数组一样,可以作用...来解构元组的剩余元素,从而得到一个短的元组:

let [a, ...bc] = tuple; // bc: [string, boolean]
let [a, b, c, ...d] = tuple; // d: [], the empty tuple

或者,忽略末尾元素或其它元素:

let [a] = tuple; // a: number
let [, b] = tuple; // b: string

对象解构

你也可以解构对象:

let o = {
  a: 'foo',
  b: 12,
  c: 'bar',
};
let { a, b } = o;

这通过 o.a and o.b 创建了 ab 。 注意,如果你不需要 c 你可以忽略它。

就像数组解构,你可以用没有声明的赋值:

({ a, b } = { a: 'baz', b: 101 });

注意,我们需要用括号将它括起来,因为 Javascript 通常会将以 { 起始的语句解析为一个块。

你可以在对象里使用...语法创建剩余变量:

let { a, ...passthrough } = o;
let total = passthrough.b + passthrough.c.length;

属性重命名

你也可以给属性以不同的名字:

let { a: newName1, b: newName2 } = o;

这里的语法开始变得混乱。 你可以将 a: newName1 读做 "a 作为 newName1"。 方向是从左到右,好像你写成了以下样子:

let newName1 = o.a;
let newName2 = o.b;

令人困惑的是,这里的冒号不是指示类型的。 如果你想指定它的类型, 仍然需要在其后写上完整的模式。

let { a, b }: { a: string; b: number } = o;

默认值

我们可以为属性指定一个默认值,当属性值为undefined时,将使用该默认值:

function keepWholeObject(wholeObject: { a: string; b?: number }) {
  let { a, b = 1001 } = wholeObject;
}

此例中,b?表明b是可选的,因此它可能为undefined。 现在,即使 b 为 undefined , keepWholeObject 函数的变量 wholeObject 的属性 ab 都会有值。

函数声明

解构也能用于函数声明。 看以下简单的情况:

type C = { a: string; b?: number };
function f({ a, b }: C): void {
  // ...
}

但是,通常情况下更多的是指定默认值,解构默认值有些棘手。 首先,你需要在默认值之前设置其格式。

function f({ a = '', b = 0 } = {}): void {
  // ...
}
f();

上面的代码是一个类型推断的例子,将在本手册后文介绍。

其次,你需要知道在解构属性上给予一个默认或可选的属性用来替换主初始化列表。 要知道 C 的定义有一个 b 可选属性:

function f({ a, b = 0 } = { a: '' }): void {
  // ...
}
f({ a: 'yes' }); // ok, default b = 0
f(); // ok, default to {a: ""}, which then defaults b = 0
f({}); // error, 'a' is required if you supply an argument

要小心使用解构。 从前面的例子可以看出,就算是最简单的解构表达式也是难以理解的。 尤其当存在深层嵌套解构的时候,就算这时没有堆叠在一起的重命名,默认值和类型注解,也是令人难以理解的。 解构表达式要尽量保持小而简单。 你自己也可以直接使用解构将会生成的赋值表达式。

展开

展开操作符正与解构相反。 它允许你将一个数组展开为另一个数组,或将一个对象展开为另一个对象。 例如:

let first = [1, 2];
let second = [3, 4];
let bothPlus = [0, ...first, ...second, 5];

这会令bothPlus的值为[0, 1, 2, 3, 4, 5]。 展开操作创建了firstsecond的一份浅拷贝。 它们不会被展开操作所改变。

你还可以展开对象:

let defaults = { food: 'spicy', price: '$$', ambiance: 'noisy' };
let search = { ...defaults, food: 'rich' };

search的值为{ food: "rich", price: "$$", ambiance: "noisy" }。 对象的展开比数组的展开要复杂的多。 像数组展开一样,它是从左至右进行处理,但结果仍为对象。 这就意味着出现在展开对象后面的属性会覆盖前面的属性。 因此,如果我们修改上面的例子,在结尾处进行展开的话:

let defaults = { food: 'spicy', price: '$$', ambiance: 'noisy' };
let search = { food: 'rich', ...defaults };

那么,defaults里的food属性会重写food: "rich",在这里这并不是我们想要的结果。

对象展开还有其它一些意想不到的限制。 首先,它仅包含对象 自身的可枚举属性。 大体上是说当你展开一个对象实例时,你会丢失其方法:

class C {
  p = 12;
  m() {}
}
let c = new C();
let clone = { ...c };
clone.p; // ok
clone.m(); // error!

其次,TypeScript 编译器不允许展开泛型函数上的类型参数。 这个特性会在 TypeScript 的未来版本中考虑实现。

TypeScript 手册

关于本手册

引入编程社区以来的 20 多年后,JavaScript 现在是最广泛使用的跨平台语言之一。JavaScript 最初是一个用于为网页添加简单交互的脚本语言,但现在已经发展成为前端和后端应用程序的首选语言,无论规模大小。尽管 JavaScript 程序的大小、范围和复杂性呈指数级增长,但 JavaScript 语言表达代码单元之间关系的能力并没有相应增长。再加上 JavaScript 具有相当特殊的运行时语义,这种语言和程序复杂性之间的不匹配使得 JavaScript 开发在规模较大时变得困难。

程序员常犯的最常见错误是类型错误:在期望使用某种类型的值时使用了另外一种类型的值。这可能是由于简单的拼写错误、未理解库的 API、对运行时行为的错误假设或其他错误导致的。TypeScript 的目标是成为 JavaScript 程序的静态类型检查器,也就是说,在你的代码运行之前(静态)运行的工具,确保程序的类型是正确的(类型检查)。

如果你没有 JavaScript 背景,打算把 TypeScript 作为你的第一门语言学习,我们建议你首先阅读 微软 JavaScript 学习教程或阅读 Mozilla Web 文档中的 JavaScript 教程。

如果你有其他语言的经验,通过阅读本手册,你应该能够很快掌握 JavaScript 语法。

本手册结构

本手册分为两个部分:

  • 手册

    TypeScript 手册旨在向普通程序员详细解释 TypeScript。你可以按照左侧导航从上到下阅读手册。

    每个章节或页面都能让你对给定概念获得深入理解。TypeScript 手册不是完整的语言规范,但它旨在成为语言的所有特性和行为的全面指南。

    完成手册阅读的读者应该能够:

    • 阅读和理解常用的 TypeScript 语法和模式
    • 解释重要编译器选项的影响
    • 在大多数情况下正确预测类型系统的行为

    为了清晰和简洁起见,手册的主要内容不会探讨特性的每个细枝末节。你可以在参考文章中找到有关特定概念的更多详细信息。

  • 参考文件

    手册下方导航栏中的参考部分旨在提供对 TypeScript 的特定部分如何工作的更深入理解。你可完整阅读它,但每个部分都只是旨在更详细地解释某个特定概念,因此没有连续性的要求。

非目标

本手册还旨在成为一份简明的文档,你可以在几个小时内轻松阅读完成。为了保持简洁,某些主题将不会涉及。

具体来说,本手册不会完全介绍核心 JavaScript 基础知识,如函数、类和闭包。在适当的情况下,我们将提供背景相关的链接,你可以用来了解这些概念。

本手册也不打算替代语言规范。在某些情况下,会跳过边界情况或形式化行为描述,而选择使用概括、易于理解的解释。相反,有单独的参考页面更准确、更形式地描述 TypeScript 的许多方面的行为。参考页面不是为不熟悉 TypeScript 的读者准备的,因此可能会使用高级术语或引用你尚未阅读过的主题。

最后,本手册不会涵盖 TypeScript 与其他工具的交互方式,除非有必要。像如何使用 webpack、rollup、parcel、react、babel、closure、lerna、rush、bazel、preact、vue、angular、svelte、jquery、yarn 或 npm 配置 TypeScript 这样的主题超出了范围——你可以在网络上的其他地方找到这些资源。

开始学习

在开始学习基础知识之前,我们建议挑一个以下介绍页面阅读。这些介绍旨在突出 TypeScript 与你喜欢的编程语言之间的主要相似性和差异,并澄清与这些语言特定的常见误解。

否则,你可以直接跳转到基础知识 部分。

基础

JavaScript 中的每个值会随着我们执行不同的操作表现出一系列的行为。这听起来很抽象,看下面的例子,考虑一下针对变量 message 可能执行的操作。

// 访问 message 的 toLowerCase 方法并调用它
message.toLowerCase();

// 调用 message 函数
message();

如果我们拆分这个过程,那么第一行代码就是访问了 messagetoLowerCase 方法并调用它;

第二行代码则尝试直接调用 message 函数。

不过让我们假设一下,我们并不知道 message 的值——这是很常见的一种情况,仅从上面的代码中我们无法确切得知最终的结果。每个操作的结果完全取决于 message 的初始值。

  • message 是否可以调用?
  • 它有 toLowerCase 属性吗?
  • 如果有这个属性,那么 toLowerCase 可以调用吗?
  • 如果 message 以及它的属性都是可以调用的,那么分别返回什么?

在编写 JavaScript 代码的时候,这些问题的答案经常需要我们自己记在脑子里,而且我们必须得确保自己处理好了所有细节。

假设 message 是这样定义的:

const message = 'Hello World!';

你可能很容易猜到,如果执行 message.toLowerCase(),我们将会得到一个所有字母都是小写的字符串。

如果执行第二行代码呢?如果你熟悉 JavaScript 的话,肯定猜到了,这会抛出一个异常:

TypeError: message is not a function

如果可以避免这样的错误就好了。

当我们执行代码的时候,JavaScript 运行时会计算出值的类型——根据这种类型有什么行为和功能,从而决定采取什么措施。这就是上面的代码会抛出 TypeError 的原因——它表明字符串 "Hello World!" 无法作为函数被调用。

对于诸如 string 或者 number 这样的原始类型,我们可以通过 typeof 操作符在运行时计算出它们的类型。但对于像函数这样的类型,并没有对应的运行时机制来判断类型。举个例子,看下面的函数:

function fn(x) {
  return x.flip();
}

从代码可以看出,仅当将带有可调用的 flip 属性的对象作为实参时,这个函数才可以正常运行,但 JavaScript 无法在代码执行时以一种我们可以检查的方式传递这个信息。要让纯 JavaScript 告诉我们 fn 在给定特定参数的时候会做什么事,唯一的方法就是实际调用 fn 函数。这样的行为使得我们很难在代码执行前进行相关的预测,也意味着我们在编写代码的时候,很难搞清楚代码会做什么事。

从这个角度看,所谓的类型其实就是描述了什么值可以安全传递给 fn,什么值会引起报错。JavaScript 只提供了动态类型——执行代码,然后才能知道会发生什么事。

那么不妨采用一种替代方案,使用静态的类型系统,在代码实际执行预测代码的行为。

静态类型检查

还记得之前我们将字符串作为函数调用时,抛出的 TypeError 错误吗?大多数人不希望在执行代码时看到任何错误——毕竟这些都是 bug!当我们编写新代码的时候,我们也会尽量避免引入新的 bug。

如果我们只是添加了一点代码,保存文件,重新运行代码,然后马上看到报错,那么我们或许可以快速定位到问题——但情况并非总是如此。我们可能没有全面、彻底地进行测试,导致没有发现一些潜在错误!或者,如果我们幸运地发现了这个错误,我们可能最终会进行大规模的重构,并添加许多不同的代码。

理想的方案应该是,我们有一个工具可以在代码执行前找出 bug。而这正是像 TypeScript 这样的静态类型检查器所做的事情。静态类型系统描述了程序运行时值的结构和行为。像 TypeScript 这样的静态类型检查器会利用类型系统提供的信息,并在事态发展不对劲的时候告知我们。

// @errors: 2349
const message = 'hello!';

message();

用 TypeScript 运行之前的例子,它会在我们执行代码之前首先抛出错误。

非异常失败

目前为止,我们讨论的都是运行时错误——JavaScript 运行时告诉我们,它觉得某个地方有异常。这些异常之所以能够抛出,是因为 ECMAScript 规范明确规定了针对异常应该表现的行为。

举个例子,规范指出,试图调用无法调用的东西应该抛出一个错误。也许这听上去像是“显而易见的行为”,并且你会觉得,访问对象上不存在的属性时,也会抛出一个错误。但恰恰相反,JavaScript 的表现和我们的预想不同,它返回的是值 undefined

const user = {
  name: 'Daniel',
  age: 26,
};
user.location; // 返回 undefined

最终,我们需要一个静态类型系统来告诉我们,哪些代码在这个系统中被标记为错误的代码——即使它是不会马上引起错误的“有效” JavaScript 代码。在 TypeScript 中,下面的代码会抛出错误,指出 location 没有定义:

// @errors: 2339
const user = {
  name: 'Daniel',
  age: 26,
};

user.location;

虽然有时候这意味着你需要在表达的内容上进行权衡,但我们的目的是为了找到程序中更多合法的 bug。而 TypeScript 也的确可以捕获到很多合法的 bug:

举个例子,拼写错误:

// @noErrors
const announcement = 'Hello World!';

// 你需要花多久才能注意到拼写错误?
announcement.toLocaleLowercase();
announcement.toLocalLowerCase();

// 实际上正确的拼写是这样的……
announcement.toLocaleLowerCase();

未调用的函数:

// @noUnusedLocals
// @errors: 2365
function flipCoin() {
  // 应该是 Math.random()
  return Math.random < 0.5;
}

或者是基本的逻辑错误:

// @errors: 2367
const value = Math.random() < 0.5 ? 'a' : 'b';
if (value !== 'a') {
  // ...
} else if (value === 'b') {
  // 永远无法到达这个分支
}

类型工具

TypeScript 可以在我们的代码出现错误时捕获 bug。这很好,但更关键的是,它能够在一开始就防止我们的代码出现错误。

类型检查器可以通过获取的信息检查我们是否正在访问变量或者其它属性上的正确属性。一旦它获取到了这些信息,它也能够提示你可能想要访问的属性。

这意味着 TypeScript 也能用于编辑代码。我们在编辑器中输入的时候,核心的类型检查器能够提供报错信息和代码补全。人们经常会谈到 TypeScript 在工具层面的作用,这就是一个典型的例子。

// @noErrors
// @esModuleInterop
import express from 'express';
const app = express();

app.get('/', function (req, res) {
  res.sen
  //     ^|
});

app.listen(3000);

TypeScript 在工具层面的作用非常强大,远不止拼写时进行代码补全和错误信息提示。支持 TypeScript 的编辑器可以通过“快速修复”功能自动修复错误,重构产生易组织的代码。同时,它还具备有效的导航功能,能够让我们跳转到某个变量定义的地方,或者找到对于给定变量的所有引用。所有这些功能都建立在类型检查器上,并且是跨平台的,因此你最喜欢的编辑器很可能也支持了 TypeScript

TypeScript 编译器——tsc

我们一直在讨论类型检查器,但目前为止还没上手使用过。是时候和我们的新朋友——TypeScript 编译器 tsc 打交道了。首先,通过 npm 进行安装。

npm install -g typescript

这将全局安装 TypeScript 的编译器 tsc。 如果你更倾向于将 tsc 安装在本地的 node_modules 文件夹中,那你可能需要借助 npx 或者类似的工具。

现在,我们新建一个空文件夹,尝试编写第一个 TypeScript 程序:hello.ts

// 和世界打个招呼
console.log('Hello world!');

注意这行代码没有任何多余的修饰,它看起来就和使用 JavaScript 编写的“hello world”程序一模一样。现在,让我们运行 typescript 安装包自带的 tsc 指令进行类型检查。

tsc hello.ts

看!

等等,“看”什么呢?我们运行了 tsc 指令,但什么事情也没有发生!是的,毕竟这行代码没有类型错误,所以控制台中当然看不到报错信息的输出。不过再检查一下——我们其实得到了一个输出文件。如果我们查看当前目录,会发现除了 hello.ts 文件外还有一个 hello.js 文件。而 hello.js 文件是 tsc 编译或者转换 hello.ts 文件之后输出的纯 JavaScript 文件。如果检查 hello.js 文件的内容,我们可以看到 TypeScript 编译器处理完 .ts 文件后产出的内容:

// 和世界打个招呼
console.log('Hello world!');

在这个例子中,TypeScript 几乎没有需要转译的内容,所以转译前后的代码看起来一模一样。编译器总是试图产出清晰可读的代码,这些代码看起来就像正常的开发者编写的一样。虽然这不是一件容易的事情,但 TypeScript 始终保持缩进,关注跨行的代码,并且会尝试保留注释。

如果我们刻意引入了类型检查错误呢?让我们重写一下 hello.ts

// @noErrors
// 行业通用打招呼函数
function greet(person, date) {
  console.log(`Hello ${person}, today is ${date}!`);
}

greet('Brendan');

如果我们再次执行 tsc hello.ts,那么会注意到命令行抛出了错误!

Expected 2 arguments, but got 1.

TypeScript 告诉我们,我们少传了一个参数给 greet 函数——本来应该是要传入那个参数的。目前为止,我们编写的仍然是标准的 JavaScript 代码,但类型检查依然可以发现我们代码中的问题。感谢 TypeScript!

报错时仍产出文件

有一件事你可能没有注意到,在上面的例子中,我们的 hello.js 文件再次发生了改动。如果我们打开这个文件,会发现内容和输入的文件内容是一样的。这可能有点出乎意料,毕竟 tsc 刚才报错了。但这种结果其实和 TypeScript 的核心原则有关:大多数时候,比 TypeScript 更了解代码。

再次重申,对代码进行类型检查,会限制可以运行的程序的种类,因此类型检查器会进行权衡,以确定哪些代码是可以被接受的。大多数时候,这样没什么问题,但有的时候,这些检查会对我们造成阻碍。举个例子,想象你现在正把 JavaScript 代码迁移到 TypeScript 代码,并产生了很多类型检查错误。最后,你不得不花费时间解决类型检查器抛出的错误,但问题在于,原始的 JavaScript 代码本身就是可以运行的!为什么把它们转换为 TypeScript 代码之后,反而就不能运行了呢?

所以 TypeScript 并不会对你造成阻碍。当然,随着时间的推移,你可能希望对错误采取更具防御性的措施,同时也让 TypeScript 采取更加严格的行为。在这种情况下,你可以开启 noEmitOnError 编译选项。尝试修改你的 hello.ts 文件,并使用参数去运行 tsc 指令:

tsc --noEmitOnError hello.ts

现在你会发现,hello.js 没有再发生改动了。

显式类型

目前为止,我们还没有告诉 TypeScript persondate 是什么。我们修改一下代码,告诉 TypeScript personstringdata 则应该是 Date 对象。我们也会调用 datetoDateString 方法。

function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}

我们所做的事情,是给 persondate 添加类型注解,描述 greet 调用的时候应该接受什么类型的参数。你可以将这个签名解读为“greet 接受 string 类型的 person,以及 Date 类型的 date”。

有了类型注解之后,TypeScript 就能告诉我们,哪些情况下对于 greet 的调用可能是不正确的。比如……

// @errors: 2345
function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}

greet('Maddison', Date());

什么?TypeScript 报错提示第二个参数有问题,但这是为什么呢?你可能会有点惊讶,因为在 JavaScript 中直接调用 Date() 返回的是 string。另一方面,通过 new Date() 去构造 Date,则可以如预期那样返回 Date 对象。

不管怎样,我们可以快速修复这个错误:

function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}

greet('Maddison', new Date());

记住,我们并不总是需要显式地进行类型注解。在很多情况下,即使省略了类型注解,TypeScript 也可以为我们推断出(或者“搞清楚”)类型。

let msg = 'hello there!';
//  ^?

即使我们没有告诉 TypeScript msg 的类型是 string,它自己也能够搞清楚。这是一个特性,在类型系统能够正确地进行类型推断的时候,最好不要手动添加类型注解了。

注意:代码信息会在上面的代码示例中的气泡中展示出来。如果将鼠标放到变量上面,那么编辑器也会有相同的提示。

擦除类型

我们看一下,通过 tsc 将上面的 greet 函数编译成 JavaScript 后会发生什么事:

// @showEmit
// @target: es5
function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}

greet('Maddison', new Date());

注意到有两个变化:

  1. 我们的 persondate 参数的类型注解不见了。
  2. 我们的“模板字符串”(使用反引号(`)包裹的字符串)变成了通过 + 拼接的普通字符串。

稍后再解释第二点,我们先来看第一个变化。类型注解并不属于 JavaScript(或者专业上所说的 ECMAScript)的内容,所以没有任何浏览器或者运行时能够直接执行不经处理的 TypeScript 代码。这也是为什么 TypeScript 首先需要一个编译器——它需要经过编译,才能去除或者转换 TypeScript 独有的代码,从而让这些代码可以在浏览器上运行。大多数 TypeScript 独有的代码都会被擦除,在这个例子中,可以看到类型注解的代码完全被擦除了。

记住: 类型注解永远不会改变你的程序在运行时的行为

降级

上面的另一个变化,就是我们的模板字符串从:

`Hello ${person}, today is ${date.toDateString()}!`;

被重写为:

'Hello ' + person + ', today is ' + date.toDateString() + '!';

为什么会这样子呢?

模板字符串是 ECMAScript 2015(或者 ECMAScript6、ES2015、ES6 等)引入的新特性。TypeScript 可以将高版本 ECMAScript 的代码重写为类似 ECMAScript3 或者 ECMAScript5(也就是 ES3 或者 ES5)这样较低版本的代码。类似这样将更新或者“更高”版本的 ECMAScript 向下降级为更旧或者“更低”版本的代码,就是所谓的降级

默认情况下,TypeScript 会转化为 ES3 代码,这是一个非常旧的 ECMAScript 版本。我们可以使用 target 选项将代码往较新的 ECMAScript 版本转换。通过使用 --target es2015 参数进行编译,我们可以得到 ECMAScript2015 版本的目标代码,这意味着这些代码能够在支持 ECMAScript2015 的环境中执行。因此,运行 tsc --target es2015 hello.ts 之后,我们会得到如下代码:

function greet(person, date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
greet('Maddison', new Date());

虽然默认的目标代码采用的是 ES3 语法,但现在浏览器大多数都已经支持 ES2015 了。 > 所以,大多数开发者可以安全地指定目标代码采用 ES2015 或者是更高的 ES 版本,除非你需要着重兼容某些古老的浏览器。

严格性

不同的用户会由于不同的理由去选择使用 TypeScript 的类型检查器。一些用户寻求的是一种更加松散、可选的开发体验,他们希望类型检查仅作用于部分代码,同时还可享受 TypeScript 提供的功能。这也是 TypeScript 默认提供的开发体验,类型是可选的,推断会使用最松散的类型,对于潜在的 null/undefined 类型的值也不会进行检查。就像 tsc 在编译报错的情况下仍然能够正常产出文件一样,这些默认的配置会确保不对你的开发过程造成阻碍。如果你正在迁移现有的 JavaScript 代码,那么这样的配置可能刚好适合。

另一方面,一些用户更希望 TypeScript 可以快速地、尽可能多地检查代码,这也是这门语言提供了严格性设置的原因。这些严格性设置将静态的类型检查从一种切换开关的模式(对于你的代码,要么全部进行检查,要么完全不检查)转换为接近于刻度盘那样的模式。你越是调节它,TypeScript 就会为你检查越多东西。这可能需要额外的工作,但从长远来看,这是值得的,它可以带来更彻底的检查以及更精细的工具。如果可能,新的代码库应该始终启用这些严格性配置。

TypeScript 有几个和类型检查相关的严格性标志,它们可以随时打开或关闭,如若没有特殊说明,我们文档中的例子都是在开启所有严格性设置的情况下执行的。CLI 中的 strict 标志,或者 tsconfig.json 中的 "strict: true" 设置,可以一次性开启全部严格性选项。但我们也可以单独开启或者关闭某个选项。在所有这些设置中,尤其需要关注的是 noImplicitAnystrictNullChecks

noImplicitAny

回想一下,在前面的某些例子中,TypeScript 没有为我们进行类型推断,这时候变量会采用最宽泛的类型:any。这并不是一件最糟糕的事情——毕竟,使用 any 类型基本就和纯 JavaScript 一样了。

但是,使用 any 通常会和使用 TypeScript 的目的相违背。你的程序使用越多的类型,那么在验证和工具辅助上你的收益就越多,这意味着在编码的时候你会遇到越少的 bug。启用 noImplicitAny 配置项,在遇到被隐式推断为 any 类型的变量时就会抛出一个错误。

strictNullChecks

默认情况下,nullundefined 可以被赋值给其它任意类型。这会让你的编码更加容易,但世界上无数多的 bug 正是由于忘记处理 nullundefined 导致的——有时候它甚至会带来数十亿美元的损失strictNullChecks 标志让处理 nullundefined 的过程更加明显,让我们不用担心自己是否忘记处理 nullundefined

常见类型

在本章中,我们将介绍一些在 JavaScript 代码中最常见的值的类型,并说明在 TypeScript 中描述这些类型相应的方法。这不是一个详尽的列表,后续章节将描述命名和使用其他类型的更多方法。

类型还可以出现在许多地方,而不仅仅是类型注释。在我们了解类型本身的同时,我们还将了解在哪些地方可以引用这些类型来形成新的结构。

我们将首先回顾一下你在编写 JavaScript 或 TypeScript 代码时可能遇到的最基本和最常见的类型。这些将在稍后形成更复杂类型的核心构建块。

基本类型:stringnumberboolean

JavaScript 有三种非常常用的基本类型stringnumberboolean。在 TypeScript 中,每种 JS 基本类型都有其对应的类型。如果在一个值上使用 JavaScript 的 typeof 运算符,会看到这些类型的名称:

  • string表示字符串值,如 "Hello, world"
  • number用于数字,如 42。JavaScript 没有针对整数的特殊运行时值,所以没有类似 intfloat 的等价物——一切都是 number
  • boolean 用于 truefalse 这两个值

类型名称 StringNumberBoolean(以大写字母开头)是合法的,但是它们指向的是一些特殊的内置类型,其在代码中很少出现。请始终使用 stringnumberboolean 作为类型。

数组

要指定类似 [1, 2, 3] 的数组的类型,可以使用语法 number[];这个语法适用于任何类型(例如 string[] 是字符串数组,依此类推)。你可能还会看到其写作 Array<number>,它们的意思是一样的。当我们学习泛型时,将更多地了解到 T<U> 语法的含义。

请注意,[number] 是不同的东西;请参阅关于元组类型的部分。

any

TypeScript 还有一种特殊的类型 any,你如果不希望特定值引起类型检查错误的话,可以使用它。

当一个值的类型是 any 时,你可以访问它的任何属性(其属性的类型也将是 any),像调用函数一样调用它,将其赋值给任何类型的值,或者将任何类型的值赋给它,或者几乎任何其他在语法上合法的操作:

let obj: any = { x: 0 };
// 以下代码行都不会引发编译器错误。
// 用 `any` 就禁用所有进一步的类型检查,意味着你比 TypeScript 更了解环境。
obj.foo();
obj();
obj.bar = 100;
obj = 'hello';
const n: number = obj;

如果你不想费力编写出一个很长的类型,就只是为了让 TypeScript 相信某一行代码是正确的,any 类型就非常有用。

noImplicitAny

当你没有指定类型,并且 TypeScript 无法从上下文中推断出类型时,编译器通常会默认为any

然而,通常情况下,你最好避免使用 any,因为 any 不会进行类型检查。编译器标志 noImplicitAny 可以将任何隐式的 any 标记为错误。

变量的类型注解

使用 constvarlet 声明变量时,你可以选择性地添加类型注解来显式指定变量的类型:

let myName: string = 'Alice';
//        ^^^^^^^^ 类型注解

TypeScript 不使用类似 int x = 0; 的“左侧类型”声明。 类型注解总是放在被注解的内容之后

但在大多数情况下,并不是必须要这样。TypeScript 会尽可能自动根据代码推断出类型。例如,以下变量的类型是根据其初始化的值推断出来的:

// 不需要类型注解——“myName”推断为 “string” 类型
let myName = 'Alice';

在大多数情况下,你不需要学习推断规则。如果你刚开始使用,尝试少使用一些类型注解——实际上仅需要了解少量的类型注解,就能让 TypeScript 完全理解代码的含义。

函数

在 JavaScript 中数据的传递主要通过函数进行。TypeScript 允许你指定函数的输入和输出值的类型。

参数类型注解

声明函数时,你可以在每个参数后面添加类型注解,以声明函数的参数类型。参数类型注解放在参数名后面:

// 参数类型注解
function greet(name: string) {
  //                 ^^^^^^^^
  console.log('你好,' + name.toUpperCase() + '!!');
}

当参数具有类型注解时,传递给该函数的参数将被检查:

// @errors: 2345
declare function greet(name: string): void;
// ---cut---
// 如果执行,将会产生运行时错误!
greet(42);

即使参数没有类型注解,TypeScript 仍然会检查你传递参数的数量是否正确。

返回类型注解

你也可以添加返回类型注解。返回类型注解出现在参数列表之后:

function getFavoriteNumber(): number {
  //                        ^^^^^^^^
  return 26;
}

和变量类型注解一样,通常情况下你不需要返回类型注解,因为 TypeScript 会根据 return 语句自动推断函数的返回值类型。上面示例中的类型注解并没有任何影响。有些代码库显式指定返回类型是为了记录,也有些是为了防止意外更改或仅仅出于个人偏好。

匿名函数

匿名函数与函数声明有些不同。如果在 TypeScript 能够确定其调用方式的位置使用一个函数,该函数的参数会自动获得类型。

以下是例子:

// @errors: 2551
// 这里没有类型注解,但 TypeScript 可以发现错误
const names = ['Alice', 'Bob', 'Eve'];

// 函数的上下文类型推断
names.forEach(function (s) {
  console.log(s.toUppercase());
});

// 箭头函数也适用上下文类型推断
names.forEach(s => {
  console.log(s.toUppercase());
});

尽管参数 s 没有类型注解,但 TypeScript 使用了 forEach 函数的类型以及数组的推断类型,来确定 s 的类型。

这个过程被称为上下文类型推断,因为函数出现的上下文告诉它应该具有的类型。类似于推断规则,你不需要显式地学习这个过程是如何发生的,但了解它发生的事实可以帮助你注意到不需要类型注解的情况。稍后,我们将看到更多关于值所处的上下文如何影响其类型的示例。

对象类型

除了基本类型之外,最常见的类型是对象类型。任何具有属性的 JavaScript 值都是对象类型,其几乎包括所有值!要定义一个对象类型,我们只需要列出其属性及其属性的类型。

例如,这是一个以类似于点的对象为参数的函数:

// 参数的类型注解是对象类型
function printCoord(pt: { x: number; y: number }) {
  //                      ^^^^^^^^^^^^^^^^^^^^^^^^
  console.log('坐标的 x 值是 ' + pt.x);
  console.log('坐标的 y 值是 ' + pt.y);
}
printCoord({ x: 3, y: 7 });

本例中,我们使用具有两个属性 xy 的类型注解来注解参数,两个属性都是 number 类型。你可以使用 ,; 来分隔属性,最后一个分隔符可以省略。

每个属性的类型部分也是可选的。如果你不指定类型,它将被默认为是 any 类型。

可选属性

对象类型还可以指定它们的某些或所有属性是可选的。要实现这一点,可以在属性名后面加上 ?

function printName(obj: { first: string; last?: string }) {
  // ...
}
// 都是有效的
printName({ first: 'Bob' });
printName({ first: 'Alice', last: 'Alisson' });

在 JavaScript 中,如果访问一个不存在的属性,你会得到 undefined 而不是运行时错误。因此,如果你读取的是一个可选属性的话,那么在使用它之前,你需要检查其是否为 undefined

// @errors: 2532
function printName(obj: { first: string; last?: string }) {
  // 错误——如果没有提供 'obj.last',可能会崩溃!
  console.log(obj.last.toUpperCase());
  if (obj.last !== undefined) {
    // 正常运行
    console.log(obj.last.toUpperCase());
  }

  // 一种使用现代 JavaScript 语法的安全替代方法:
  console.log(obj.last?.toUpperCase());
}

联合类型

TypeScript 的类型系统允许你使用各种运算符从现有类型构建新类型。现在我们了解了如何编写一些类型,是时候开始以有趣的方式*组合(combine)*它们了。

定义联合类型

联合(Union)类型是组合类型的一种方式。联合类型是由两个或更多其他类型形成的类型,表示值可以是这些类型中的任意一个。我们将每个类型都称为联合的成员

以下是可以操作字符串或数字的函数:

// @errors: 2345
function printId(id: number | string) {
  console.log('你的 ID 是:' + id);
}
// 正常运行
printId(101);
// 正常运行
printId('202');
// 错误
printId({ myID: 22342 });

使用联合类型

提供与联合类型匹配的值很容易——只需提供与联合的成员之一匹配的类型即可。但是如果你一个联合类型的值,你该如何使用它呢?

只有当某个操作对联合的每个成员都有效时,TypeScript 才允许你对联合类型值进行操作。例如,如果你有一个 string | number 的联合类型,那么你不能使用仅适用于 string 的方法:

// @errors: 2339
function printId(id: number | string) {
  console.log(id.toUpperCase());
}

解决方法是使用代码来紧缩联合类型的范围,就像在没有类型注解的 JavaScript 中一样。如果 TypeScript 可以根据代码的结构推断出更具体的类型的值的话,就会发生紧缩

例如,TypeScript 知道只有 stringtypeof 值为 "string"

function printId(id: number | string) {
  if (typeof id === 'string') {
    // 在这个分支中,id 的类型是 'string'
    console.log(id.toUpperCase());
  } else {
    // 在这里,id 的类型是 'number'
    console.log(id);
  }
}

另一个例子是 Array.isArray 函数:

function welcomePeople(x: string[] | string) {
  if (Array.isArray(x)) {
    // 在这里:'x' 的类型是 'string[]'
    console.log('你好,' + x.join(' 和 '));
  } else {
    // 在这里:'x' 的类型是 'string'
    console.log('欢迎,孤独旅行者 ' + x);
  }
}

请注意,在 else 分支中,我们不需要做任何特殊处理(如果 x 不是 string[],那么它肯定是 string)。

有时你会遇到一个联合类型,其中所有成员都具有共同的特征。例如,数组和字符串都有一个 slice 方法。如果联合的每个成员都有一个共同的属性,你可以在不紧缩类型的情况下使用该属性:

// 返回类型被推断为 number[] | string
function getFirstThree(x: number[] | string) {
  return x.slice(0, 3);
}

联合类型的名字可能会让人困惑,因为它实际上是这些类型的属性的交集。(译注:联合类型的英文是“Union”,和并集是同一个单词) 这是有意为之(名称联合类型来自于类型理论)。 联合类型 number | string 是通过将每个类型的合并而组成的。 注意,给定两个集合,每个集合有相应特征,只有这些特征的交集适用于这些集合的合集。 例如,假设有一个房间,里面的人都是戴帽子的高个,而另一个房间里的人都戴帽子且说西班牙语,将这些房间组合在一起后,我们只知道每个人都戴着帽子。

类型别名

可以直接在类型注解中编写对象类型和联合类型来使用它们。这虽然很方便,但是我们常常会有一个需求,就是如果多次使用同一个类型的话,可以通过一个名称来引用它。

类型别名正是如此(任意类型名称)。类型别名的语法是:

type Point = {
  x: number;
  y: number;
};

// 与前面的示例完全相同
function printCoord(pt: Point) {
  console.log('x 的坐标值是 ' + pt.x);
  console.log('y 的坐标值是 ' + pt.y);
}

printCoord({ x: 100, y: 100 });

实际上,不只是对象类型,你可以使用类型别名为任何类型命名。例如,类型别名可以命名联合类型:

type ID = number | string;

请注意,别名只是别名(你不能使用类型别名来创建同一类型的不同“版本”)。当你使用别名时,它与你编写的别名所对应的类型完全一样。换句话说,这段代码可能看起来是非法的,但是对于 TypeScript 来说是正确的,因为这两种类型都是同一类型的别名:

declare function getInput(): string;
declare function sanitize(str: string): string;
// ---cut---
type UserInputSanitizedString = string;

function sanitizeInput(str: string): UserInputSanitizedString {
  return sanitize(str);
}

// 创建一个经过清理的输入框
let userInput = sanitizeInput(getInput());

// 仍然可以使用字符串重新赋值
userInput = '新的输入';

接口

接口声明是命名对象类型的另一种方式:

interface Point {
  x: number;
  y: number;
}

function printCoord(pt: Point) {
  console.log('x 的坐标值是 ' + pt.x);
  console.log('y 的坐标值是 ' + pt.y);
}

printCoord({ x: 100, y: 100 });

就像我们上面使用类型别名时一样,这个示例的工作方式就像我们使用了匿名对象类型一样。TypeScript 只关心我们传递给 printCoord 的值的结构——它只关心它是否具有预期的属性。只关心类型的结构和功能,这就是为什么我们说 TypeScript 是一个结构化类型的类型系统。

类型别名和接口之间的区别

类型别名和接口非常相似,在大多数情况下你可以在它们之间自由选择。几乎所有的 interface 功能都可以在 type 中使用,关键区别在于不能重新开放类型以添加新的属性,而接口始终是可扩展的。

Interface Type

扩展接口

interface Animal {
  name: string
}
interface Bear extends Animal { honey: boolean }
const bear = getBear() bear.name bear.honey

通过 "&" 扩展类型

type Animal = {
  name: string
}
type Bear = Animal & { honey: Boolean }
const bear = getBear(); bear.name; bear.honey;

向现有接口添加新字段

interface Window {
  title: string
}
interface Window { ts: TypeScriptAPI }
const src = 'const a = "Hello World"'; window.ts.transpileModule(src, {});

类型创建后不能更改

type Window = {
  title: string
}
type Window = { ts: TypeScriptAPI }
// Error: Duplicate identifier 'Window'.

在后面的章节中你会学到更多关于这些概念的知识,所以如果你没有立即理解这些知识,请不要担心。

在大多数情况下,你可以根据个人喜好进行选择,TypeScript 会告诉你它是否需要其他类型的声明。如果你想要启发式方法,可以使用 interface 直到你需要使用 type 中的功能。

类型断言

有时候你会遇到一种情况,就是 TypeScript 无法确定一些类型。

例如,如果你使用 document.getElementById,TypeScript 只能知道它返回某种 HTMLElement,但是可能你希望 TypeScript 知道的更具体一点,例如让它知道这个 ID 指向的应当是一个 HTMLCanvasElement

在这种情况下,你可以使用类型断言来指定更具体的类型:

const myCanvas = document.getElementById('main_canvas') as HTMLCanvasElement;

与类型注解类似,类型断言会在编译时移除,不会影响代码的运行行为。

你也可以使用尖括号语法(除非代码在 .tsx 文件中),效果是一样的:

const myCanvas = <HTMLCanvasElement>document.getElementById('main_canvas');

提醒:由于类型断言在编译时被移除,因此没有与类型断言相关的运行时检查。 如果类型断言错误,不会生成异常或 null

TypeScript 只允许将类型断言为更具体更不具体的类型。这个规则阻止了一些“不可能”的强制转换,比如:

// @errors: 2352
const x = 'hello' as number;

有时这个规则可能过于保守,会禁止一些更复杂的强制转换,尽管这些转换可能是有效的。如果遇到这种情况,你可以使用两个断言,先断言为 any(或者后面我们会介绍的 unknown),然后再断言为目标类型:

declare const expr: any;
type T = { a: 1; b: 2; c: 3 };
// ---cut---
const a = expr as any as T;

字面类型(literal type)

除了通用的 stringnumber 类型之外,我们还可以在类型位置引用特定的字符串和数字。

可以这样想,JavaScript 提供了不同的声明变量的方式。varlet 都允许改变变量中保存的值,而 const 则不允许。这体现在 TypeScript 创建字面类型的方式上。

let changingString = 'Hello World';
changingString = 'Olá Mundo';
// `changingString` 可以表示任意可能的字符串,所以 TypeScript 在类型系统中这样描述它
changingString;
// ^?

const constantString = 'Hello World';
// `constantString` 只能表示一个可能的字符串,它有字面类型的表示形式
constantString;
// ^?

单独来看,字面类型并没有多大价值:

// @errors: 2322
let x: 'hello' = 'hello';
// OK
x = 'hello';
// ...
x = 'howdy';

只能是固定一个值的变量并没有多大用处!

但是如果将字面类型组合成联合类型,就可以表达更有用的概念,例如,只接受一组特定已知值的函数:

// @errors: 2345
function printText(s: string, alignment: 'left' | 'right' | 'center') {
  // ...
}
printText('Hello, world', 'left');
printText("G'day, mate", 'centre');

数字字面类型的工作方式相同:

function compare(a: string, b: string): -1 | 0 | 1 {
  return a === b ? 0 : a > b ? 1 : -1;
}

当然,你可以将其与非字面类型组合使用:

// @errors: 2345
interface Options {
  width: number;
}
function configure(x: Options | 'auto') {
  // ...
}
configure({ width: 100 });
configure('auto');
configure('automatic');

还有一种字面类型:布尔字面类型。只有两种布尔字面类型,truefalseboolean 类型本身实际上只是 true | false 的联合类型的别名。

字面量推断

如果你使用对象来初始化变量,TypeScript 会假设该对象的属性可能会在后续的代码中发生变化。例如,如果你编写了如下代码:

declare const someCondition: boolean;
// ---cut---
const obj = { counter: 0 };
if (someCondition) {
  obj.counter = 1;
}

TypeScript 不会认为将 1 赋值给之前为 0 的字段是一个错误。换句话说,obj.counter 必须具有类型 number,而不是 0,因为类型用于确定读取写入行为。

字符串也是同样的情况:

// @errors: 2345
declare function handleRequest(url: string, method: 'GET' | 'POST'): void;
// ---cut---
const req = { url: 'https://example.com', method: 'GET' };
handleRequest(req.url, req.method);

在上面的例子中,req.method 被推断为 string,而不是 "GET"。因为创建 req 和调用 handleRequest 之间可能会有代码对 req.method 进行赋值,例如将 "GUESS" 赋给 req.method,TypeScript 认为此代码存在错误。

有两种方法可以解决这个问题。

  1. 可以通过在任一位置添加类型断言来改变推断结果:

    declare function handleRequest(url: string, method: 'GET' | 'POST'): void;
    // ---cut---
    // 改变 1:
    const req = { url: 'https://example.com', method: 'GET' as 'GET' };
    // 改变 2:
    handleRequest(req.url, req.method as 'GET');
    

    改变 1 的意思是 "我打算让 req.method 始终具有字面量类型 "GET"",阻止在之后将 "GUESS" 赋值给该字段。 改变 2 的意思是 "我出于某些原因知道 req.method 的值为 "GET""。

  2. 可以使用 as const 将整个对象转换为字面量类型:

    declare function handleRequest(url: string, method: 'GET' | 'POST'): void;
    // ---cut---
    const req = { url: 'https://example.com', method: 'GET' } as const;
    handleRequest(req.url, req.method);
    

    as const 后缀的作用类似于 const,但是针对的是类型系统,确保所有属性都被赋予字面量类型,而不是更一般的类型,如 stringnumber

nullundefined

JavaScript 有两个基本值,用于表示缺失或未初始化的值:nullundefined

TypeScript 也有两个相应的类型,名称相同。这些类型的特性取决于是否打开了 strictNullChecks 选项。

strictNullChecks 关闭

如果 strictNullChecks 关闭,可能为 nullundefined 的值仍然可以正常访问,并且可以将 nullundefined 赋值给任何类型的属性。这类似于没有空值检查的语言(例如 C#、Java)的行为。不检查这些值的缺失往往是错误的主要来源;建议尽可能打开 strictNullChecks

strictNullChecks 打开

如果 strictNullChecks 打开,当一个值为 nullundefined 时,你需要在使用该值的方法或属性之前进行检查。就像在使用可选属性之前检查 undefined 一样,我们可以使用缩小类型来检查可能为 null 的值:

function doSomething(x: string | null) {
  if (x === null) {
    // 什么都不做
  } else {
    console.log('Hello, ' + x.toUpperCase());
  }
}

非空断言操作符(后缀 !

TypeScript 还有一个特殊的语法,用于在不进行任何显式检查的情况下去除类型中的 nullundefined。在任何表达式后面写 ! 实际上是断言该值不是 nullundefined

function liveDangerously(x?: number | null) {
  // 没有错误
  console.log(x!.toFixed());
}

与其他类型断言一样,这不会改变你的代码的运行行为,因此只有在你知道该值不可能nullundefined 时才使用 !

枚举

枚举是 TypeScript 添加到 JavaScript 中的功能,它允许描述一个值,该值可以是一组可能的命名常量之一。与大多数 TypeScript 特性不同,这不是 JavaScript 类型级别的添加,而是添加到语言和运行时的功能。因此,你应该知道这个特性的存在,但除非你确定,否则最好不要使用。你可以在枚举参考页面上阅读更多关于枚举的信息。

不常见的原始类型

值得一提的是 JavaScript 中的其他基本类型,在类型系统中也有相应的表示。我们在这里不会深入讨论。

bigint

从 ES2020 开始,JavaScript 中有一个用于表示非常大整数的基本类型 BigInt

// @target: es2020

// 通过 BigInt 函数创建一个 bigint
const oneHundred: bigint = BigInt(100);

// 通过字面量语法创建一个 BigInt
const anotherHundred: bigint = 100n;

你可以在 TypeScript 3.2 发布说明中了解更多关于 BigInt 的信息。

symbol

JavaScript 中有一个用于通过 Symbol() 函数创建全局唯一引用的基本类型:

// @errors: 2367
const firstName = Symbol('name');
const secondName = Symbol('name');

if (firstName === secondName) {
  // 永远不会发生
}

你可以在 Symbols 参考页面中了解更多相关信息。

缩小类型范围

假设我们有一个名为 padLeft 的函数。

function padLeft(padding: number | string, input: string): string {
  throw new Error('尚未实现!');
}

如果 padding 是一个 number,它将把它作为我们想要在 input 前面添加的空格数。如果 padding 是一个 string,它应该只是将 padding 添加到 input 前面。让我们尝试为当向 padLeftpadding 参数传递一个 number 时实现逻辑。

// @errors: 2345
function padLeft(padding: number | string, input: string) {
  return ' '.repeat(padding) + input;
}

糟糕,我们得到 padding 相关的错误。TypeScript 警告我们正在将类型为 number | string 的值传递给 repeat 函数,而该函数只接受一个 number 参数,而它是正确的。换句话说,我们没有明确检查 padding 是否为 number,也没有处理它是 string 的情况,所以我们来做一下。

function padLeft(padding: number | string, input: string) {
  if (typeof padding === 'number') {
    return ' '.repeat(padding) + input;
  }
  return padding + input;
}

如果这看起来像无聊的 JavaScript 代码,那就是目的所在。除了我们放置的注解之外,这段 TypeScript 代码看起来像 JavaScript。这是因为 TypeScript 的类型系统旨在尽可能地让你编写典型的 JavaScript 代码,而无需费力地获取类型安全性。

虽然它看起来可能不起眼,但在这里实际上发生了很多事情。就像 TypeScript 使用静态类型分析运行时值一样,它还在 JavaScript 的运行时控制流构造(如 if/else、条件三元运算符、循环、真值检查等)上叠加了类型分析,这些构造都可以影响这些类型。

在我们的 if 检查中,TypeScript 看到 typeof padding === "number" 并将其理解为特殊形式的代码,称为类型守卫。TypeScript 沿着程序可能采取的路径来分析值在给定位置的最具体可能类型。它查看这些特殊的检查(称为类型守卫)和赋值,并将类型细化为比声明更具体的类型的过程称为缩小。在许多编辑器中,我们可以观察到这些类型在变化,我们在示例中也将这样做。

function padLeft(padding: number | string, input: string) {
  if (typeof padding === 'number') {
    return ' '.repeat(padding) + input;
    //                ^?
  }
  return padding + input;
  //     ^?
}

TypeScript 可以理解几种不同的缩小类型的构造。

typeof 类型守卫

正如我们已经看到的,JavaScript 支持 typeof 运算符,它可以在运行时提供关于值类型的基本信息。TypeScript 期望它返回一组特定的字符串:

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

就像我们在 padLeft 中看到的那样,这个运算符在许多 JavaScript 库中经常出现,而 TypeScript 可以理解它以在不同的分支中缩小类型。

在 TypeScript 中,针对 typeof 返回值的检查是一种类型守卫。因为 TypeScript 对 typeof 在不同值上的操作方式进行了编码,所以它了解 JavaScript 中的一些怪异之处。例如,请注意在上面的列表中,typeof 不会返回字符串 null。请看下面的示例:

// @errors: 2531 18047
function printAll(strs: string | string[] | null) {
  if (typeof strs === 'object') {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === 'string') {
    console.log(strs);
  } else {
    // 什么都不做
  }
}

printAll 函数中,我们尝试检查 strs 是否是一个对象,以确定它是否为数组类型(现在是一个加强记忆的好时机,数组在 JavaScript 中是对象类型)。但事实证明,在 JavaScript 中,typeof null 实际上是 "object"!太不幸了。

有足够经验的用户可能不会感到惊讶,但并不是每个人在 JavaScript 中都遇到过这个问题;幸运的是,TypeScript 让我们知道了 strs 的类型会被缩小为 string[] | null,而不仅仅是 string[]

这可能是一个好的过渡点,让我们谈谈所谓的“真值”检查。

真值缩小类型

“真值”是你不太可能会在英文词典中找到的词,但在 JavaScript 中却非常常见。

在 JavaScript 中,我们可以在条件语句、&&||if 语句、布尔否定 (!)语句等中使用任何表达式。例如,if 语句并不要求其条件始终具有 boolean 类型。

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `现在有 ${numUsersOnline} 人在线!`;
  }
  return '这里没有人。 :(';
}

在 JavaScript 中,诸如 if 的结构首先将其条件“强制转换”为 boolean 类型,然后根据结果是 true 还是 false 选择相应的分支。像以下这些值

  • 0
  • NaN
  • ""(空字符串)
  • 0nbigint 版本的零)
  • null
  • undefined

都会被强制转换为 false,其他值则被强制转换为 true。你可以通过将值传递给 Boolean 函数,或者使用更简洁的双重布尔否定来将值强制转换为 boolean 类型。(后者的优点是 TypeScript 推断出一个狭窄的字面量布尔类型 true,而前者则推断为 boolean 类型。)

// 这两个都会得到 ‘true’
Boolean('hello'); // 类型: boolean, 值: true
!!'world'; // 类型: true,    值: true

利用这种行为是相当流行的,特别是用于防范 nullundefined 等值。例如,让我们尝试将其应用于我们的 printAll 函数。

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === 'object') {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === 'string') {
    console.log(strs);
  }
}

你会注意到,通过检查 strs 是否为真值,我们消除了上面的错误。这至少可以避免我们在运行代码时遇到以下可怕的错误:

TypeError: null is not iterable

然而请记住,对基本类型进行真值检查往往容易出错。例如,考虑另一种编写 printAll 的尝试。

function printAll(strs: string | string[] | null) {
  // !!!!!!!!!!!!!!!!
  //  不要这样做!
  //  继续阅读下去
  // !!!!!!!!!!!!!!!!
  if (strs) {
    if (typeof strs === 'object') {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === 'string') {
      console.log(strs);
    }
  }
}

我们将整个函数体都包装在一个真值检查中,但这有一个微妙的缺点:我们可能不再能正确处理空字符串的情况。

TypeScript 对我们来说没有任何问题,但如果你对 JavaScript 不太熟悉,这种行为值得注意。TypeScript 经常可以帮助你尽早发现错误,但如果你选择对一个值什么也不做,那么它能做的就有限了,而不会过于武断。如果你愿意,你可以通过使用一个代码检查工具来确保处理这类情况。

关于通过真值缩小类型的最后一点是,带有 ! 的布尔否定会将被否定的值过滤到否定分支。

function multiplyAll(
  values: number[] | undefined,
  factor: number
): number[] | undefined {
  if (!values) {
    return values;
  } else {
    return values.map(x => x * factor);
  }
}

等式缩小类型

TypeScript 还使用 switch 语句和等式检查,如 ===!====!= 来缩小类型。例如:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // 现在我们可以在 'x' 或 'y' 上调用任何 'string' 方法。
    x.toUpperCase();
    // ^?
    y.toLowerCase();
    // ^?
  } else {
    console.log(x);
    //          ^?
    console.log(y);
    //          ^?
  }
}

在上面的例子中,当我们检查 xy 是否相等时,TypeScript 知道它们的类型也必须相等。由于 string 是唯一 xy 都可能具有的公共类型,TypeScript 知道在第一个分支中 xy 一定是 string 类型。

检查特定字面值(而不是变量)也可以工作。在我们关于真值缩小类型的部分,我们编写了一个 printAll 函数,它容易出错,因为它意外地没有正确处理空字符串。相反,我们可以进行特定的检查来排除 null,而 TypeScript 仍然可以正确地从 strs 的类型中移除 null

function printAll(strs: string | string[] | null) {
  if (strs !== null) {
    if (typeof strs === 'object') {
      for (const s of strs) {
        //            ^?
        console.log(s);
      }
    } else if (typeof strs === 'string') {
      console.log(strs);
      //          ^?
    }
  }
}

JavaScript 的宽松等式检查 ==!= 也可以正确缩小类型。如果你对它们不熟悉,检查某些东西是否 == null 实际上不仅检查它是否是具体的值 null,还检查它是否可能是 undefined。同样的规则适用于 == undefined:它检查一个值是否为 nullundefined

interface Container {
  value: number | null | undefined;
}

function multiplyValue(container: Container, factor: number) {
  // 从类型中移除 'null' 和 'undefined'。
  if (container.value != null) {
    console.log(container.value);
    //                    ^?

    // 现在我们可以安全地将 'container.value' 乘以 'factor'。
    container.value *= factor;
  }
}

in 运算符缩小类型

JavaScript 有一个的运算符,用于确定对象或其原型链中是否存在具有指定名称的属性:in 运算符。TypeScript 将其视为一种缩小类型的方法。

例如,在代码中使用:"value" in x,其中 "value" 是一个字符串字面量,而 x 是一个联合类型。“true”分支会缩小 x 的类型,该类型具有可选或必需的 value 属性,而“false”分支会缩小到 value 属性可选或缺失的类型。

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
  if ('swim' in animal) {
    return animal.swim();
  }

  return animal.fly();
}

需要强调的是,可选属性在缩小类型时将出现在两个分支中。例如,人类既可以游泳又可以飞行(通过正确的装备),因此应该在 in 检查的两个分支中都出现:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };

function move(animal: Fish | Bird | Human) {
  if ("swim" in animal) {
    animal;
//  ^?
  } else {
    animal;
//  ^?
  }
}

instanceof 缩小类型

JavaScript 中有一个运算符可以检查一个值是否是另一个值的“实例”。具体来说,在 JavaScript 中,x instanceof Foo 检查 x原型链是否包含 Foo.prototype。虽然我们不会在这里深入讨论,而且在我们介绍类时会更多地涉及到它,但它仍然对大多数可以使用 new 构造的值非常有用。正如你可能已经猜到的那样,instanceof 也是一种类型护卫,在由 instanceof 保护的分支中,TypeScript 会缩小类型范围。

function logValue(x: Date | string) {
  if (x instanceof Date) {
    console.log(x.toUTCString());
    //          ^?
  } else {
    console.log(x.toUpperCase());
    //          ^?
  }
}

赋值语句

正如我们之前提到的,当我们对任何变量进行赋值时,TypeScript 会查看赋值语句的右侧,并相应地缩小左侧的类型。

let x = Math.random() < 0.5 ? 10 : 'hello world!';
//  ^?
x = 1;

console.log(x);
//          ^?
x = 'goodbye!';

console.log(x);
//          ^?

请注意,每个赋值都有效。尽管在第一次赋值后,x 的观察类型变为 number,但我们仍然可以将 string 值赋值给 x。这是因为 x声明类型x 起始的类型)是 string | number,而可赋值性始终根据声明类型进行检查。

如果我们将 boolean 值赋值给 x,就会看到错误,因为它不是声明类型的一部分。

// @errors: 2322
let x = Math.random() < 0.5 ? 10 : 'hello world!';
//  ^?
x = 1;

console.log(x);
//          ^?
x = true;

console.log(x);
//          ^?

控制流分析

到目前为止,我们已经通过一些基本示例演示了 TypeScript 在特定分支中如何缩小类型。但实际上,TypeScript 并不仅仅是从每个变量开始向上查找类型守卫的 ifwhile 或者条件语句。例如:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === 'number') {
    return ' '.repeat(padding) + input;
  }
  return padding + input;
}

padLeft 在其第一个 if 块中返回。TypeScript 能够分析这段代码,并看到在 paddingnumber 的情况下,函数体的其余部分(return padding + input;)是不可达的。因此,在函数的剩余部分中,它能够将 numberpadding 的类型中移除(将 string | number 缩小为 string)。

这种基于可达性的代码分析称为控制流分析,TypeScript 在遇到类型守卫和赋值时使用这种流分析来缩小类型。分析变量时,控制流可以一次又一次地分裂和重新合并,并且该变量在每个点上都可能具有不同的类型。

function example() {
  let x: string | number | boolean;

  x = Math.random() < 0.5;

  console.log(x);
  //          ^?

  if (Math.random() < 0.5) {
    x = 'hello';
    console.log(x);
    //          ^?
  } else {
    x = 100;
    console.log(x);
    //          ^?
  }

  return x;
  //     ^?
}

使用类型断言

到目前为止,我们已经使用现有的 JavaScript 构造来处理类型缩小,但有时你可能希望更直接地控制代码中的类型变化。

要定义用户自定义的类型守卫,我们只需定义一个返回类型为类型断言的函数:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
declare function getSmallPet(): Fish | Bird;
// ---cut---
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

在这个示例中,pet is Fish 是我们的类型断言。断言采用 parameterName is Type 的形式,其中 parameterName 必须是当前函数签名中的参数名称。

每当使用某个变量调用 isFish 时,TypeScript 将会根据原始类型是否兼容,将该变量缩小为特定类型。

type Fish = { swim: () => void };
type Bird = { fly: () => void };
declare function getSmallPet(): Fish | Bird;
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}
// ---cut---
// “swim”和“fly”的调用现在都没问题。
let pet = getSmallPet();

if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

请注意,TypeScript 不仅知道在 if 分支中 petFish;它还知道在 else 分支中,其并非Fish,所以它肯定是 Bird

你可以使用类型守卫 isFish 来过滤 Fish | Bird 数组,并获得 Fish 数组:

type Fish = { swim: () => void; name: string };
type Bird = { fly: () => void; name: string };
declare function getSmallPet(): Fish | Bird;
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}
// ---cut---
const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// 或者等价地
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];

// 对于更复杂的示例,可能需要重复使用类型断言
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === 'sharkey') return false;
  return isFish(pet);
});

此外,类可以使用 this is Type 来缩小其类型。

断言函数

类型也可以使用断言函数来缩小。

辨识联合类型

到目前为止,我们所讨论的大多数示例都集中在缩小包含简单类型(如 stringbooleannumber)的单个变量上。虽然这很常见,但在 JavaScript 中,我们通常会处理稍微复杂一些的结构。

为了说明这一点,让我们假设我们正在尝试编码圆形和正方形这样的形状。圆形保持其半径,而正方形保持其边长。我们将使用一个名为 kind 的字段来告诉我们正在处理的形状。下面是定义 Shape 的第一次尝试。

interface Shape {
  kind: 'circle' | 'square';
  radius?: number;
  sideLength?: number;
}

请注意,我们使用了字符串字面量类型的联合:“circle” 和 “square”,以告诉我们应该将形状视为圆形还是正方形。通过使用 "circle" | "square" 而不是 string,我们可以避免拼写错误问题。

// @errors: 2367
interface Shape {
  kind: 'circle' | 'square';
  radius?: number;
  sideLength?: number;
}

// ---cut---
function handleShape(shape: Shape) {
  // 出错了!
  if (shape.kind === 'rect') {
    // ...
  }
}

我们可以编写 getArea 函数,根据处理的是圆形还是正方形应用相应的逻辑。我们首先尝试处理圆形。

// @errors: 2532 18048
interface Shape {
  kind: 'circle' | 'square';
  radius?: number;
  sideLength?: number;
}

// ---cut---
function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
}

strictNullChecks 下,这将引发一个错误——这是合适的,因为 radius 可能未定义。但是,如果我们对 kind 属性进行适当的检查,会发生什么呢?

// @errors: 2532 18048
interface Shape {
  kind: 'circle' | 'square';
  radius?: number;
  sideLength?: number;
}

// ---cut---
function getArea(shape: Shape) {
  if (shape.kind === 'circle') {
    return Math.PI * shape.radius ** 2;
  }
}

嗯,TypeScript 仍然不知道该怎么处理这里的情况。我们已经达到了一个我们对值的了解比类型检查器更多的点。我们可以尝试使用非空断言(在 shape.radius 后面加上)来表示 radius 肯定存在。

interface Shape {
  kind: 'circle' | 'square';
  radius?: number;
  sideLength?: number;
}

// ---cut---
function getArea(shape: Shape) {
  if (shape.kind === 'circle') {
    return Math.PI * shape.radius! ** 2;
  }
}

但这并不是理想的解决方法。我们不得不在类型检查器面前大声喊出这些非空断言(!),以说服它 shape.radius 是定义过的,但是如果我们开始调整代码,这些断言就容易出错。此外,在strictNullChecks 之外,我们仍然可以意外访问这些字段(因为在读取它们时,可选属性被假定为始终存在)。我们肯定可以做得更好。

这种 Shape 的编码方式的问题在于,类型检查器无法根据 kind 属性知道 radiussideLength 是否存在。我们需要将我们所了解的信息传达给类型检查器。考虑到这一点,让我们尝试另一种方法来定义 Shape

interface Circle {
  kind: 'circle';
  radius: number;
}

interface Square {
  kind: 'square';
  sideLength: number;
}

type Shape = Circle | Square;

在这里,我们将 Shape 适当地分成了两种类型,这两种类型在 kind 属性上有不同的值,但是 radiussideLength 在各自的类型中被声明为必需属性。

让我们看看当我们尝试访问 Shaperadius 时会发生什么。

// @errors: 2339
interface Circle {
  kind: 'circle';
  radius: number;
}

interface Square {
  kind: 'square';
  sideLength: number;
}

type Shape = Circle | Square;

// ---cut---
function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
}

就像我们对 Shape 的第一个定义一样,这仍然是一个错误。当 radius 是可选的时候,我们遇到了错误(在启用了 strictNullChecks 的情况下),因为 TypeScript 无法确定属性是否存在。现在 Shape 是一个联合类型,TypeScript 告诉我们 shape 可能是一个 Square,而 Square 上没有定义 radius!这两种解释都是正确的,但只要 Shape 是联合类型,无论 strictNullChecks 如何配置,都会导致错误。

但是如果我们再次尝试检查 kind 属性呢?

interface Circle {
  kind: 'circle';
  radius: number;
}

interface Square {
  kind: 'square';
  sideLength: number;
}

type Shape = Circle | Square;

// ---cut---
function getArea(shape: Shape) {
  if (shape.kind === 'circle') {
    return Math.PI * shape.radius ** 2;
    //               ^?
  }
}

这样就消除了错误!当联合类型的每个成员都包含具有字面类型的共同属性时,TypeScript 将其视为可辨识联合,并可以排除联合的成员。

在这种情况下,kind 就是这个共同属性(被认为是 Shape辨识属性)。检查 kind 属性是否为 "circle" 可以排除 Shape 中没有具有类型为 "circle"kind 属性的类型。这将 shape 缩小为类型 Circle

相同的检查也适用于 switch 语句。现在我们可以尝试编写完整的 getArea 函数,而无需使用烦人的 ! 非空断言。

interface Circle {
  kind: 'circle';
  radius: number;
}

interface Square {
  kind: 'square';
  sideLength: number;
}

type Shape = Circle | Square;

// ---cut---
function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    //                 ^?
    case 'square':
      return shape.sideLength ** 2;
    //       ^?
  }
}

这里重要的是对 Shape 的编码。向 TypeScript 传达正确的信息——CircleSquare 实际上是具有特定 kind 字段的两种不同类型——是至关重要的。通过这样做,我们可以编写与我们本来会编写的 JavaScript 没有任何区别的类型安全的 TypeScript 代码。从那里,类型系统能够做出“正确”的事情,并确定我们 switch 语句中每个分支的类型。

顺便说一句,尝试玩弄上面的示例并删除一些 return 关键字。 你会发现,在 switch 语句的不同子句之间意外“掉落”时,类型检查可以帮助避免错误。

可辨识联合不仅适用于描述圆圈和正方形。它们适用于表示 JavaScript 中的任何一种消息方案,例如在网络上发送消息(客户端/服务器通信)或在状态管理框架中编码变更。

never 类型

在缩小类型时,你可以将联合类型的选项减少到没有剩余选项的程度。在这种情况下,TypeScript 将使用 never 类型来表示一个不应存在的状态。

完备性检查

never 类型可以赋值给任何类型;然而,没有类型可以赋值给 never(除了 never 本身)。这意味着你可以使用缩小操作,并依赖于 never 来进行 switch 语句的完备性检查。

例如,在我们的 getArea 函数中添加一个 default 分支,尝试将形状赋值给 never,当处理了所有可能的情况时不会引发错误。

interface Circle {
  kind: 'circle';
  radius: number;
}

interface Square {
  kind: 'square';
  sideLength: number;
}
// ---cut---
type Shape = Circle | Square;

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

如果向 Shape 联合类型添加一个新成员,将会引发 TypeScript 错误:

// @errors: 2322
interface Circle {
  kind: 'circle';
  radius: number;
}

interface Square {
  kind: 'square';
  sideLength: number;
}
// ---cut---
interface Triangle {
  kind: 'triangle';
  sideLength: number;
}

type Shape = Circle | Square | Triangle;

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

函数进阶

函数是任何应用程序的基本构建块,它们可以是本地函数、从另一个模块导入的函数或者类的方法。它们也是值,并且与其他值一样,TypeScript 有很多方法来描述函数的调用方式。让我们来学习如何编写用于描述函数的类型。

函数类型表达式

描述函数的最简单方式是使用函数类型表达式。这些类型在语法上类似于箭头函数:

function greeter(fn: (a: string) => void) {
  fn('Hello, World');
}

function printToConsole(s: string) {
  console.log(s);
}

greeter(printToConsole);

(a: string) => void 的语法表示“有一个名为 a 的参数,a 的类型为 string,且没有返回值的函数”。就像函数声明一样,如果没有指定参数类型,它会隐式地被推断为 any 类型。

注意,参数名是必需的。函数类型 (string) => void 的意思是“这个函数带有一个名为 string 的参数,这个参数的类型为 any”!

当然,我们可以使用类型别名为函数类型命名:

type GreetFunction = (a: string) => void;
function greeter(fn: GreetFunction) {
  // ...
}

调用签名

在 JavaScript 中,函数除了可以被调用之外,还可以有其他属性。然而,函数类型表达式语法不允许声明属性。如果我们想描述带有属性的可调用对象,可以在其对象类型中编写一个调用签名(call signature)

type DescribableFunction = {
  description: string;
  (someArg: number): boolean;
};
function doSomething(fn: DescribableFunction) {
  console.log(fn.description + ' 返回了 ' + fn(6));
}

function myFunc(someArg: number) {
  return someArg > 3;
}
myFunc.description = '默认描述';

doSomething(myFunc);

请注意,其语法与函数类型表达式略有不同——在参数列表和返回类型之间使用 :,而不是 =>

构造签名

JavaScript 函数还可以使用 new 运算符调用。TypeScript 将这些称为“构造函数”,因为它们通常会创建一个新对象。你可以在调用签名前面添加 new 关键字,以编写一个“构造签名”:

type SomeObject = any;
// ---cut---
type SomeConstructor = {
  new (s: string): SomeObject;
};
function fn(ctor: SomeConstructor) {
  return new ctor('你好');
}

某些对象,比如 JavaScript 的 Date 对象,既可以在使用也可以在不使用 new 的情况下调用。你可以任意组合调用和构造签名在同一类型中:

interface CallOrConstruct {
  new (s: string): Date;
  (n?: number): string;
}

泛型函数

通常我们会编写一些函数,其中输入的类型与输出的类型相关联,或者两个输入的类型以某种方式相关联。让我们考虑一个返回数组的第一个元素的函数:

function firstElement(arr: any[]) {
  return arr[0];
}

这个函数完成了它的工作,但不太好的是它的返回类型是 any。如果函数返回数组元素的类型会更好。

在 TypeScript 中,当我们想要描述两个值之间的对应关系时,我们使用泛型。我们可以在函数签名中声明类型参数

function firstElement<Type>(arr: Type[]): Type | undefined {
  return arr[0];
}

通过在函数中添加类型参数 Type,并在两个地方使用它,我们在函数的输入(数组)和输出(返回值)之间建立了一个链接。现在当我们调用它时,会得到更具体的类型:

declare function firstElement<Type>(arr: Type[]): Type | undefined;
// ---cut---
// s 的类型是 'string'
const s = firstElement(['a', 'b', 'c']);
// n 的类型是 'number'
const n = firstElement([1, 2, 3]);
// u 的类型是 undefined
const u = firstElement([]);

类型推断

请注意,在这个示例中我们不必指定 Type。TypeScript 会自动推断类型。

我们也可以使用多个类型参数。例如,map 函数的独立版本如下:

// prettier-ignore
function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): Output[] {
  return arr.map(func);
}

// 参数 'n' 的类型是 'string'
// 'parsed' 的类型是 'number[]'
const parsed = map(['1', '2', '3'], n => parseInt(n));

请注意,在这个示例中,TypeScript 可以(根据给定的 string 数组)推断出 Input 类型参数的类型,同时根据函数表达式的返回值(number)推断出 Output 类型参数的类型。

约束

我们编写了一些泛型函数,可以适用于任何类型的值。有时候我们想要关联两个值,但只能对某个子集的类型的值进行操作。在这种情况下,我们可以使用约束来限制类型参数可以接受的类型的子集。

让我们编写一个返回两个值中较长的值的函数。为了做到这一点,我们需要值属于具有 length 属性的类型。我们通过编写 extends 子句将类型参数约束为该类型:

// @errors: 2345 2322
function longest<Type extends { length: number }>(a: Type, b: Type) {
  if (a.length >= b.length) {
    return a;
  } else {
    return b;
  }
}

// longerArray 的类型为 'number[]'
const longerArray = longest([1, 2], [1, 2, 3]);
// longerString 的类型为 'alice' | 'bob'
const longerString = longest('alice', 'bob');
// 错误!数字没有 'length' 属性
const notOK = longest(10, 100);

这个例子中有几个有趣的地方。我们允许 TypeScript 推断 longest 的返回类型。返回类型推断也适用于泛型函数。

由于我们将 Type 约束为 { length: number },我们可以访问 ab 参数的 .length 属性。如果没有类型约束,我们将无法访问这些属性,因为这些值可能是没有 length 属性的其他类型。

longerArraylongerString 的类型是基于参数推断的。记住,泛型主要是关于将两个或多个值与相同类型进行关联!

最后,正如我们所希望的,调用 longest(10, 100) 被拒绝,因为 number 类型没有 .length 属性。

使用受限值

在处理泛型约束时,以下是一个常见的错误:

// @errors: 2322
function minimumLength<Type extends { length: number }>(
  obj: Type,
  minimum: number
): Type {
  if (obj.length >= minimum) {
    return obj;
  } else {
    return { length: minimum };
  }
}

这个函数看起来可能没问题——Type 被约束为 { length: number },而函数要么返回 Type 类型的值,要么返回与该约束相匹配的值。问题在于该函数承诺返回与传入的对象相同类型的对象,而不仅仅是与约束匹配的任意对象。如果这段代码可以通过检查,那么你可以编写肯定不起作用的代码:

declare function minimumLength<Type extends { length: number }>(
  obj: Type,
  minimum: number
): Type;
// ---cut---
// 'arr' 得到值 { length: 6 }
const arr = minimumLength([1, 2, 3], 6);
// 然后在这里崩溃,因为数组有一个 'slice' 方法,但返回的对象没有!
console.log(arr.slice(0));

指定类型参数

TypeScript 通常可以推断出泛型函数调用中的类型参数,但并非总是如此。例如,假设你编写了一个函数来合并两个数组:

function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
  return arr1.concat(arr2);
}

通常情况下,如果使用不匹配的数组调用该函数,会产生一个错误:

// @errors: 2322
declare function combine<Type>(arr1: Type[], arr2: Type[]): Type[];
// ---cut---
const arr = combine([1, 2, 3], ['hello']);

然而,如果你打算这样做,可以手动指定 Type

declare function combine<Type>(arr1: Type[], arr2: Type[]): Type[];
// ---cut---
const arr = combine<string | number>([1, 2, 3], ['hello']);

编写良好的泛型函数的指南

编写泛型函数很有趣,但很容易过度使用类型参数。如果类型参数过多或在不需要的情况下使用约束,可能会导致类型推断不成功,从而使函数调用变得困难。

将类型参数往下推

以下是两种看似相似的函数编写方式:

function firstElement1<Type>(arr: Type[]) {
  return arr[0];
}

function firstElement2<Type extends any[]>(arr: Type) {
  return arr[0];
}

// a: number(好)
const a = firstElement1([1, 2, 3]);
// b: any(差)
const b = firstElement2([1, 2, 3]);

这两个函数乍一看可能相同,但是 firstElement1 是编写该函数的更好方式。它的推断返回类型是 Type,但是 firstElement2 的推断返回类型是 any,因为 TypeScript 必须使用约束类型解析 arr[0] 表达式,而不是在调用期间“等待”解析元素。

规则:在可能的情况下,使用类型参数本身而不是对其进行约束。

使用较少的类型参数

以下是另一对类似的函数:

function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] {
  return arr.filter(func);
}

function filter2<Type, Func extends (arg: Type) => boolean>(
  arr: Type[],
  func: Func
): Type[] {
  return arr.filter(func);
}

我们创建了一个类型参数 Func,它没有将任何两个值进行关联。这总是一个警告信号,因为这意味着调用者想要指定类型参数时,必须手动为无关的类型参数指定额外的类型参数。Func 没有任何用处,只是让函数变得更难阅读和理解!

规则:使用尽可能少的类型参数。

类型参数应该出现两次

有时候我们会忘记函数可能没有必要是泛型的:

function greet<Str extends string>(s: Str) {
  console.log('你好,' + s);
}

greet('世界');

我们也可以写一个更简单的版本:

function greet(s: string) {
  console.log('你好,' + s);
}

记住,类型参数是用于关联多个值的类型。如果类型参数在函数签名中只被使用一次,它就没有在关联任何内容。这包括推断的返回类型;例如,如果 Strgreet 的推断返回类型的一部分,它将关联参数和返回类型,因此在写入的代码中只出现一次,但实际上使用了两次。

规则:如果一个类型参数只出现在一个位置,请仔细考虑是否真的需要它。

可选参数

JavaScript 中的函数通常可以接受可变数量的参数。例如,numbertoFixed 方法接受一个可选的数字位数:

function f(n: number) {
  console.log(n.toFixed()); // 0 个参数
  console.log(n.toFixed(3)); // 1 个参数
}

我们可以在 TypeScript 中使用 ? 将参数标记为可选

function f(x?: number) {
  // ...
}
f(); // 可以
f(10); // 可以

尽管参数的类型被指定为 number,但是因为 JavaScript 中未指定的参数其值被当作 undefined,所以 x 参数实际上具有类型 number | undefined

你还可以提供参数的默认值

function f(x = 10) {
  // ...
}

现在在 f 的函数体中,x 将具有类型 number,因为任何 undefined 的参数将被替换为 10。请注意,如果参数是可选的,调用者始终可以传递 undefined,因为这只是模拟了一个“缺失”的参数:

declare function f(x?: number): void;
// cut
// 全部正常
f();
f(10);
f(undefined);

回调函数中的可选参数

一旦你了解了可选参数和函数类型表达式,编写调用回调函数的函数时很容易犯以下错误:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i);
  }
}

当把 index? 作为可选参数时,人们通常希望这两种调用都是合法的:

// @errors: 2532 18048
declare function myForEach(
  arr: any[],
  callback: (arg: any, index?: number) => void
): void;
// ---cut---
myForEach([1, 2, 3], a => console.log(a));
myForEach([1, 2, 3], (a, i) => console.log(a, i));

然而,实际上这样的话 callback 只可能会被传递一个参数。换句话说,函数定义表示其实现可能如下所示:

// @errors: 2532 18048
function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    // 我今天不想提供 index
    callback(arr[i]);
  }
}

然后,TypeScript 将强制执行这个含义,并发出实际上不可能的错误:

// @errors: 2532 18048
declare function myForEach(
  arr: any[],
  callback: (arg: any, index?: number) => void
): void;
// ---cut---
myForEach([1, 2, 3], (a, i) => {
  console.log(i.toFixed());
});

在 JavaScript 中,如果你用比参数多的实参调用一个函数,多余的实参会被忽略。TypeScript 的行为也是一样的。参数较少(类型相同)的函数总是可以替代参数较多的函数。

规则:在编写回调函数的函数类型时,除非你打算在调用函数时不传递该参数,否则永远不要编写可选参数。

函数重载

某些 JavaScript 函数可以以不同数量或类型的实参进行调用。例如,你可以编写函数来创建 Date 对象,它既可以接受时间戳作为参数(一个实参),也可以接受月份/日期/年份作为参数(三个实参)。

在 TypeScript 中,我们可以通过编写重载签名来指定可以以不同方式调用的函数。为此,我们先编写一些函数签名(通常是两个或更多),然后再编写函数的具体实现:

// @errors: 2575
function makeDate(timestamp: number): Date;
function makeDate(m: number, d: number, y: number): Date;
function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
  if (d !== undefined && y !== undefined) {
    return new Date(y, mOrTimestamp, d);
  } else {
    return new Date(mOrTimestamp);
  }
}
const d1 = makeDate(12345678);
const d2 = makeDate(5, 5, 5);
const d3 = makeDate(1, 3);

在这个示例中,我们编写了两个重载:一个接受一个参数,另一个接受三个参数。这两个签名被称为重载签名

然后,我们编写了一个具体的函数实现,其签名与重载签名是兼容的。函数有一个具体实现签名,但这个签名不能直接调用。尽管我们在函数必需的参数后面写了两个可选参数,但它不能用两个参数调用!

重载签名和具体实现签名

这是一个常见的困惑来源。通常人们会编写这样的代码,并不理解为什么会出错:

// @errors: 2554
function fn(x: string): void;
function fn() {
  // ...
}
// 期望可以使用零个参数调用
fn();

同样,函数体的签名在外部是“看”不到的。

外部无法看到具体实现的签名。 当编写重载函数时,你应该始终在函数实现之前编写两个或更多的签名。

具体实现的签名也必须与重载签名兼容。例如,下面的函数存在错误,因为具体实现的签名与重载签名不匹配:

// @errors: 2394
function fn(x: boolean): void;
// 参数类型不正确
function fn(x: string): void;
function fn(x: boolean) {}
// @errors: 2394
function fn(x: string): string;
// 返回类型不正确
function fn(x: number): boolean;
function fn(x: string | number) {
  return 'oops';
}

编写良好的重载函数

与泛型一样,使用函数重载时应遵循一些准则。遵循这些原则将使你的函数更易于调用、理解和实现。

让我们考虑一个返回字符串或数组的长度的函数:

function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
  return x.length;
}

这个函数是无错误的;我们可以用字符串或数组调用它。然而,我们不能用可能是字符串数组的值调用它,因为 TypeScript 只能解析函数调用为单个重载:

// @errors: 2769
declare function len(s: string): number;
declare function len(arr: any[]): number;
// ---cut---
len(''); // OK
len([0]); // OK
len(Math.random() > 0.5 ? 'hello' : [0]);

由于两个重载具有相同的参数数量和相同的返回类型,我们可以使用非重载版本的函数:

function len(x: any[] | string) {
  return x.length;
}

这好多了!调用者可以使用任一类型的值调用它,而且作为额外的好处,我们不必找出一个正确的实现签名。

尽可能使用带有联合类型的参数,而不是重载

在函数中声明 this

TypeScript 通过代码流分析推断出函数中的 this 应该是什么,例如在下面的代码中:

const user = {
  id: 123,

  admin: false,
  becomeAdmin: function () {
    this.admin = true;
  },
};

TypeScript 理解到 user.becomeAdmin 函数有一个对应的 this,即外部对象 userthis 在很多情况下已经足够,但也有很多情况下你需要更多地掌控 this 表示的对象。JavaScript 规范规定你不能有一个名为 this 的参数,因此 TypeScript 使用该语法空间来让你在函数体中声明 this 的类型。

interface User {
  id: number;
  admin: boolean;
}
declare const getDB: () => DB;
// ---cut---
interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}

const db = getDB();
const admins = db.filterUsers(function (this: User) {
  return this.admin;
});

这种模式在回调式 API 中很常见,其中另一个对象通常控制何时调用你的函数。请注意,你需要使用 function 而不是箭头函数来获得这种行为:

// @errors: 7041 7017
interface User {
  id: number;
  isAdmin: boolean;
}
declare const getDB: () => DB;
// ---cut---
interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}

const db = getDB();
const admins = db.filterUsers(() => this.admin);

其他需要了解的类型

在处理函数类型时,有一些额外的类型需要你认识。虽然所有类型都可以在任何地方使用,但这些类型在函数的上下文中尤其相关。

void

void 表示不返回任何值的函数的返回类型。当一个函数没有任何 return 语句,或者 return 语句中没有明确的返回值时,它是推断出的返回类型。

// 推断的返回类型是 void
function noop() {
  return;
}

在 JavaScript 中,不返回任何值的函数会隐式返回 undefined 值。然而,在 TypeScript 中,voidundefined 并不相同。有关此问题的更多细节将会在本章末尾介绍。

voidundefined 不是相同的类型。

object

特殊类型 object 指代任何非原始类型(stringnumberbigintbooleansymbolnullundefined)的值。这与 空对象类型 { } 不同,也不同于全局类型 Object。你可能永远不会使用 Object

object 不是 Object。请总是使用 object

需要注意的是,在 JavaScript 中,函数值也是对象:它们具有属性,在原型链中包含 Object.prototype,是 instanceof Object,可以对它们调用 Object.keys 等等。因此,在 TypeScript 中,函数类型被认为是 object 类型。

unknown

unknown 类型表示任意值。这与 any 类型类似,但更安全,因为无法对 unknown 值进行任何操作:

// @errors: 2571 18046
function f1(a: any) {
  a.b(); // OK
}
function f2(a: unknown) {
  a.b();
}

在描述函数类型时,这有大作用,因为你可以描述接受任意值的函数,而不需要在函数体中使用 any 值。

相反地,你可以描述返回未知类型值的函数:

declare const someRandomString: string;
// ---cut---
function safeParse(s: string): unknown {
  return JSON.parse(s);
}

// 需要小心处理 'obj'!
const obj = safeParse(someRandomString);

never

有些函数永远不会返回值:

function fail(msg: string): never {
  throw new Error(msg);
}

never 类型表示永远不会观察到的值。在返回类型中,这意味着函数会抛出异常或终止程序的执行。

当 TypeScript 确定联合类型中没有剩余的选项时,也会出现 never

function fn(x: string | number) {
  if (typeof x === 'string') {
    // 做一些操作
  } else if (typeof x === 'number') {
    // 做另一些操作
  } else {
    x; // 的类型为 'never'!
  }
}

Function

全局类型 Function 描述了 JavaScript 中所有函数值的属性,如 bindcallapply 等。它还具有特殊属性,这些属性 Function 类型的值总是可以调用;这些调用返回 any

function doSomething(f: Function) {
  return f(1, 2, 3);
}

这是一个无类型的函数调用,一般最好避免使用,因为它具有不安全的 any 返回类型。

如果你需要接受任意函数但不打算调用它,类型 () => void 通常更安全。

剩余参数和剩余实参

背景阅读:
剩余参数
Spread Syntax

剩余参数

除了使用可选参数或重载来创建可以接受各种固定参数数量的函数之外,我们还可以使用剩余参数定义可以接受不确定数量实参的函数。

剩余参数位于其他参数之后,使用 ... 语法:

function multiply(n: number, ...m: number[]) {
  return m.map(x => n * x);
}
// 'a' 的值为 [10, 20, 30, 40]
const a = multiply(10, 1, 2, 3, 4);

在 TypeScript 中,这些参数的类型注解隐式地是 any[] 而不是 any,而且任何给定的类型注解必须是 Array<T>T[] 的形式,或者是元组类型(我们稍后会学习到元组类型)。

剩余实参

相反地,我们可以使用扩展语法从可迭代对象(例如数组)中提供可变数量的实参。例如,数组的 push 方法接受任意数量的实参:

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
arr1.push(...arr2);

请注意,通常情况下,TypeScript 不会假设数组是不可变的。这可能会导致一些令人惊讶的行为:

// @errors: 2556
// 推断的类型是 number[]——“一个包含零个或多个数字的数组”,而不是特定的两个数字
const args = [8, 5];
const angle = Math.atan2(...args);

对于这种情况,最佳解决方案有点依赖于你的代码,但通常来说,在 const 上下文中是最直接的解决方案:

// 推断为长度为 2 的元组
const args = [8, 5] as const;
// OK
const angle = Math.atan2(...args);

在使用剩余参数时,可能需要在针对较旧的运行时环境时启用 downlevelIteration

参数解构

背景阅读:
解构赋值

你可以使用参数解构将作为参数提供的对象方便地解构到函数体中的一个或多个局部变量中。在 JavaScript 中,它的样子如下:

function sum({ a, b, c }) {
  console.log(a + b + c);
}
sum({ a: 10, b: 3, c: 9 });

对象的类型注解位于解构语法之后:

function sum({ a, b, c }: { a: number; b: number; c: number }) {
  console.log(a + b + c);
}

这可能看起来有点冗长,但你也可以在这里使用命名类型:

// 与之前的示例相同
type ABC = { a: number; b: number; c: number };
function sum({ a, b, c }: ABC) {
  console.log(a + b + c);
}

函数的可赋值性

返回类型为 void

对于返回类型为 void 的函数,它们可能会产生一些不寻常但是符合预期的行为。

使用返回类型为 void 的上下文类型并不会强制函数返回任何值。换句话说,当实现一个带有 void 返回类型的上下文函数类型(type voidFunc = () => void)时,它可以返回任何其他值,但是该返回值会被忽略。

因此,以下 () => void 类型的实现是有效的:

type voidFunc = () => void;

const f1: voidFunc = () => {
  return true;
};

const f2: voidFunc = () => true;

const f3: voidFunc = function () {
  return true;
};

当将其中一个函数的返回值赋给另一个变量时,它将保持 void 类型:

type voidFunc = () => void;

const f1: voidFunc = () => {
  return true;
};

const f2: voidFunc = () => true;

const f3: voidFunc = function () {
  return true;
};
// ---cut---
const v1 = f1();

const v2 = f2();

const v3 = f3();

这个行为的存在使得以下代码是有效的,即使 Array.prototype.push 返回一个数字,而 Array.prototype.forEach 方法期望的是一个返回类型为 void 的函数。

const src = [1, 2, 3];
const dst = [0];

src.forEach(el => dst.push(el));

还有另一种特殊情况需要注意,当字面函数定义的返回类型为 void 时,该函数必须返回任何内容。

function f2(): void {
  // @ts-expect-error
  return true;
}

const f3 = function (): void {
  // @ts-expect-error
  return true;
};

有关 void 的更多信息,请参考以下其他文档条目:

对象类型

在 JavaScript 中,对象是我们最基本的组织和传递数据的方式。在 TypeScript 中,我们通过对象类型来表示它们。

正如我们所见,它们可以是匿名的:

function greet(person: { name: string; age: number }) {
  //                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  return "Hello " + person.name;
}

或者可以通过接口来命名:

interface Person {
  //      ^^^^^^
  name: string;
  age: number;
}

function greet(person: Person) {
  return "Hello " + person.name;
}

或者使用类型别名来命名:

type Person = {
  // ^^^^^^
  name: string;
  age: number;
};

function greet(person: Person) {
  return "Hello " + person.name;
}

在上面的三个示例中,我们编写了接受包含属性 name(必须是 string 类型)和 age(必须是 number 类型)的对象的函数。

快速参考

我们为 typeinterface 都提供了备忘单,如果你想快速查看重要的常用语法,可以看一下。

属性修饰符

对象类型中的每个属性可以指定一些内容:类型、属性是否可选以及属性是否可写。

可选属性

大部分情况下,我们处理的对象可能会有某些属性设置。在这种情况下,我们可以通过在属性名称末尾添加问号(?)来将这些属性标记为可选

interface Shape {}
declare function getShape(): Shape;

// ---cut---
interface PaintOptions {
  shape: Shape;
  xPos?: number;
  //  ^
  yPos?: number;
  //  ^
}

function paintShape(opts: PaintOptions) {
  // ...
}

const shape = getShape();
paintShape({ shape });
paintShape({ shape, xPos: 100 });
paintShape({ shape, yPos: 100 });
paintShape({ shape, xPos: 100, yPos: 100 });

在此示例中,xPosyPos 都被视为可选的。我们可以选择提供其中任意一个,因此上面对 paintShape 的每个调用都是有效的。可选性实际上表示,如果属性被设置,它必须具有特定的类型。

我们也可以读取这些属性的值——但是在 strictNullChecks 下,TypeScript 会告诉我们它们可能是 undefined

interface Shape {}
declare function getShape(): Shape;

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}

// ---cut---
function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos;
  //              ^?
  let yPos = opts.yPos;
  //              ^?
  // ...
}

在 JavaScript 中,即使属性从未被设置,我们仍然可以访问它——它只会给我们返回 undefined 的值。我们只需要通过检查 undefined 来特殊处理它。

interface Shape {}
declare function getShape(): Shape;

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}

// ---cut---
function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos === undefined ? 0 : opts.xPos;
  //  ^?
  let yPos = opts.yPos === undefined ? 0 : opts.yPos;
  //  ^?
  // ...
}

需要注意的是,设置未指定值的默认值的这种模式非常常见,JavaScript 提供了相应的语法来支持它。

interface Shape {}
declare function getShape(): Shape;

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}

// ---cut---
function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
  console.log("x 坐标为", xPos);
  //                      ^?
  console.log("y 坐标为", yPos);
  //                      ^?
  // ...
}

在这里,我们使用了解构赋值模式 来定义 paintShape 的参数,并为 xPosyPos 提供了默认值。现在,在 paintShape 函数体内,xPosyPos 都是必然存在的,但对于 paintShape 的调用者来说是可选的。

注意,目前无法在解构赋值模式中放置类型注解。 这是因为在 JavaScript 中,以下语法已经具有不同的含义。

// @noImplicitAny: false
// @errors: 2552 2304
interface Shape {}
declare function render(x: unknown);
// ---cut---
function draw({ shape: Shape, xPos: number = 100 /*...*/ }) {
  render(shape);
  render(xPos);
}

在对象解构赋值模式中,shape: Shape 的意思是“获取属性 shape 并在本地重新定义为名为 Shape 的变量。 同样,xPos: number 创建一个名为 number 的变量,其值基于参数的 xPos

只读属性

在 TypeScript 中,属性也可以标记为 readonly。虽然在运行时不会改变任何行为,但标记为 readonly 的属性在类型检查期间无法被写入。

// @errors: 2540
interface SomeType {
  readonly prop: string;
}

function doSomething(obj: SomeType) {
  // 我们可以读取‘obj.prop’的值。
  console.log(`prop 的值为 '${obj.prop}'。`);

  // 但是我们无法重新赋值。
  obj.prop = "hello";
}

使用 readonly 修饰符并不一定意味着一个值是完全不可变的,或者换句话说,它的内部内容无法改变。它只是表示该属性本身无法被改变。

// @errors: 2540
interface Home {
  readonly resident: { name: string; age: number };
}

function visitForBirthday(home: Home) {
  // 我们可以读取和更新‘home.resident’的属性。
  console.log(`生日快乐,${home.resident.name}!`);
  home.resident.age++;
}

function evict(home: Home) {
  // 但是我们无法直接写入‘Home’的‘resident’属性本身。
  home.resident = {
    name: "Victor the Evictor",
    age: 42,
  };
}

适当调整对 readonly 的预期非常重要。在开发期间,它有助于 TypeScript 明确对象的使用方式。当检查两种类型是否兼容时,TypeScript 不会考虑这两种类型的属性是否为 readonly,所以通过别名,readonly 属性也可以发生变化。

interface Person {
  name: string;
  age: number;
}

interface ReadonlyPerson {
  readonly name: string;
  readonly age: number;
}

let writablePerson: Person = {
  name: "Person McPersonface",
  age: 42,
};

// 可行
let readonlyPerson: ReadonlyPerson = writablePerson;

console.log(readonlyPerson.age); // 输出 '42'
writablePerson.age++;
console.log(readonlyPerson.age); // 输出 '43'

使用映射修饰符,可以去除 readonly 特性。

索引签名

有时候你预先并不知道所有属性的名称,但是你知道这些值的大致信息。

在这种情况下,你可以使用索引签名来描述可能的值类型,例如:

declare function getStringArray(): StringArray;
// ---cut---
interface StringArray {
  [index: number]: string;
}

const myArray: StringArray = getStringArray();
const secondItem = myArray[1];
//     ^?

上面的例子中,我们有一个 StringArray 接口,它具有一个索引签名。这个索引签名表示当使用 number 值对 StringArray 进行索引时,它将返回 string 类型的值。

索引签名属性只允许某些类型:stringnumbersymbol、模板字符串模式,以及只包含这些类型的联合类型。

它是可以同时支持两种类型的索引器的...

它是可以同时支持两种类型的索引器的,但是数字索引器返回的类型必须是字符串索引器返回类型的子类型。这是因为在使用 number 进行索引时,JavaScript 实际上会将其转换为 string,然后再对对象进行索引。这意味着使用 100(一个 `number`)进行索引与使用 "100"(一个 string)进行索引是一样的,所以两者需要保持一致。

// @errors: 2413
// @strictPropertyInitialization: false
interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

// 错误:使用数字字符串进行索引可能会得到一个完全不同类型的 Animal!
interface NotOkay {
  [x: number]: Animal;
  [x: string]: Dog;
}

虽然字符串索引签名是描述“字典”模式的强大方式,但它也强制要求所有属性与它们的返回类型匹配。这是因为字符串索引声明了 obj.property 也可以使用 obj["property"] 访问。在下面的例子中,name 的类型与字符串索引的类型不匹配,类型检查器会报错:

// @errors: 2411
// @errors: 2411
interface NumberDictionary {
  [index: string]: number;

  length: number; // 可行
  name: string;
}

然而,如果索引签名是属性类型的联合类型,不同类型的属性是可以接受的:

interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // 可行,length 是一个数字
  name: string; // 可行,name 是一个字符串
}

最后,你可以将索引签名设置为 readonly,以防止对索引项进行赋值:

declare function getReadOnlyStringArray(): ReadonlyStringArray;
// ---cut---
// @errors: 2542
interface ReadonlyStringArray {
  readonly [index: number]: string;
}

let myArray: ReadonlyStringArray = getReadOnlyStringArray();
myArray[2] = "Mallory";

你不能设置 myArray[2],因为索引签名是 readonly 的。

多余属性检查

对象被赋予类型的位置和方式会对类型系统产生影响。其中一个关键例子是多余属性检查(excess property checking),它在对象创建并赋值给对象类型时更加彻底地验证对象。

// @errors: 2345 2739
interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  return {
    color: config.color || "red",
    area: config.width ? config.width * config.width : 20,
  };
}

let mySquare = createSquare({ colour: "red", width: 100 });

注意,传递给 createSquare 的参数中将 color 拼写为 colour 而不是 color。在普通的 JavaScript 中,这种情况会悄无声息地失败。

你可以认为这个程序是正确类型化的,因为 width 属性是兼容的,没有 color 属性存在,并且额外的 colour 属性是无关紧要的。

然而,TypeScript 认为这段代码可能存在 bug。对象字面量在赋值给其他变量或作为实参传递时会经历额外的属性检查。如果对象字面量具有任何目标类型不具备的属性,就会产生错误:

// @errors: 2345 2739
interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  return {
    color: config.color || "red",
    area: config.width ? config.width * config.width : 20,
  };
}
// ---cut---
let mySquare = createSquare({ colour: "red", width: 100 });

绕过这些检查实际上非常简单。最简单的方法是使用类型断言:

// @errors: 2345 2739
interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  return {
    color: config.color || "red",
    area: config.width ? config.width * config.width : 20,
  };
}
// ---cut---
let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);

然而,如果你确定该对象可以具有一些额外的属性,并且这些属性在某种特殊方式下使用,一种更好的方法是在对象上添加字符串索引签名。如果 SquareConfig 可以具有上述类型的 colorwidth 属性,但可以具有任意数量的其他属性,那么我们可以这样定义它:

interface SquareConfig {
  color?: string;
  width?: number;
  [propName: string]: any;
}

在这里,我们表示 SquareConfig 可以具有任意数量的属性,只要它们不是 colorwidth,它们的类型就无关紧要。

最后一种绕过这些检查的方式可能有点令人惊讶,那就是将对象赋值给另一个变量:由于对 squareOptions 进行赋值不会进行多余属性检查,编译器不会报错:

interface SquareConfig {
  color?: string;
  width?: number;
  [propName: string]: any;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  return {
    color: config.color || "red",
    area: config.width ? config.width * config.width : 20,
  };
}
// ---cut---
let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);

上述解决方法只适用于 squareOptionsSquareConfig 之间存在公共属性的情况。在这个例子中,公共属性是 width。然而,如果变量没有任何公共对象属性,这种解决方法将失败。例如:

// @errors: 2559
interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  return {
    color: config.color || "red",
    area: config.width ? config.width * config.width : 20,
  };
}
// ---cut---
let squareOptions = { colour: "red" };
let mySquare = createSquare(squareOptions);

请记住,对于上述简单的代码,你最好不应该试图绕过这些检查。对于具有方法和状态的更复杂的对象字面量,你可能需要牢记这些技巧,但是绝大多数多余属性错误实际上是 bug。

这意味着,如果你在处理诸如选项包(option bags)之类的问题时遇到多余属性检查问题,你可能需要重新检查一些类型声明。在这种情况下,如果将同时具有 colorcolour 属性的对象传递给 createSquare 是允许的,那么你应该修正 SquareConfig 的定义以反映这一点。

拓展类型

在类型系统中,有时候会存在一些更具体版本的类型。例如,我们可能有一个 BasicAddress 类型,用于描述在美国发送信函和包裹所需的字段。

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

在某些情况下,这已经足够了,但是地址经常会有一个单元号与之关联,比如某个地址对应的建筑物有多个单元。我们可以描述 AddressWithUnit 类型。

interface AddressWithUnit {
  name?: string;
  unit: string;
//^^^^^^^^^^^^^
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

这样做是可以的,但是这里的缺点是,我们不得不在我们的更改中重复所有其他来自 BasicAddress 的字段,然而我们想要做的更改只是简单地添加。相反,我们可以扩展原始的 BasicAddress 类型来达到同样的效果,这样只需添加唯一属于 AddressWithUnit 的新字段就可以了。

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

interface AddressWithUnit extends BasicAddress {
  unit: string;
}

interface 上使用 extends 关键字可以让我们有效地复制其他命名类型的成员,并添加任何我们想要的新成员。这可以减少我们必须编写的类型声明的样板代码量,并且可以表明多个对同一属性的不同声明可能相关联。例如,AddressWithUnit 不需要重复 street 属性,并且因为 street 来源于 BasicAddress,读者会知道这两个类型在某种程度上是相关的。

interface 也可以从多个类型进行扩展。

interface Colorful {
  color: string;
}

interface Circle {
  radius: number;
}

interface ColorfulCircle extends Colorful, Circle {}

const cc: ColorfulCircle = {
  color: "red",
  radius: 42,
};

交叉类型

在 TypeScript 中,除了使用 interface 来扩展已有类型外,还提供了另一种构造方式,称为交叉类型(intersection types),主要用于组合现有的对象类型。

交叉类型使用 & 运算符进行定义。

interface Colorful {
  color: string;
}
interface Circle {
  radius: number;
}

type ColorfulCircle = Colorful & Circle;

在这个例子中,我们对 ColorfulCircle 进行了交叉,生成了新类型,该类型具有 Colorful Circle 的所有成员。

// @errors: 2345
interface Colorful {
  color: string;
}
interface Circle {
  radius: number;
}
// ---cut---
function draw(circle: Colorful & Circle) {
  console.log(`颜色是:${circle.color}`);
  console.log(`半径是:${circle.radius}`);
}

// 正常
draw({ color: "蓝", radius: 42 });

// 错误
draw({ color: "红", raidus: 42 });

接口 vs. 交叉类型

我们刚刚讨论了两种将相似但实际上略有不同的类型组合在一起的方法。使用接口,我们可以使用 extends 子句从其他类型进行扩展,而交叉类型后给结果起类型别名也与之相似,并且我们可以。两者之间的主要区别在于如何处理冲突,而这种区别通常是你选择接口还是交叉类型的主要依据之一。

泛型对象类型

让我们想象 Box 类型,它可以包含任何值——string 值、number 值、Giraffe 值,或者其他任何类型的值。

interface Box {
  contents: any;
}

目前,contents 属性的类型为 any,这样也不是不能工作,但可能会在后续操作中导致错误。

我们可以使用 unknown,但这意味着在我们已经知道 contents 的类型的情况下,我们需要进行预防性检查,或者使用容易出错的类型断言。

interface Box {
  contents: unknown;
}

let x: Box = {
  contents: "hello world",
};

// 我们可以检查‘x.contents’
if (typeof x.contents === "string") {
  console.log(x.contents.toLowerCase());
}

// 或者我们可以使用类型断言
console.log((x.contents as string).toLowerCase());

一种类型安全的方法是为每种类型的 contents 创建不同的 Box 类型。

// @errors: 2322
interface NumberBox {
  contents: number;
}

interface StringBox {
  contents: string;
}

interface BooleanBox {
  contents: boolean;
}

但这样的话,我们将不得不创建不同的函数或函数的重载来操作这些类型。

interface NumberBox {
  contents: number;
}

interface StringBox {
  contents: string;
}

interface BooleanBox {
  contents: boolean;
}
// ---cut---
function setContents(box: StringBox, newContents: string): void;
function setContents(box: NumberBox, newContents: number): void;
function setContents(box: BooleanBox, newContents: boolean): void;
function setContents(box: { contents: any }, newContents: any) {
  box.contents = newContents;
}

有很多样板代码。而且,以后我们可能需要引入新的类型和重载。这很令人沮丧,因为我们的盒子类型和重载实际上是相同的。

相反,我们可以创建声明类型参数泛型 Box 类型。

interface Box<Type> {
  contents: Type;
}

你可以将其理解为“Type 类型的 Box 是具有类型为 Typecontents 的东西”。在稍后引用 Box 时,我们必须在 Type 的位置上给出一个类型参数

interface Box<Type> {
  contents: Type;
}
// ---cut---
let box: Box<string>;

Box 视为一个真实类型的模板,其中 Type 是一个占位符,将被替换为其他类型。当 TypeScript 看到 Box<string> 时,它将用 string 替换 Box<Type> 中的每个 Type 实例,最终使用类似 { contents: string } 的东西进行处理。换句话说,Box<string> 和我们之前的 StringBox 完全相同。

interface Box<Type> {
  contents: Type;
}
interface StringBox {
  contents: string;
}

let boxA: Box<string> = { contents: "hello" };
boxA.contents;
//   ^?

let boxB: StringBox = { contents: "world" };
boxB.contents;
//   ^?

Box 是可重用的,因为 Type 可以替换为任何类型。这意味着当我们需要一个新类型的盒子时,我们根本不需要声明新的 Box 类型(尽管如果我们愿意,确实可以声明新的类型)。

interface Box<Type> {
  contents: Type;
}

interface Apple {
  // ....
}

// 等同于‘{ contents: Apple }’。
type AppleBox = Box<Apple>;

这也意味着我们可以通过使用泛型函数来完全避免重载。

interface Box<Type> {
  contents: Type;
}

// ---cut---
function setContents<Type>(box: Box<Type>, newContents: Type) {
  box.contents = newContents;
}

值得注意的是,类型别名也可以是泛型的。假如我们有 Box<Type> 接口,它是:

interface Box<Type> {
  contents: Type;
}

可以使用类型别名来替代:

type Box<Type> = {
  contents: Type;
};

由于类型别名不像接口那样只能描述对象类型,因此我们还可以使用它们来编写其他类型的通用辅助类型。

// @errors: 2575
type OrNull<Type> = Type | null;

type OneOrMany<Type> = Type | Type[];

type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;
//   ^?

type OneOrManyOrNullStrings = OneOrManyOrNull<string>;
//   ^?

稍后我们会回到类型别名。

Array 类型

泛型对象类型通常是独立于其包含元素类型的容器类型。这样设计数据结构可以使其在不同的数据类型之间可重用。

事实上,在整个手册中我们一直在使用一种类似的类型:Array 类型。当我们写出像 number[]string[] 这样的类型时,实际上它们是 Array<number>Array<string> 的简写形式。

function doSomething(value: Array<string>) {
  // ...
}

let myArray: string[] = ["hello", "world"];

// 以下两种方式都可以!
doSomething(myArray);
doSomething(new Array("hello", "world"));

与上面的 Box 类型类似,Array 本身也是泛型类型。

// @noLib: true
interface Number {}
interface String {}
interface Boolean {}
interface Symbol {}
// ---cut---
interface Array<Type> {
  /**
   * 获取或设置数组的长度。
   */
  length: number;

  /**
   * 从数组中移除最后一个元素并返回它。
   */
  pop(): Type | undefined;

  /**
   * 向数组追加新元素,并返回数组的新长度。
   */
  push(...items: Type[]): number;

  // ...
}

现代 JavaScript 还提供了其他泛型的数据结构,如 Map<K, V>Set<T>Promise<T>。所有这些都意味着由于 MapSetPromise 的行为方式,它们可以适用于任何类型的集合。

ReadonlyArray 类型

ReadonlyArray 是一种特殊类型,用于描述不应该被修改的数组。

// @errors: 2339
function doStuff(values: ReadonlyArray<string>) {
  // 我们可以从‘values’中读取...
  const copy = values.slice();
  console.log(`The first value is ${values[0]}`);

  // ...但是我们不能修改‘values’。
  values.push("hello!");
}

与属性的 readonly 修饰符类似,它主要是一个用于表达意图的工具。当我们看到返回 ReadonlyArray 的函数时,它告诉我们不应该对其内容进行任何修改;而当我们看到接受 ReadonlyArray 的函数时,它告诉我们可以将任何数组传递给该函数,而不必担心它会更改其内容。

Array 不同,ReadonlyArray 没有构造函数。

// @errors: 2693
new ReadonlyArray("red", "green", "blue");

相反,我们可以将普通的 Array 赋值给 ReadonlyArray

const roArray: ReadonlyArray<string> = ["red", "green", "blue"];

正如 TypeScript 提供了 Array<Type> 的简写语法 Type[],它还提供了 ReadonlyArray<Type> 的简写语法 readonly Type[]

// @errors: 2339
function doStuff(values: readonly string[]) {
  //                     ^^^^^^^^^^^^^^^^^
  // 我们可以从‘values’中读取...
  const copy = values.slice();
  console.log(`The first value is ${values[0]}`);

  // ...但是我们不能修改‘values’。
  values.push("hello!");
}

最后需要注意的是,与属性的 readonly 修饰符不同,普通的 ArrayReadonlyArray 之间的可赋值性不是双向的。

// @errors: 4104
let x: readonly string[] = [];
let y: string[] = [];

x = y;
y = x;

元组类型

元组类型是另一种 Array 类型,它确切地知道它包含多少个元素,以及在特定位置包含的确切类型。

type StringNumberPair = [string, number];
//                      ^^^^^^^^^^^^^^^^

在这里,StringNumberPair 是一个包含 stringnumber 的元组类型。与 ReadonlyArray 类似,它在运行时没有表示,但对于 TypeScript 来说非常重要。对于类型系统来说,StringNumberPair 描述了一个数组,其 0 索引包含一个 string,而 1 索引包含一个 number

function doSomething(pair: [string, number]) {
  const a = pair[0];
  //    ^?
  const b = pair[1];
  //    ^?
  // ...
}

doSomething(["hello", 42]);

如果我们尝试超出元素数量的索引,将会得到一个错误。

// @errors: 2493
function doSomething(pair: [string, number]) {
  // ...

  const c = pair[2];
}

我们还可以使用 JavaScript 的数组解构来解构元组

function doSomething(stringHash: [string, number]) {
  const [inputString, hash] = stringHash;

  console.log(inputString);
  //          ^?

  console.log(hash);
  //          ^?
}

元组类型在高度基于约定的 API 中非常有用,这种 API 中每个元素的含义是“显而易见的”。 这使得我们在解构它们时可以根据需要为变量命名。 在上面的示例中,我们能够将元素 01 命名为任何我们想要的名称。

然而,由于不是每个用户都对什么是显而易见的持有相同的观点,因此再三考虑是否为你的 API 使用具有描述性属性名称的对象比较好。

除了长度检查外,简单的元组类型与声明具有特定索引属性和使用数字字面类型声明 lengthArray 版本的类型是等效的。

interface StringNumberPair {
  // 特别的属性
  length: 2;
  0: string;
  1: number;

  // 其他‘Array<string | number>’的成员...
  slice(start?: number, end?: number): Array<string | number>;
}

另一个你可能感兴趣的是,元组可以通过在元素类型后面写一个问号 (?) 来拥有可选属性。可选的元组元素只能出现在末尾,并且也会影响 length 的类型。

type Either2dOr3d = [number, number, number?];

function setCoordinate(coord: Either2dOr3d) {
  const [x, y, z] = coord;
  //           ^?

  console.log(`所给坐标有 ${coord.length} 个维度`);
  //                             ^?
}

元组还可以拥有剩余元素,它们必须是数组/元组类型。

type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];
  • StringNumberBooleans 描述了一个元组,其前两个元素分别是 stringnumber,但后面可以有任意数量的 boolean
  • StringBooleansNumber 描述了一个元组,其第一个元素是 string,然后是任意数量的 boolean,最后是一个 number
  • BooleansStringNumber 描述了一个元组,其起始元素是任意数量的 boolean,然后是一个 string,最后是一个 number

带有剩余元素的元组没有固定的“length”——它只有一组在不同位置上的已知元素。

type StringNumberBooleans = [string, number, ...boolean[]];
// ---cut---
const a: StringNumberBooleans = ["hello", 1];
const b: StringNumberBooleans = ["beautiful", 2, true];
const c: StringNumberBooleans = ["world", 3, true, false, true, false, true];

为什么可选和剩余元素会有用呢?这使得 TypeScript 能够将元组与参数列表相对应。元组类型可以在剩余参数和剩余实参 中使用,因此以下代码:

function readButtonInput(...args: [string, number, ...boolean[]]) {
  const [name, version, ...input] = args;
  // ...
}

基本上等同于:

function readButtonInput(name: string, version: number, ...input: boolean[]) {
  // ...
}

这在你想要使用剩余参数接收可变数量的实参,并且你需要确保有最小数量的元素,但又不想引入中间变量时非常方便。

readonly 元组类型

关于元组类型,最后还有一个要注意的地方——元组类型有 readonly 变体,可以通过在前面加上 readonly 修饰符来指定,就像数组简写语法一样。

function doSomething(pair: readonly [string, number]) {
  //                       ^^^^^^^^^^^^^^^^^^^^^^^^^
  // ...
}

正如你所预期的,不允许在 readonly 元组的任何属性上进行写操作。

// @errors: 2540
function doSomething(pair: readonly [string, number]) {
  pair[0] = "hello!";
}

在大多数代码中,元组通常被创建后不会被修改,因此在可能的情况下将类型注释为 readonly 元组是一个很好的默认选择。这一点也很重要,因为带有 const 断言的数组字面量将被推断为具有 readonly 元组类型。

// @errors: 2345
let point = [3, 4] as const;

function distanceFromOrigin([x, y]: [number, number]) {
  return Math.sqrt(x ** 2 + y ** 2);
}

distanceFromOrigin(point);

在这个例子中,distanceFromOrigin 从不修改其元素,但它期望一个可变的元组。由于 point 的类型被推断为 readonly [3, 4],它与 [number, number] 不兼容,因为这个类型无法保证 point 的元素不会被修改。

用现有类型创建新类型

TypeScript 的类型系统非常强大,因为它允许用其他类型表达类型。

这个想法最简单的形式就是泛型。此外,我们还有多种类型操作符可以使用。我们也可以用我们已有的表达类型。

通过组合不同的类型操作符,我们可以用简洁、可维护的方式表达复杂的操作和值。在本部分中,我们将介绍如何用现有的类型或值表达新的类型。

用现有类型创建新类型

TypeScript 的类型系统非常强大,因为它允许用其他类型表达类型。

这个想法最简单的形式就是泛型。此外,我们还有多种类型操作符可以使用。我们也可以用我们已有的表达类型。

通过组合不同的类型操作符,我们可以用简洁、可维护的方式表达复杂的操作和值。在本部分中,我们将介绍如何用现有的类型或值表达新的类型。

泛型

软件工程中的一个要点,是构建具有明确定义且一致的 API 的组件,同时这些组件还要具备可重用性。既能够处理当下数据,也能够处理未来数据的组件,将帮助你更加灵活地构建大型软件系统。

在像 C# 和 Java 这样的语言中,创建可重用组件的主要工具之一是泛型,即能够创建可以处理多种类型而不仅限于单一类型的组件。这允许用户使用自己的类型来使用这些组件。

泛型的 Hello World

首先,让我们来看一下泛型的“Hello World”: identify 函数。 identify 函数会返回传入的参数。你可以将其类比为 echo 命令行命令。

在没有泛型的情况下,我们要么为 identify 函数指定特定类型:

function identity(arg: number): number {
  return arg;
}

要么,使用 any 类型来描述 identify 函数:

function identity(arg: any): any {
  return arg;
}

虽然使用 any 使函数接收任何类型的 arg 具有泛型的特性,但是我们将丢失有关返回类型的信息。如果我们传入数字,我们只知道任何类型都可能返回。

相反,我们需要一种能够捕获参数类型并在返回类型中使用它的方式。在这里,我们将使用类型变量,这是一种特殊类型的变量,用于处理类型而不是值。

function identity<Type>(arg: Type): Type {
  return arg;
}

我们现在在 identify 函数中添加了类型变量 Type。这个 Type 可以捕获用户提供的类型(例如 number),以便我们以后可以使用该信息。在这里,我们再次使用 Type 作为返回类型。检查一下,我们现在可以看到参数类型和返回类型都是同一种。这样的话,我们可以在函数的一侧传递类型信息,并在另一侧传递出去。

这个版本的 identity 函数是泛型的,因为它适用于各种类型。与使用 any 不同,它也与使用数字作为参数和返回类型的第一个 identity 函数一样精确(即不丢失任何信息)。

在编写了泛型 identify 函数之后,我们可以通过两种方式调用它。第一种方式是将所有实参(包括类型实参)传递给函数:

function identity<Type>(arg: Type): Type {
  return arg;
}
// ---cut---
let output = identity<string>("myString");
//       ^?

这里我们明确将 Type 设置为函数调用的一个实参(即 string),使用 <> 将参数括起来而不是 ()

第二种方式也许是最常用的。这里我们使用类型参数推断——即,我们让编译器根据我们传入的实参的类型自动为我们设置 Type 的值:

function identity<Type>(arg: Type): Type {
  return arg;
}
// ---cut---
let output = identity("myString");
//       ^?

请注意,我们不必显式传递尖括号 (<>) 中的类型;编译器只需查看值 "myString" 并将 Type 设置为其类型。虽然类型参数推断可以使代码更简洁、更易读,但在一些更复杂的情况中,编译器无法推断类型的情况下,你可能需要像前面的示例中那样显式传递类型参数。

使用泛型类型变量

当你开始使用泛型时,你会注意到,当你创建像 identity 这样的泛型函数时,编译器将强制你在函数体中正确使用任何泛型的参数。也就是说,你实际上要将这些参数视为可能是任何类型。

让我们以前面的 identity 函数为例:

function identity<Type>(arg: Type): Type {
  return arg;
}

如果我们还想在每次调用时将参数 arg 的长度记录到控制台,我们可能会这样写:

// @errors: 2339
function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length);
  return arg;
}

这样做时,编译器会给出一个错误,指出我们正在使用 arg.length 成员,但我们并没有说明 arg 具有这个成员。请记住,我们前面说过,这些类型变量代表任何类型,因此使用该函数的人可以传入没有 .length 成员的 number 类型。

假设我们实际上打算让这个函数在 Type 类型的数组上工作,而不是直接在 Type 上。由于我们正在处理数组,.length 成员是可用的。我们可以像创建其他类型的数组一样描述它:

function loggingIdentity<Type>(arg: Type[]): Type[] {
  console.log(arg.length);
  return arg;
}

你可以将 loggingIdentity 的类型解读为“泛型函数 loggingIdentity 接受类型参数 Type 和实参 arg,该实参是 Type 的数组,并返回(另一个) Type 的数组”。如果我们传入一个数字数组,我们将得到一个数字数组作为返回值,因为 Type 将绑定到 number。这允许我们在我们对正在处理的类型使用泛型类型变量 Type,从而提供更大的灵活性。

我们还可以用另一种方式编写示例:

function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
  console.log(arg.length); // 数组有 .length 属性,因此不再报错
  return arg;
}

你可能已经熟悉这种类型的写法,它在其他语言中也是常见的。在下一节中,我们将介绍如何创建自己的泛型类型,比如 Array<Type>

泛型类型

在前面的部分中,我们创建了适用于多种类型的泛型 identity 函数。在本节中,我们将探讨函数本身的类型以及如何创建泛型接口。

泛型函数的类型与非泛型函数的类型类似,类型参数在前面列出,类似于函数声明:

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: <Type>(arg: Type) => Type = identity;

我们也可以在类型中使用不同的名称来表示泛型类型参数,只要类型变量的数量和类型变量的使用方式对应即可。

function identity<Input>(arg: Input): Input {
  return arg;
}

let myIdentity: <Input>(arg: Input) => Input = identity;

我们还可以将泛型类型写成对象字面量类型的调用签名:

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: { <Type>(arg: Type): Type } = identity;

这引导我们来编写我们的第一个泛型接口。让我们将前面示例中的对象字面量移动到一个接口中:

interface GenericIdentityFn {
  <Type>(arg: Type): Type;
}

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: GenericIdentityFn = identity;

在类似的示例中,我们可能希望将泛型参数移到整个接口的参数位置。这样可以让我们看到我们泛型化的类型或类型(例如 Dictionary<string> 而不仅仅是 Dictionary)。这将使类型参数对接口的所有其他成员可见。

interface GenericIdentityFn<Type> {
  (arg: Type): Type;
}

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: GenericIdentityFn<number> = identity;

请注意,我们的示例发生了一些变化。我们现在不再描述一个泛型函数,而是一个非泛型函数签名,它是泛型类型的一部分。当我们使用 GenericIdentityFn 时,我们现在还需要指定相应的类型实参(在这里是 number),从而确切地确定调用签名将使用的类型。了解何时直接将类型参数放在调用签名上,何时将其放在接口本身上,有助于描述类型的哪些方面是泛型的。

除了泛型接口,我们还可以创建泛型类。请注意,无法创建泛型枚举和泛型命名空间。

泛型类

泛型类的形式和泛型接口相似。泛型类在类名后面使用尖括号 (<>) 来指定泛型类型参数列表。

// @strict: false
class GenericNumber<NumType> {
  zeroValue: NumType;
  add: (x: NumType, y: NumType) => NumType;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
  return x + y;
};

这是对 GenericNumber 类的一种直接的使用方式,但你可能已经注意到,它没有限制只能使用 number 类型。我们也可以使用 string 或者更复杂的对象。

// @strict: false
class GenericNumber<NumType> {
  zeroValue: NumType;
  add: (x: NumType, y: NumType) => NumType;
}
// ---cut---
let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
  return x + y;
};

console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));

与接口一样,将类型参数放在类本身上可以确保类的所有属性均使用相同的类型。

正如我们在介绍类的部分中所介绍的,类有两个方面的类型:静态方面和实例方面。泛型类只能在实例方面使用泛型,而不能在静态方面使用,因此在处理类时,静态成员无法使用类的类型参数。

通用约束

如果你还记得之前的例子,有时你可能希望编写一个通用函数,该函数适用于一组类型,你对该类型集合的某些特点有一些了解。在我们的 loggingIdentity 示例中,我们希望能够访问 arg.length 属性,但编译器无法证明每种类型都有 .length 属性,所以它警告我们不能做出这种假设。

// @errors: 2339
function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length);
  return arg;
}

我们希望将此函数限制为仅适用于具有 .length 属性的任何类型,而不是处理任何类型。只要类型具有这个成员,我们就允许它,否则就不允许。为此,我们必须将我们的要求作为对 Type 的约束来列出。

我们可以创建描述我们的约束的接口。下面这个例子中,我们创建了具有 .length 属性的接口,然后我们使用这个接口和 extends 关键字来表示我们的约束:

interface Lengthwise {
  length: number;
}

function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length); // 现在我们知道它有 .length 属性,所以不会再出现错误
  return arg;
}

由于这个泛型函数现在受到约束,它将不再适用于任意类型:

// @errors: 2345
interface Lengthwise {
  length: number;
}

function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length);
  return arg;
}
// ---cut---
loggingIdentity(3);

相反,我们需要传入具有所有必需属性的值的类型:

interface Lengthwise {
  length: number;
}

function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length);
  return arg;
}
// ---cut---
loggingIdentity({ length: 10, value: 3 });

在泛型约束中使用类型参数

你可以声明受到另一个类型参数的约束的类型参数。例如,我们想根据对象的属性名获取对象的属性。我们希望确保不会意外地获取一个在 obj 上不存在的属性,因此我们将在这两个类型之间添加约束:

// @errors: 2345
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a");
getProperty(x, "m");

在泛型中使用类的类型

在 TypeScript 中使用泛型创建工厂时,需要通过构造函数来引用类的类型。例如,

function create<Type>(c: { new (): Type }): Type {
  return new c();
}

更高级的示例使用原型属性来推断和约束构造函数和类的类型的实例之间的关系。

// @strict: false
class BeeKeeper {
  hasMask: boolean = true;
}

class ZooKeeper {
  nametag: string = "Mikle";
}

class Animal {
  numLegs: number = 4;
}

class Bee extends Animal {
  numLegs = 6;
  keeper: BeeKeeper = new BeeKeeper();
}

class Lion extends Animal {
  keeper: ZooKeeper = new ZooKeeper();
}

function createInstance<A extends Animal>(c: new () => A): A {
  return new c();
}

createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;

这种模式被用于实现 mixins 设计模式。

泛型参数默认值

通过为泛型参数声明默认值,可以选择性地指定相应的类型参数。例如,一个创建新的 HTMLElement 的函数。在不带参数调用该函数时,生成一个 HTMLDivElement;在将元素作为第一个参数调用该函数时,生成一个与参数类型相对应的元素。还可以选择性地传递子元素列表。以前,你必须将函数定义为:

type Container<T, U> = {
  element: T;
  children: U;
};

// ---cut---
declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;
declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;
declare function create<T extends HTMLElement, U extends HTMLElement>(
  element: T,
  children: U[]
): Container<T, U[]>;

有了泛型参数默认值,我们可以将其简化为:

type Container<T, U> = {
  element: T;
  children: U;
};

// ---cut---
declare function create<T extends HTMLElement = HTMLDivElement, U = T[]>(
  element?: T,
  children?: U
): Container<T, U>;

const div = create();
//    ^?

const p = create(new HTMLParagraphElement());
//    ^?

泛型参数默认值遵循以下规则:

  • 如果类型参数有默认值,则被视为可选的。
  • 必需的类型参数不能在可选的类型参数之后。
  • 类型参数的默认类型必须满足类型参数的约束(如果存在)。
  • 在指定类型参数时,只需要为必需的类型参数指定类型参数。未指定的类型参数将解析为其默认类型。
  • 如果指定了默认类型并且无法推断出候选项,则会推断为默认类型。
  • 与现有类或接口声明合并的类或接口声明可以引入对现有类型参数的默认值。
  • 与现有类或接口声明合并的类或接口声明可以引入新的类型参数,只要它指定了默认值。

keyof 类型运算符

keyof 类型运算符

keyof 运算符接受对象类型,并生成其键的字符串或数字字面量联合类型。下面的类型 Ptype P = "x" | "y" 相同:

type Point = { x: number; y: number };
type P = keyof Point;
//   ^?

如果类型具有 stringnumber 索引签名,keyof 将返回相应的类型:

type Arrayish = { [n: number]: unknown };
type A = keyof Arrayish;
//   ^?

type Mapish = { [k: string]: boolean };
type M = keyof Mapish;
//   ^?

请注意,在此示例中,Mstring | number——这是因为 JavaScript 对象的键总是被强制转换为字符串,所以 obj[0] 总是等同于 obj["0"]

当与映射类型结合使用时,keyof 类型变得非常有用,我们稍后将详细了解。

typeof 类型运算符

typeof 类型运算符

JavaScript 已经有了 typeof 运算符,你可以在表达式上下文中使用它:

// 输出 "string"
console.log(typeof "Hello world");

TypeScript 添加了 typeof 运算符,你可以在类型上下文中使用它来引用变量或属性的 类型

let s = "hello";
let n: typeof s;
//  ^?

对于基本类型,这并不是很有用,但是如果与其他类型运算符结合使用,你就可以方便地表达许多模式。例如,让我们首先看一下预定义类型 ReturnType<T>。它接受函数类型为参数并生成其返回类型:

type Predicate = (x: unknown) => boolean;
type K = ReturnType<Predicate>;
//   ^?

如果我们尝试对函数名称使用 ReturnType,我们会看到一个指示性的错误:

// @errors: 2749
function f() {
  return { x: 10, y: 3 };
}
type P = ReturnType<f>;

请记住,类型不是相同的东西。要引用f 具有的类型,我们使用 typeof

function f() {
  return { x: 10, y: 3 };
}
type P = ReturnType<typeof f>;
//   ^?

限制

TypeScript 有意限制了你可以在其上使用 typeof 的表达式类型。

具体来说,只有在标识符(即变量名)或其属性上使用 typeof 是合法的。这有助于避免编写你认为正在执行但实际上没有执行的代码的混淆陷阱:

// @errors: 1005
declare const msgbox: (prompt: string) => boolean;
// type msgbox = any;
// ---cut---
// 本意是使用 = ReturnType<typeof msgbox>
let shouldContinue: typeof msgbox("你是否确定要继续?");

索引访问类型

我们可以使用索引访问类型在另一个类型上查找特定的属性:

type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"];
//   ^?

索引类型本身就是一种类型,因此我们可以使用联合类型、keyof 或其他完全不同的类型:

type Person = { age: number; name: string; alive: boolean };
// ---cut---
type I1 = Person["age" | "name"];
//   ^?

type I2 = Person[keyof Person];
//   ^?

type AliveOrName = "alive" | "name";
type I3 = Person[AliveOrName];
//   ^?

如果你尝试索引不存在的属性,会看到一个错误:

// @errors: 2339
type Person = { age: number; name: string; alive: boolean };
// ---cut---
type I1 = Person["alve"];

使用任意类型进行索引的另一个示例是使用 number 来获取数组元素的类型。我们可以将其与 typeof 结合使用,方便地捕获数组字面量的元素类型:

const MyArray = [
  { name: "Alice", age: 15 },
  { name: "Bob", age: 23 },
  { name: "Eve", age: 38 },
];

type Person = typeof MyArray[number];
//   ^?
type Age = typeof MyArray[number]["age"];
//   ^?
// 或者
type Age2 = Person["age"];
//   ^?

在进行索引时,你只能使用类型,这意味着不能使用 const 来进行变量引用:

// @errors: 2538 2749
type Person = { age: number; name: string; alive: boolean };
// ---cut---
const key = "age";
type Age = Person[key];

但是,你可以使用类型别名来进行类似的重构:

type Person = { age: number; name: string; alive: boolean };
// ---cut---
type key = "age";
type Age = Person[key];

条件类型

大多数有效程序的核心是,我们必须依据输入做出一些决定。JavaScript 程序也是如此,但是由于值可以很容易地被内省,这些决定也是基于输入的类型。条件类型有助于描述输入和输出类型之间的关系。

interface Animal {
  live(): void;
}
interface Dog extends Animal {
  woof(): void;
}

type Example1 = Dog extends Animal ? number : string;
//   ^?

type Example2 = RegExp extends Animal ? number : string;
//   ^?

条件类型看起来有点像 JavaScript 中的条件表达式(条件 ? true 表达式 : false 表达式):

type SomeType = any;
type OtherType = any;
type TrueType = any;
type FalseType = any;
type Stuff =
  // ---cut---
  SomeType extends OtherType ? TrueType : FalseType;

extends 左边的类型可以赋值给右边的类型时,你将获得第一个分支(“true”分支)中的类型;否则你将获得后一个分支(“false”分支)中的类型。

上面的例子中,条件类型可能不是很有用——我们可以告诉自己是否 Dog extends Animal 并选择 numberstring!但是条件类型的威力来自于将它们与泛型一起使用。

让我们以下面的 createLabel 函数为例:

interface IdLabel {
  id: number /* 一些字段 */;
}
interface NameLabel {
  name: string /* 其它字段 */;
}

function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw "unimplemented";
}

这些 createLabel 的重载描述了一个单独的 JavaScript 函数,根据其输入的类型进行选择。请注意以下几点:

  1. 如果一个库在其 API 中不断进行相同类型的选择,这将变得很繁琐。
  2. 我们需要创建三个重载:对于我们确定类型的情况(一个针对 string,一个针对 number),以及最通用情况的重载(接受 string | number)。对于 createLabel 能处理的每种新类型,重载的数量呈指数级增长。

相反,我们可以将该逻辑转换为条件类型:

interface IdLabel {
  id: number /* 一些字段 */;
}
interface NameLabel {
  name: string /* 其它字段 */;
}
// ---cut---
type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;

然后,我们可以使用该条件类型将重载简化为没有重载的单个函数。

interface IdLabel {
  id: number /* 一些字段 */;
}
interface NameLabel {
  name: string /* 其它字段 */;
}
type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;
// ---cut---
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "unimplemented";
}

let a = createLabel("typescript");
//  ^?

let b = createLabel(2.8);
//  ^?

let c = createLabel(Math.random() ? "hello" : 42);
//  ^?

条件类型约束

通常,条件类型的检查将为我们提供一些新信息。就像使用类型守卫缩小范围可以给我们提供更具体的类型一样,条件类型的 true 分支将根据我们检查的类型进一步约束泛型。

让我们来看看下面的例子:

// @errors: 2536
type MessageOf<T> = T["message"];

在本例中,TypeScript 产生错误是因为不知道 T 有一个名为 message 的属性。我们可以约束 T,这样 TypeScript 就不会再报错了:

type MessageOf<T extends { message: unknown }> = T["message"];

interface Email {
  message: string;
}

interface Dog {
  bark(): void;
}

type EmailMessageContents = MessageOf<Email>;
//   ^?

然而,如果我们希望 MessageOf 接受任何类型,并且在 message 属性不可用的情况下将其默认为 never 之类的类型,我们应该怎么做呢?我们可以通过移出约束并引入条件类型来实现这一点:

type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;

interface Email {
  message: string;
}

interface Dog {
  bark(): void;
}

type EmailMessageContents = MessageOf<Email>;
//   ^?

type DogMessageContents = MessageOf<Dog>;
//   ^?

在 true 分支中,TypeScript 知道 T message 属性。

举另一个示例,我们还可以编写名为 Flatten 的类型,如果是数组类型的话,将其类型展平为其元素类型,否则类型保持不变:

type Flatten<T> = T extends any[] ? T[number] : T;

// 提取出元素类型。
type Str = Flatten<string[]>;
//   ^?

// 保持类型不变。
type Num = Flatten<number>;
//   ^?

Flatten 接收到数组类型时,它使用 number 进行索引访问来提取出 string[] 的元素类型。否则,它会直接返回原类型。

在条件类型中推断

我们刚才使用条件类型来应用约束并提取出类型。这种操作非常常见,条件类型使得这一过程更加简单。

条件类型提供了从我们在 true 分支中进行比较的类型中进行类型推断的方式,这通过使用 infer 关键字来实现。例如,在 Flatten 中,我们可以推断出元素类型,而不是使用索引访问类型来“手动”提取:

type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

在这里,我们使用 infer 关键字声明性地引入名为 Item 的新泛型类型变量,而不是在 true 分支中指定如何检索 Type 的元素类型。这样,我们就不需要考虑如何解构我们的类型的结构。

我们可以使用 infer 关键字编写一些有用的辅助类型别名。例如,对于简单的情况,我们可以从函数类型中提取返回类型:

type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
  ? Return
  : never;

type Num = GetReturnType<() => number>;
//   ^?

type Str = GetReturnType<(x: string) => string>;
//   ^?

type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>;
//   ^?

当从具有多个调用签名的类型(如重载函数的类型)进行推断时,将从最后一个签名进行推断(这也许是最宽松的万能情况)。无法基于实参类型列表对重载函数进行决策。

declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;

type T1 = ReturnType<typeof stringOrNum>;
//   ^?

分布式条件类型

当条件类型作用于泛型类型时,如果给定一个联合类型,它们就变成了分布式类型。例如,考虑以下代码:

type ToArray<Type> = Type extends any ? Type[] : never;

如果我们将联合类型传递给 ToArray,那么条件类型将应用于联合类型的每个成员。

type ToArray<Type> = Type extends any ? Type[] : never;

type StrArrOrNumArr = ToArray<string | number>;
//   ^?

这里发生的是 ToArray 在以下代码上进行了分布:

type StrArrOrNumArr =
  // ---cut---
  string | number;

并且对联合类型的每个成员类型进行了映射,实际上相当于:

type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr =
  // ---cut---
  ToArray<string> | ToArray<number>;

这样我们就得到:

type StrArrOrNumArr =
  // ---cut---
  string[] | number[];

通常,分布性是期望的行为。要避免这种行为,可以在 extends 关键字的两边加上方括号。

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

// ‘ArrOfStrOrNum’不再是联合类型。
type ArrOfStrOrNum = ToArrayNonDist<string | number>;
//   ^?

映射类型

如果你想避免重复代码,那么可以基于某个类型创建另一个类型。

映射类型建立在索引签名的语法基础上,索引签名用于声明未提前声明的属性的类型:

type Horse = {};
// ---cut---
type OnlyBoolsAndHorses = {
  [key: string]: boolean | Horse;
};

const conforms: OnlyBoolsAndHorses = {
  del: true,
  rodney: false,
};

映射类型是一种泛型类型,它使用 PropertyKey 的联合类型(通常通过 keyof 创建)来遍历键以创建类型:

type OptionsFlags<Type> = {
  [Property in keyof Type]: boolean;
};

在此示例中,OptionsFlags 将获取类型 Type 的所有属性,并将它们的值更改为布尔值。

type OptionsFlags<Type> = {
  [Property in keyof Type]: boolean;
};
// ---cut---
type Features = {
  darkMode: () => void;
  newUserProfile: () => void;
};

type FeatureOptions = OptionsFlags<Features>;
//   ^?

映射修饰符

在映射过程中,可以应用两个额外的修饰符:readonly?,分别影响可变性和可选性。

你可以通过以 -+ 为前缀来移除或添加这些修饰符。如果你不添加前缀,则默认为 +

// 从类型的属性中移除‘readonly’属性
type CreateMutable<Type> = {
  -readonly [Property in keyof Type]: Type[Property];
};

type LockedAccount = {
  readonly id: string;
  readonly name: string;
};

type UnlockedAccount = CreateMutable<LockedAccount>;
//   ^?
// 从类型的属性中移除‘optional’属性
type Concrete<Type> = {
  [Property in keyof Type]-?: Type[Property];
};

type MaybeUser = {
  id: string;
  name?: string;
  age?: number;
};

type User = Concrete<MaybeUser>;
//   ^?

通过 as 进行键重映射

从 TypeScript 4.1 开始,你可以在映射类型中使用 as 子句重新映射键:

type MappedTypeWithNewProperties<Type> = {
    [Properties in keyof Type as NewKeyType]: Type[Properties]
}

你可以利用模板字面量类型等特性,根据先前的属性创建新的属性名:

type Getters<Type> = {
    [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};

interface Person {
    name: string;
    age: number;
    location: string;
}

type LazyPerson = Getters<Person>;
//   ^?

你可以通过条件类型产生 never 来过滤掉键:

// 移除 'kind' 属性
type RemoveKindField<Type> = {
    [Property in keyof Type as Exclude<Property, "kind">]: Type[Property]
};

interface Circle {
    kind: "circle";
    radius: number;
}

type KindlessCircle = RemoveKindField<Circle>;
//   ^?

你可以对任意联合类型进行映射,不仅仅是 string | number | symbol 的联合类型,可以是任意类型的联合类型:

type EventConfig<Events extends { kind: string }> = {
    [E in Events as E["kind"]]: (event: E) => void;
}

type SquareEvent = { kind: "square", x: number, y: number };
type CircleEvent = { kind: "circle", radius: number };

type Config = EventConfig<SquareEvent | CircleEvent>
//   ^?

进一步探索

映射类型可以与本手册类型操作部分中介绍的其他特性很好地配合使用,例如,以下是使用条件类型的映射类型示例,根据对象是否具有设置为字面量 true 的属性 pii 来返回 truefalse

type ExtractPII<Type> = {
  [Property in keyof Type]: Type[Property] extends { pii: true } ? true : false;
};

type DBFields = {
  id: { format: "incrementing" };
  name: { type: string; pii: true };
};

type ObjectsNeedingGDPRDeletion = ExtractPII<DBFields>;
//   ^?

模版字面量类型

从 TypeScript 4.1 开始支持

模版字面量类型以字符串字面量类型为基础,且可以展开为多个字符串类型的联合类型。

其语法与 JavaScript 中的模版字面量是一致的,但是是用在类型的位置上。 当与某个具体的字面量类型一起使用时,模版字面量会将文本连接从而生成一个新的字符串字面量类型。

type World = 'world';

type Greeting = `hello ${World}`;
//   'hello world'

如果在替换字符串的位置是联合类型,那么结果类型是由每个联合类型成员构成的字符串字面量的集合:

type EmailLocaleIDs = 'welcome_email' | 'email_heading';
type FooterLocaleIDs = 'footer_title' | 'footer_sendoff';

type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;
// "welcome_email_id" | "email_heading_id" | "footer_title_id" | "footer_sendoff_id"

多个替换字符串的位置上的多个联合类型会进行交叉相乘:

type EmailLocaleIDs = 'welcome_email' | 'email_heading';
type FooterLocaleIDs = 'footer_title' | 'footer_sendoff';

type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;
type Lang = 'en' | 'ja' | 'pt';

type LocaleMessageIDs = `${Lang}_${AllLocaleIDs}`;
//   type EmailLocaleIDs = "welcome_email" | "email_heading";
type FooterLocaleIDs = 'footer_title' | 'footer_sendoff';

type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;
type Lang = 'en' | 'ja' | 'pt';

type LocaleMessageIDs = `${Lang}_${AllLocaleIDs}`;
//   "en_welcome_email_id" | "en_email_heading_id" | "en_footer_title_id" | "en_footer_sendoff_id" | "ja_welcome_email_id" | "ja_email_heading_id" | "ja_footer_title_id" | "ja_footer_sendoff_id" | "pt_welcome_email_id" | "pt_email_heading_id" | "pt_footer_title_id" | "pt_footer_sendoff_id"

我们还是建议开发者要提前生成数量巨大的字符串联合类型,但如果数量较少,那么上面介绍的方法会有所帮助。

类型中的字符串联合类型

模版字面量的强大之处在于它能够基于给定的字符串来创建新的字符串。

例如,JavaScript 中有一个常见的模式是基于对象的现有属性来扩展它。 下面我们定义一个函数类型on,它用于监听值的变化。

declare function makeWatchedObject(obj: any): any;

const person = makeWatchedObject({
    firstName: 'Saoirse',
    lastName: 'Ronan',
    age: 26,
});

person.on('firstNameChanged', (newValue) => {
    console.log(`firstName was changed to ${newValue}!`);
});

注意,on会监听"firstNameChanged"事件,而不是"firstName"。 模版字面量提供了操作字符串类型的能力:

type PropEventSource<Type> = {
    on(
        eventName: `${string & keyof Type}Changed`,
        callback: (newValue: any) => void
    ): void;
};

/// Create a "watched object" with an 'on' method
/// so that you can watch for changes to properties.
declare function makeWatchedObject<Type>(
    obj: Type
): Type & PropEventSource<Type>;

这样做之后,当传入了错误的属性名会产生一个错误:

type PropEventSource<Type> = {
    on(
        eventName: `${string & keyof Type}Changed`,
        callback: (newValue: any) => void
    ): void;
};

declare function makeWatchedObject<T>(obj: T): T & PropEventSource<T>;

const person = makeWatchedObject({
    firstName: 'Saoirse',
    lastName: 'Ronan',
    age: 26,
});

person.on('firstNameChanged', () => {});

// 以下存在拼写错误
person.on('firstName', () => {});
person.on('frstNameChanged', () => {});

模版字面量类型推断

注意,上例中没有使用原属性值的类型,在回调函数中仍使用any类型。 模版字面量类型能够从替换字符串的位置推断出类型。

下面,我们将上例修改成泛型,它会从eventName字符串来推断出属性名。

type PropEventSource<Type> = {
    on<Key extends string & keyof Type>(
        eventName: `${Key}Changed`,
        callback: (newValue: Type[Key]) => void
    ): void;
};

declare function makeWatchedObject<Type>(
    obj: Type
): Type & PropEventSource<Type>;

const person = makeWatchedObject({
    firstName: 'Saoirse',
    lastName: 'Ronan',
    age: 26,
});

person.on('firstNameChanged', (newName) => {
    //                        string
    console.log(`new name is ${newName.toUpperCase()}`);
});

person.on('ageChanged', (newAge) => {
    //                  number
    if (newAge < 0) {
        console.warn('warning! negative age');
    }
});

这里,我们将on改为泛型方法。

当用户使用字符串"firstNameChanged'来调用时,TypeScript 会尝试推断K的类型。 为此,TypeScript 尝试将Key"Changed"之前的部分进行匹配,并且推断出字符串"firstName"。 当 TypeScript 推断出了类型后,on方法就能够获取firstName属性的类型,即string类型。 相似的,当使用"ageChanged"调用时,TypeScript 能够知道age属性的类型是number

类型推断可以以多种方式组合,例如拆解字符串然后以其它方式重新构造字符串。

操作固有字符串的类型

为了方便字符串操作,TypeScript 提供了一系列操作字符串的类型。 这些类型内置于编译器之中,以便提高性能。 它们不存在于 TypeScript 提供的.d.ts文件中。

Uppercase<StringType>

将字符串中的每个字符转换为大写字母。

Example
type Greeting = 'Hello, world';
type ShoutyGreeting = Uppercase<Greeting>;
//   "HELLO, WORLD"

type ASCIICacheKey<Str extends string> = `ID-${Uppercase<Str>}`;
type MainID = ASCIICacheKey<'my_app'>;
//   "ID-MY_APP"

Lowercase<StringType>

将字符串中的每个字符转换为小写字母。

type Greeting = 'Hello, world';
type QuietGreeting = Lowercase<Greeting>;
//   "hello, world"

type ASCIICacheKey<Str extends string> = `id-${Lowercase<Str>}`;
type MainID = ASCIICacheKey<'MY_APP'>;
//   "id-my_app"

Capitalize<StringType>

将字符串中的首字母转换为大写字母。

Example
type LowercaseGreeting = 'hello, world';
type Greeting = Capitalize<LowercaseGreeting>;
//   "Hello, world"

Uncapitalize<StringType>

将字符串中的首字母转换为小写字母。

Example
type UppercaseGreeting = 'HELLO WORLD';
type UncomfortableGreeting = Uncapitalize<UppercaseGreeting>;
//   "hELLO WORLD"
固有字符串操作类型的技术细节

在TypeScript 4.1中会直接使用JavaScript中的字符串操作函数来操作固有字符串,且不会考虑本地化字符。

function applyStringMapping(symbol: Symbol, str: string) {
    switch (intrinsicTypeKinds.get(symbol.escapedName as string)) {
        case IntrinsicTypeKind.Uppercase: return str.toUpperCase();
        case IntrinsicTypeKind.Lowercase: return str.toLowerCase();
        case IntrinsicTypeKind.Capitalize: return str.charAt(0).toUpperCase() + str.slice(1);
        case IntrinsicTypeKind.Uncapitalize: return str.charAt(0).toLowerCase() + str.slice(1);
    }
    return str;
}

背景阅读:
类(MDN)

TypeScript 对 ES2015 引入的 class 关键字提供了全面支持。

与其他 JavaScript 语言特性一样,TypeScript 添加了类型注解和其他语法,使你能够表达类和其他类型之间的关系。

类成员

下面是一个最基本的类——空类:

class Point {}

这个类目前还没有什么用,所以我们添加一些成员。

字段

字段声明在类上创建了一个公共可写属性:

// @strictPropertyInitialization: false
class Point {
  x: number;
  y: number;
}

const pt = new Point();
pt.x = 0;
pt.y = 0;

与其他位置一样,类型注解是可选的,但如果未指定,则会隐式为 any 类型。

字段还可以有初始化器;当类被实例化时,它们将自动运行:

class Point {
  x = 0;
  y = 0;
}

const pt = new Point();
// 输出 0, 0
console.log(`${pt.x}, ${pt.y}`);

constletvar 一样,类属性的初始化器会用于推断其类型:

// @errors: 2322
class Point {
  x = 0;
  y = 0;
}
// ---cut---
const pt = new Point();
pt.x = '0';

--strictPropertyInitialization

strictPropertyInitialization 设置项控制是否需要在构造函数中初始化类字段。

// @errors: 2564
class BadGreeter {
  name: string;
}
class GoodGreeter {
  name: string;

  constructor() {
    this.name = 'hello';
  }
}

请注意,字段需要在构造函数内部进行初始化。TypeScript 在检测初始化时不会分析从构造函数中调用的方法,因为派生类可能会覆写这些方法,并未初始化成员。

如果你打算通过构造函数以外的方式明确初始化字段(例如,也许外部库填充类的一部分内容),你可以使用明确赋值断言操作符 !

class OKGreeter {
  // 没有初始化,但没有错误
  name!: string;
}

readonly

字段可以使用 readonly 修饰符进行前缀标记。这将阻止在构造函数之外对字段进行赋值。

// @errors: 2540 2540
class Greeter {
  readonly name: string = 'world';

  constructor(otherName?: string) {
    if (otherName !== undefined) {
      this.name = otherName;
    }
  }

  err() {
    this.name = '不可以';
  }
}
const g = new Greeter();
g.name = '同样不可以';

构造函数

背景阅读:
构造函数(MDN)

类构造函数与函数非常相似。你可以添加带有类型注解、默认值和重载的参数:

class Point {
  x: number;
  y: number;

  // 带有默认值的普通签名
  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
}
class Point {
  // 重载
  constructor(x: number, y: string);
  constructor(s: string);
  constructor(xs: any, y?: any) {
    // 待定
  }
}

类构造函数签名与函数签名之间只有一些小的区别:

  • 构造函数不能有类型参数——这些属于外部类声明的部分,我们稍后会学习到
  • 构造函数不能有返回类型注解——返回的类型始终是类实例的类型

调用父类构造函数

与 JavaScript 类似,如果你有一个基类,在使用任何 this. 成员之前,需要在构造函数体中调用 super();

// @errors: 17009
class Base {
  k = 4;
}

class Derived extends Base {
  constructor() {
    // 在 ES5 中输出错误的值;在 ES6 中抛出异常
    console.log(this.k);
    super();
  }
}

忘记调用 super 是在 JavaScript 中很容易犯的错误,但是 TypeScript 会在必要时告诉你。

方法

背景阅读:
方法定义

类中的函数属性称为方法。方法可以使用与函数和构造函数相同的类型注解:

class Point {
  x = 10;
  y = 10;

  scale(n: number): void {
    this.x *= n;
    this.y *= n;
  }
}

除了标准的类型注解外,TypeScript 对方法没有引入任何新的内容。

请注意,在方法体内部,仍然必须通过 this. 访问字段和其他方法。在方法体中的未限定名称始终会引用封闭作用域中的某个内容:

// @errors: 2322
let x: number = 0;

class C {
  x: string = 'hello';

  m() {
    // 这是尝试修改第 1 行的‘x’,而不是类属性
    x = 'world';
  }
}

Getter / Setter

类也可以拥有访问器

class C {
  _length = 0;
  get length() {
    return this._length;
  }
  set length(value) {
    this._length = value;
  }
}

注意,在 JavaScript 中,如果一个字段的 get/set 对没有额外的逻辑,它很少有用处。 如果在 get/set 操作期间不需要添加其他逻辑,可以直接暴露公共字段。

TypeScript 对访问器有一些特殊的类型推断规则:

  • 如果存在 get 但不存在 set,则属性自动为 readonly
  • 如果 setter 参数的类型未指定,则从 getter 的返回类型进行推断
  • getter 和 setter 的成员可见性必须相同

TypeScript 4.3 起,可以在获取和设置时使用不同的类型。

class Thing {
  _size = 0;

  get size(): number {
    return this._size;
  }

  set size(value: string | number | boolean) {
    let num = Number(value);

    // 不允许 NaN、Infinity 等

    if (!Number.isFinite(num)) {
      this._size = 0;
      return;
    }

    this._size = num;
  }
}

索引签名

类可以声明索引签名;这与其他对象类型的索引签名相同:

class MyClass {
  [s: string]: boolean | ((s: string) => boolean);

  check(s: string) {
    return this[s] as boolean;
  }
}

由于索引签名类型还需要捕获方法的类型,因此很难有用地使用这些类型。通常最好将索引数据存储在类实例本身以外的其他位置。

类继承

与其他具有面向对象特性的语言一样,JavaScript 中的类可以从基类继承。

implements 子句

你可以使用 implements 子句来检查一个类是否满足特定的接口。如果类未能正确实现接口,将会发出错误提示:

// @errors: 2420
interface Pingable {
  ping(): void;
}

class Sonar implements Pingable {
  ping() {
    console.log('ping!');
  }
}

class Ball implements Pingable {
  pong() {
    console.log('pong!');
  }
}

类也可以实现多个接口,例如 class C implements A, B {

注意事项

重要的是要理解,implements 子句仅仅是一个检查,用于判断类是否可以被视为接口类型。它完全不会改变类或其方法的类型。一个常见的错误是认为 implements 子句会改变类的类型——实际上并不会!

// @errors: 7006
interface Checkable {
  check(name: string): boolean;
}

class NameChecker implements Checkable {
  check(s) {
    // 注意这里没有错误
    return s.toLowerCase() === 'ok';
    //         ^?
  }
}

在这个例子中,我们可能认为 s 的类型会受到 check 方法的 name: string 参数的影响。但实际上不会——implements 子句不会改变对类的检查或类型推断的方式。

类似地,实现具有可选属性的接口并不会创建该属性:

// @errors: 2339
interface A {
  x: number;
  y?: number;
}
class C implements A {
  x = 0;
}
const c = new C();
c.y = 10;

extends 子句

背景阅读:
extends 关键字(MDN)

类可以从基类进行 extends 继承。派生类具有其基类的所有属性和方法,并且还可以定义额外的成员。

class Animal {
  move() {
    console.log('继续前进!');
  }
}

class Dog extends Animal {
  woof(times: number) {
    for (let i = 0; i < times; i++) {
      console.log('汪!');
    }
  }
}

const d = new Dog();
// 基类方法
d.move();
// 派生类方法
d.woof(3);

覆写方法

背景阅读:
super 关键字(MDN)

派生类还可以覆写基类的字段或属性。你可以使用 super. 语法来访问基类的方法。请注意,由于 JavaScript 类是一个简单的查找对象,没有“super 字段”的概念。

TypeScript 强制要求派生类始终是其基类的子类型。

例如,以下是一种覆写方法的合法方式:

class Base {
  greet() {
    console.log('你好,世界!');
  }
}

class Derived extends Base {
  greet(name?: string) {
    if (name === undefined) {
      super.greet();
    } else {
      console.log(`你好,${name.toUpperCase()}`);
    }
  }
}

const d = new Derived();
d.greet();
d.greet('reader');

派生类必须遵循其基类的约定。请记住,通过基类引用来引用派生类实例是非常常见的做法(并且始终是合法的):

class Base {
  greet() {
    console.log('你好,世界!');
  }
}
class Derived extends Base {}
const d = new Derived();
// ---cut---
// 通过基类引用来声明派生类实例的别名
const b: Base = d;
// 没有问题
b.greet();

那么如果 Derived 不遵循 Base 的约定会怎样呢?

// @errors: 2416
class Base {
  greet() {
    console.log('你好,世界!');
  }
}

class Derived extends Base {
  // 使这个参数成为必需的
  greet(name: string) {
    console.log(`你好,${name.toUpperCase()}`);
  }
}

如果我们无视错误,仍编译了这段代码,那么这个示例将会崩溃:

declare class Base {
  greet(): void;
}
declare class Derived extends Base {}
// ---cut---
const b: Base = new Derived();
// 由于“name”为 undefined,所以崩溃
b.greet();

仅类型字段声明

target >= ES2022useDefineForClassFieldstrue 时,类字段在父类构造函数完成后进行初始化,覆盖了父类设置的任何值。这在你只想为继承字段声明更准确的类型时可能会成为问题。为了处理这些情况,你可以使用 declare 来告诉 TypeScript 此字段声明不会产生运行时效果。

interface Animal {
  dateOfBirth: any;
}

interface Dog extends Animal {
  breed: any;
}

class AnimalHouse {
  resident: Animal;
  constructor(animal: Animal) {
    this.resident = animal;
  }
}

class DogHouse extends AnimalHouse {
  // 不会生成 JavaScript 代码,
  // 只是确保类型正确
  declare resident: Dog;
  constructor(dog: Dog) {
    super(dog);
  }
}

初始化顺序

JavaScript 类的初始化顺序在某些情况下可能会出人意料。让我们来看一下这段代码:

class Base {
  name = '基础';
  constructor() {
    console.log('我是' + this.name);
  }
}

class Derived extends Base {
  name = '派生';
}

// 输出“基础”,而不是“派生”
const d = new Derived();

怎么回事?

按照 JavaScript 的定义,类的初始化顺序如下:

  • 初始化基类字段
  • 运行基类构造函数
  • 初始化派生类字段
  • 运行派生类构造函数

这意味着在运行基类构造函数期间,它看到的是自己的 name 值,因为派生类字段的初始化尚未运行。

继承内置类型

注意:如果你不打算继承像 ArrayErrorMap 等内置类型,或者你的编译目标明确设置为 ES6/ES2015 或更高版本,则可以跳过本节。

在 ES2015 中,返回对象的构造函数会隐式地用 this 的值替换 super(...) 的调用者。生成的构造函数代码需要捕获 super(...) 的潜在返回值并将其替换为 this

因此,ErrorArray 等类型的继承可能不再按预期工作。这是因为 ErrorArray 等类型的构造函数使用 ECMAScript 6 的 new.target 来调整原型链;然而,在 ECMAScript 5 中,在调用构造函数时无法确保为 new.target 设置一个值。其他降级编译器通常都有相同的限制。

对于以下子类:

class MsgError extends Error {
  constructor(m: string) {
    super(m);
  }
  sayHello() {
    return '你好' + this.message;
  }
}

你可能会发现:

  • 在构造这些子类的对象时,方法可能为 undefined,因此调用 sayHello 将导致错误。
  • 子类实例与其实例之间的 instanceof 将会失效,因此 (new MsgError()) instanceof MsgError 将返回 false

作为建议,你可以在任何 super(...) 调用之后手动调整原型。

class MsgError extends Error {
  constructor(m: string) {
    super(m);

    // 显式地设置原型。
    Object.setPrototypeOf(this, MsgError.prototype);
  }

  sayHello() {
    return 'hello ' + this.message;
  }
}

但是,MsgError 的任何子类也必须手动设置原型。对于不支持 Object.setPrototypeOf 的运行时环境,你可以使用 __proto__

不幸的是,这些解决方法在 Internet Explorer 10 及更早版本中不起作用。你可以手动将原型上的方法复制到实例本身(即将 MsgError.prototype 复制到 this),但原型链本身无法修复。

成员可见性

你可以使用 TypeScript 控制某些方法或属性对类外部代码的可见性。

public

类成员的默认可见性是 publicpublic 成员可以在任何地方访问:

class Greeter {
  public greet() {
    console.log('嗨!');
  }
}
const g = new Greeter();
g.greet();

因为 public 已经是默认的可见性修饰符,所以你不需要在类成员上写它,但出于风格或可读性的原因,你可以选择这样做。

protected

protected 成员只对声明它们的类的子类可见。

// @errors: 2445
class Greeter {
  public greet() {
    console.log('你好,' + this.getName());
  }
  protected getName() {
    return '嗨';
  }
}

class SpecialGreeter extends Greeter {
  public howdy() {
    // 可以在这里访问受保护的成员
    console.log('Howdy, ' + this.getName());
    //                          ^^^^^^^^^^^^^^
  }
}
const g = new SpecialGreeter();
g.greet(); // 可以
g.getName();

暴露 protected 成员

派生类需要遵循其基类的约定,但可以选择暴露具有更多功能的基类子类型。这包括将 protected 成员改为 public

class Base {
  protected m = 10;
}
class Derived extends Base {
  // 没有修饰符,所以默认是‘public’
  m = 15;
}
const d = new Derived();
console.log(d.m); // 可以

请注意,Derived 已经能够自由读取和写入 m,因此这种情况下并不会实质性地改变“安全性”。这里需要注意的主要事项是,在派生类中,如果这种暴露不是有意的,请小心重复使用 protected 修饰符。

跨层级的 protected 访问

不同的面向对象编程语言对于通过基类引用访问 protected 成员是否合法存在分歧:

// @errors: 2446
class Base {
  protected x: number = 1;
}
class Derived1 extends Base {
  protected x: number = 5;
}
class Derived2 extends Base {
  f1(other: Derived2) {
    other.x = 10;
  }
  f2(other: Derived1) {
    other.x = 10;
  }
}

例如,Java 认为这是合法的。另一方面,C# 和 C++ 则认为这段代码应该是非法的。

TypeScript 在这里与 C# 和 C++ 保持一致,因为只有从 Derived2 的子类中才能合法地访问 Derived2 中的 x,而 Derived1 不是其中之一。此外,如果通过 Derived1 引用访问 x 是非法的(肯定应该是!),那么通过基类引用访问它是无法改变这种情况的。

参见为何不能访问派生类的 Protected 成员?,其中更详细地解释了 C# 的原理。

private

privateprotected 类似,但甚至不允许从子类中访问该成员:

// @errors: 2341
class Base {
  private x = 0;
}
const b = new Base();
// 无法从类外部访问
console.log(b.x);
// @errors: 2341
class Base {
  private x = 0;
}
// ---cut---
class Derived extends Base {
  showX() {
    // 无法在子类中访问
    console.log(this.x);
  }
}

由于 private 成员对派生类不可见,派生类无法增加它们的可见性:

// @errors: 2415
class Base {
  private x = 0;
}
class Derived extends Base {
  x = 1;
}

跨实例的 private 访问

不同的面向对象编程语言在是否允许同一类的不同实例访问彼此的 private 成员上存在分歧。Java、C#、C++、Swift 和 PHP 等语言允许这样做,但 Ruby 不允许。

TypeScript 允许跨实例的 private 访问:

class A {
  private x = 10;

  public sameAs(other: A) {
    // 没有错误
    return other.x === this.x;
  }
}

注意事项

与 TypeScript 类型系统的其他方面一样,privateprotected 仅在类型检查期间执行

这意味着像 in 或简单的属性查询这样的 JavaScript 运行时构造仍然可以访问 privateprotected 成员:

class MySafe {
  private secretKey = 12345;
}
// 在 JavaScript 文件中...
const s = new MySafe();
// 将打印 12345
console.log(s.secretKey);

private 还允许在类型检查期间使用方括号表示法进行访问。这使得对 private 声明的字段的访问在单元测试等情况下更容易,缺点是这些字段是软私有的,不严格执行私有化。

// @errors: 2341
class MySafe {
  private secretKey = 12345;
}

const s = new MySafe();

// 在类型检查期间不允许
console.log(s.secretKey);

// 可以
console.log(s['secretKey']);

与 TypeScript 的 private 不同,JavaScript 的私有字段#)在编译后仍然保持私有,并且不提供先前提到的方括号访问等逃逸口,使得它们成为硬私有字段。

class Dog {
  #barkAmount = 0;
  personality = 'happy';

  constructor() {}
}
// @target: esnext
// @showEmit
class Dog {
  #barkAmount = 0;
  personality = 'happy';

  constructor() {}
}

当编译为 ES2021 或更低版本时,TypeScript 将使用 WeakMaps 代替 #

// @target: es2015
// @showEmit
class Dog {
  #barkAmount = 0;
  personality = 'happy';

  constructor() {}
}

如果你需要保护类中的值免受恶意操作,你应该使用提供硬运行时私有的机制,例如闭包、WeakMaps 或私有字段。请注意,这些在运行时添加的隐私检查可能会影响性能。

静态成员

背景阅读
静态成员(MDN)

类可以具有 static 成员。这些成员不与类的特定实例关联。可以通过类构造器对象本身访问它们:

class MyClass {
  static x = 0;
  static printX() {
    console.log(MyClass.x);
  }
}
console.log(MyClass.x);
MyClass.printX();

静态成员也可以使用相同的 publicprotectedprivate 可见性修饰符:

// @errors: 2341
class MyClass {
  private static x = 0;
}
console.log(MyClass.x);

静态成员也会被继承:

class Base {
  static getGreeting() {
    return '你好世界';
  }
}
class Derived extends Base {
  myGreeting = Derived.getGreeting();
}

特殊的静态名称

通常情况下,覆盖 Function 原型的属性是不安全/不可能的。由于类本身是可以使用 new 调用的函数,因此不能使用某些静态名称。诸如 namelengthcall 的函数属性不能作为 static 成员定义:

// @errors: 2699
class S {
  static name = 'S!';
}

为什么没有静态类?

TypeScript(以及 JavaScript)没有类似于 C# 的 static class 构造。

这些构造的存在是因为这些语言强制要求所有的数据和函数都在类内部;因为 TypeScript 中不存在这种限制,所以也就没有必要使用它们。在 JavaScript/TypeScript 中,通常将只有一个实例的类表示为普通的对象

例如,在 TypeScript 中我们不需要“static class”语法,因为普通的对象(甚至是顶级函数)同样可以完成工作:

// 不必要的“static”类
class MyStaticClass {
  static doSomething() {}
}

// 首选(替代方案 1)
function doSomething() {}

// 首选(替代方案 2)
const MyHelperObject = {
  dosomething() {},
};

类中的 static

静态块允许你编写一系列具有自己作用域的语句,这些语句可以访问包含类中的私有字段。这意味着我们可以编写具有所有语句编写功能、没有变量泄漏以及对类内部的完全访问权限的初始化代码。

declare function loadLastInstances(): any[];
// ---cut---
class Foo {
  static #count = 0;

  get count() {
    return Foo.#count;
  }

  static {
    try {
      const lastInstances = loadLastInstances();
      Foo.#count += lastInstances.length;
    } catch {}
  }
}

泛型类

类,类似于接口,可以是泛型的。当使用 new 实例化泛型类时,其类型参数的推断方式与函数调用相同:

class Box<Type> {
  contents: Type;
  constructor(value: Type) {
    this.contents = value;
  }
}

const b = new Box('你好!');
//    ^?

类可以像接口一样使用泛型约束和默认值。

静态成员中的类型参数

下面的代码是不合法的,可能并不明显为什么会出错:

// @errors: 2302
class Box<Type> {
  static defaultValue: Type;
}

请记住,类型始终会完全擦除!在运行时,只有一个 Box.defaultValue 属性槽位。这意味着设置 Box<string>.defaultValue(如果可能的话)也会同时改变 Box<number>.defaultValue——不太好。泛型类的 static 成员永远不能引用类的类型参数。

类的运行时 this

背景阅读
this 关键字(MDN)

请记住,TypeScript 不会改变 JavaScript 的运行时行为,而 JavaScript 因其某些怪异的运行时行为而有一定的“声名”。

JavaScript 对 this 的处理方式确实有些不寻常:

class MyClass {
  name = 'MyClass';
  getName() {
    return this.name;
  }
}
const c = new MyClass();
const obj = {
  name: 'obj',
  getName: c.getName,
};

// 输出“obj”,而不是“MyClass”
console.log(obj.getName());

简而言之,默认情况下,函数内部的 this 值取决于函数的调用方式。在这个例子中,因为函数是通过 obj 引用调用的,它的 this 值是 obj 而不是类的实例。

这通常不是你想要的结果!TypeScript 提供了一些方法来减轻或防止这种类型的错误。

箭头函数

背景阅读:
箭头函数(MDN)

如果一个函数在调用时经常丢失其 this 上下文,那么使用箭头函数属性而不是方法定义可能是有意义的:

class MyClass {
  name = 'MyClass';
  getName = () => {
    return this.name;
  };
}
const c = new MyClass();
const g = c.getName;
// 输出“MyClass”而不会崩溃
console.log(g());

这种方式有一些权衡:

  • this 值在运行时保证是正确的,即使对于未经 TypeScript 检查的代码也是如此。
  • 这会使用更多的内存,因为每个类实例都会有自己的这种方式定义的函数副本。
  • 无法在派生类中使用 super.getName,因为原型链中没有条目来获取基类方法。

this 参数

在 TypeScript 中,方法或函数定义中的名为 this 的初始参数具有特殊含义。这些参数在编译过程中被擦除:

type SomeType = any;
// ---cut---
// TypeScript 输入带有‘this’参数
function fn(this: SomeType, x: number) {
  /* ... */
}
// JavaScript 输出
function fn(x) {
  /* ... */
}

TypeScript 检查调用带有 this 参数的函数时,确保使用了正确的上下文。除了使用箭头函数外,我们还可以在方法定义中添加 this 参数,以静态地强制执行正确的方法调用:

// @errors: 2684
class MyClass {
  name = 'MyClass';
  getName(this: MyClass) {
    return this.name;
  }
}
const c = new MyClass();
// 正确调用
c.getName();

// 错误,会导致崩溃
const g = c.getName;
console.log(g());

这种方法具有与箭头函数方法相反的权衡:

  • JavaScript 调用者可能仍然在不知情的情况下错误地使用类方法。
  • 每个类定义只分配一个函数,而不是每个类实例一个函数。
  • 仍然可以通过 super 调用基本方法定义。

this 类型

在类中,一个特殊的类型 this 动态地指向当前类的类型。让我们看一下它的用法:

class Box {
  contents: string = '';
  set(value: string) {
    //  ^?
    this.contents = value;
    return this;
  }
}

在这里,TypeScript 推断出 set 的返回类型是 this,而不是 Box。现在让我们创建 Box 的一个子类:

class Box {
  contents: string = '';
  set(value: string) {
    this.contents = value;
    return this;
  }
}
// ---cut---
class ClearableBox extends Box {
  clear() {
    this.contents = '';
  }
}

const a = new ClearableBox();
const b = a.set('你好');
//    ^?

你还可以在参数类型注释中使用 this

class Box {
  content: string = '';
  sameAs(other: this) {
    return other.content === this.content;
  }
}

这与编写 other: Box 是不同的——如果你有一个派生类,它的 sameAs 方法现在只接受同一派生类的其他实例:

// @errors: 2345
class Box {
  content: string = '';
  sameAs(other: this) {
    return other.content === this.content;
  }
}

class DerivedBox extends Box {
  otherContent: string = '?';
}

const base = new Box();
const derived = new DerivedBox();
derived.sameAs(base);

基于 this 的类型护卫

在类和接口的方法中,你可以在返回位置使用 this is Type。当与类型缩小(例如 if 语句)混合使用时,目标对象的类型将缩小为指定的 Type

// @strictPropertyInitialization: false
class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep;
  }
  isDirectory(): this is Directory {
    return this instanceof Directory;
  }
  isNetworked(): this is Networked & this {
    return this.networked;
  }
  constructor(public path: string, private networked: boolean) {}
}

class FileRep extends FileSystemObject {
  constructor(path: string, public content: string) {
    super(path, false);
  }
}

class Directory extends FileSystemObject {
  children: FileSystemObject[];
}

interface Networked {
  host: string;
}

const fso: FileSystemObject = new FileRep('foo/bar.txt', 'foo');

if (fso.isFile()) {
  fso.content;
  // ^?
} else if (fso.isDirectory()) {
  fso.children;
  // ^?
} else if (fso.isNetworked()) {
  fso.host;
  // ^?
}

基于 this 的类型护卫的常见用例是允许对特定字段进行延迟验证。例如,以下示例在 hasValue 被验证为 true 时,从 box 中删除了 undefined 值:

class Box<T> {
  value?: T;

  hasValue(): this is { value: T } {
    return this.value !== undefined;
  }
}

const box = new Box();
box.value = 'Gameboy';

box.value;
//  ^?

if (box.hasValue()) {
  box.value;
  //  ^?
}

参数属性

TypeScript 提供了一种特殊的语法,可以将构造函数的参数转换为具有相同名称和值的类属性。这些被称为参数属性,通过在构造函数参数前加上可见性修饰符 publicprivateprotectedreadonly 来创建。生成的字段将具有这些修饰符:

// @errors: 2341
class Params {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // 不需要主体代码
  }
}
const a = new Params(1, 2, 3);
console.log(a.x);
//            ^?
console.log(a.z);

类表达式

背景阅读:
类表达式(MDN)

类表达式与类声明非常相似。唯一的真正区别是类表达式不需要名称,尽管我们可以通过将它们绑定到标识符来引用它们:

const someClass = class<Type> {
  content: Type;
  constructor(value: Type) {
    this.content = value;
  }
};

const m = new someClass('你好,世界');
//    ^?

构造函数签名

JavaScript 类使用 new 运算符进行实例化。对于某个类本身的类型,InstanceType 类型模拟了这个操作。

class Point {
  createdAt: number;
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.createdAt = Date.now();
    this.x = x;
    this.y = y;
  }
}
type PointInstance = InstanceType<typeof Point>;

function moveRight(point: PointInstance) {
  point.x += 5;
}

const point = new Point(3, 4);
moveRight(point);
point.x; // => 8

abstract 类和成员

在 TypeScript 中,类、方法和字段可以是抽象的

抽象方法抽象字段是指没有提供实现的方法或字段。这些成员必须存在于抽象类中,抽象类不能直接被实例化。

抽象类的作用是作为子类的基类,子类需要实现所有的抽象成员。当一个类没有任何抽象成员时,它被称为具体类

让我们来看一个例子:

// @errors: 2511
abstract class Base {
  abstract getName(): string;

  printName() {
    console.log('你好,' + this.getName());
  }
}

const b = new Base();

我们不能使用 new 实例化 Base,因为它是抽象的。相反,我们需要创建派生类并实现抽象成员:

abstract class Base {
  abstract getName(): string;
  printName() {}
}
// ---cut---
class Derived extends Base {
  getName() {
    return '世界';
  }
}

const d = new Derived();
d.printName();

请注意,如果我们忘记实现基类的抽象成员,将会遇到错误:

// @errors: 2515
abstract class Base {
  abstract getName(): string;
  printName() {}
}
// ---cut---
class Derived extends Base {
  // 忘记实现任何内容
}

抽象构造函数签名

有时候,你希望接受某个类构造函数,该构造函数生成的实例派生自某个抽象类。

例如,你可能希望编写以下代码:

// @errors: 2511
abstract class Base {
  abstract getName(): string;
  printName() {}
}
class Derived extends Base {
  getName() {
    return '';
  }
}
// ---cut---
function greet(ctor: typeof Base) {
  const instance = new ctor();
  instance.printName();
}

TypeScript 正确地告诉你,你正在尝试实例化一个抽象类。毕竟,根据 greet 的定义,编写以下代码是完全合法的,这将构造一个抽象类:

declare const greet: any, Base: any;
// ---cut---
// 错误!
greet(Base);

相反,你想编写一个接受具有构造函数签名的内容的函数:

// @errors: 2345
abstract class Base {
  abstract getName(): string;
  printName() {}
}
class Derived extends Base {
  getName() {
    return '';
  }
}
// ---cut---
function greet(ctor: new () => Base) {
  const instance = new ctor();
  instance.printName();
}
greet(Derived);
greet(Base);

现在 TypeScript 正确地告诉你哪些类构造函数可以调用——Derived 可以,因为它是具体类,但 Base 不能。

类之间的关系

在大多数情况下,TypeScript 中的类是按照结构进行比较的,与其他类型一样。

例如,这两个类可以互相替换使用,因为它们是相同的:

class Point1 {
  x = 0;
  y = 0;
}

class Point2 {
  x = 0;
  y = 0;
}

// 正常
const p: Point1 = new Point2();

类之间的子类型关系也存在,即使没有显式的继承:

// @strict: false
class Person {
  name: string;
  age: number;
}

class Employee {
  name: string;
  age: number;
  salary: number;
}

// 正常
const p: Person = new Employee();

这听起来很简单,但有一些情况会让人感到很奇怪。

空类没有成员。在结构类型系统中,没有成员的类型通常是其他任何类型的超类型。因此,如果你编写一个空类(不要这样做!),则可以使用任何类型来替代它:

class Empty {}

function fn(x: Empty) {
  // 对于 'x' 无法执行任何操作,所以我们不会做任何事情
}

// 全部都可以!
fn(window);
fn({});
fn(fn);

模块

JavaScript 历来具有多种处理代码模块化的方式。TypeScript 自 2012 年问世以来,已经实现了对这其中很多格式的支持。但随着时间的推移,社区和 JavaScript 规范已经趋于使用一种称为 ES 模块(或 ES6 模块)的格式。它使用的是 import/export 语法。

ES 模块在 2015 年被添加到 JavaScript 规范中,并且截至 2020 年已经在大多数 Web 浏览器和 JavaScript 运行时中得到广泛支持。

本手册将重点介绍 ES 模块及其流行的前身 CommonJS module.exports = 语法,你可以在参考部分的模块下找到其他模块模式的信息。

JavaScript 模块的定义方式

在 TypeScript 中(与 ECMAScript 2015 一样),任何包含顶级 importexport 声明的文件都被视为一个模块。

相反,如果一个文件没有任何顶级导入或导出声明,它将被视为一个脚本,其内容在全局范围内可用(因此也可用于模块)。

模块在它们自己的作用域中执行,而不是在全局作用域中执行。这意味着在模块中声明的变量、函数、类等在模块外部是不可见的,除非它们使用其中某种导出形式进行了显式导出。相反,要使用从其他模块导出的变量、函数、类、接口等,必须使用某种导入形式进行导入。

非模块文件

在开始之前,我们有必要了解一下 TypeScript 将什么当作模块。JavaScript 规范声明,任何没有 import 声明、export 声明或顶级 await 的 JavaScript 文件都应被视为脚本而不是模块。

在脚本文件中,声明的变量和类型处于共享的全局作用域。你应该要么使用 outFile 编译器选项将多个输入文件合并为一个输出文件,要么在 HTML 代码中使用多个 <script> 标签按正确的顺序加载这些文件。

如果你有一个当前没有任何 importexport 声明的文件,但你希望将其视为一个模块,可以添加以下代码行:

export {};

这将使文件成为一个不导出任何内容的模块。无论你的模块目标是什么,这种语法都适用。

TypeScript 中的模块

延申阅读:
Impatient JS(Modules)
MDN:JavaScript 模块

在用 TypeScript 编写基于模块的代码时,主要有三个要考虑的方面:

  • 语法:我希望使用什么语法来导入和导出内容?
  • 模块解析:模块名称(或路径)与磁盘上的文件之间的关系是什么?
  • 模块输出目标:我的输出 JavaScript 模块应该是什么样子的?

ES 模块语法

一个文件可以通过 export default 来声明主要的导出项:

// @filename: hello.ts
export default function helloWorld() {
  console.log('Hello, world!');
}

然后可以通过以下方式进行导入:

// @filename: hello.ts
export default function helloWorld() {
  console.log('Hello, world!');
}
// @filename: index.ts
// ---cut---
import helloWorld from './hello.js';
helloWorld();

除了默认导出之外,你还可以通过省略 default 来导出多个变量和函数:

// @filename: maths.ts
export var pi = 3.14;
export let squareTwo = 1.41;
export const phi = 1.61;

export class RandomNumberGenerator {}

export function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}

可以通过 import 语法在另一个文件中使用这些导出项:

// @filename: maths.ts
export var pi = 3.14;
export let squareTwo = 1.41;
export const phi = 1.61;
export class RandomNumberGenerator {}
export function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}
// @filename: app.ts
// ---cut---
import { pi, phi, absolute } from './maths.js';

console.log(pi);
const absPhi = absolute(phi);
//    ^?

更多的导入语法

可以使用以下格式将导入进行重命名:import {old as new}

// @filename: maths.ts
export var pi = 3.14;
// @filename: app.ts
// ---cut---
import { pi as π } from './maths.js';

console.log(π);
//          ^?

你可以将上述语法混合使用在单个 import 语句中:

// @filename: maths.ts
export const pi = 3.14;
export default class RandomNumberGenerator {}

// @filename: app.ts
import RandomNumberGenerator, { pi as π } from './maths.js';

RandomNumberGenerator;
// ^?

console.log(π);
//          ^?

你可以使用声明 * as name 将所有导出的对象放入单个命名空间中:

// @filename: maths.ts
export var pi = 3.14;
export let squareTwo = 1.41;
export const phi = 1.61;

export function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}
// ---cut---
// @filename: app.ts
import * as math from './maths.js';

console.log(math.pi);
const positivePhi = math.absolute(math.phi);
//    ^?

通过使用 import "./file",你可以导入一个文件,而不将任何变量包含在当前模块中:

// @filename: maths.ts
export var pi = 3.14;
// ---cut---
// @filename: app.ts
import './maths.js';

console.log('3.14');

在这种情况下,import 没有任何作用。然而,maths.ts 中的所有代码都被执行,这可能触发影响其他对象的副作用。

TypeScript 特定的 ES 模块语法

类型可以使用与 JavaScript 相同的语法进行导出和导入:

// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };

export interface Dog {
  breeds: string[];
  yearOfBirth: number;
}

// @filename: app.ts
import { Cat, Dog } from './animal.js';
type Animals = Cat | Dog;

TypeScript 通过两个用来声明类型导入的概念,扩展了 import 语法:

import type

这是一个能导入类型的导入语句:

// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };
export type Dog = { breeds: string[]; yearOfBirth: number };
export const createCatName = () => 'fluffy';

// @filename: valid.ts
import type { Cat, Dog } from './animal.js';
export type Animals = Cat | Dog;

// @filename: app.ts
// @errors: 1361
import type { createCatName } from './animal.js';
const name = createCatName();
内联 type 导入

TypeScript 4.5 还允许在个别导入中使用 type 前缀,以指示被导入的引用是一个类型:

// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };
export type Dog = { breeds: string[]; yearOfBirth: number };
export const createCatName = () => 'fluffy';
// ---cut---
// @filename: app.ts
import { createCatName, type Cat, type Dog } from './animal.js';

export type Animals = Cat | Dog;
const name = createCatName();

通过这些语法,非 TypeScript 的转译器(如 Babel、swc 或 esbuild)可以知道哪些导入可以安全地移除。

具有 CommonJS 行为的 ES 模块语法

TypeScript 具有 ES 模块语法,它与 CommonJS 和 AMD 的 require 直接对应。使用 ES 模块进行导入在大多数情况下与这些环境中的 require 相同,但是此语法确保你的 TypeScript 文件与 CommonJS 输出保持一对一的匹配:

/// <reference types="node" />
// @module: commonjs
// ---cut---
import fs = require('fs');
const code = fs.readFileSync('hello.ts', 'utf8');

你可以在模块参考页面了解更多关于此语法的信息。

CommonJS 语法

CommonJS 是大多数 npm 模块采用的格式。即使你使用上面的 ES 模块语法编写代码,了解 CommonJS 语法的基本原理也将有助于更轻松地进行调试。

导出

通过在名为 module 的全局变量上设置 exports 属性,可以导出标识符。

/// <reference types="node" />
// ---cut---
function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}

module.exports = {
  pi: 3.14,
  squareTwo: 1.41,
  phi: 1.61,
  absolute,
};

然后可以通过 require 语句导入这些文件:

// @module: commonjs
// @filename: maths.ts
/// <reference types="node" />
function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}

module.exports = {
  pi: 3.14,
  squareTwo: 1.41,
  phi: 1.61,
  absolute,
};
// @filename: index.ts
// ---cut---
const maths = require('./maths');
maths.pi;
//    ^?

或者你可以使用 JavaScript 的解构(destructuring)特性来简化代码:

// @module: commonjs
// @filename: maths.ts
/// <reference types="node" />
function absolute(num: number) {
  if (num < 0) return num * -1;
  return num;
}

module.exports = {
  pi: 3.14,
  squareTwo: 1.41,
  phi: 1.61,
  absolute,
};
// @filename: index.ts
// ---cut---
const { squareTwo } = require('./maths');
squareTwo;
// ^?

CommonJS 和 ES 模块的互操作性

在 CommonJS 和 ES 模块之间存在一个特性差异,即默认导入和模块命名空间对象导入之间的区别。TypeScript 具有一个编译器标志 esModuleInterop,用于减少这两组不同约束之间的摩擦。

TypeScript 的模块解析选项

模块解析是指接收 importrequire 语句中的字符串,并确定该字符串所指向的文件的过程。

TypeScript 包括两种解析策略:经典解析和 Node 解析。经典解析在编译器选项 module 不是 commonjs 时是默认策略,它用于向后兼容。Node 解析策略复制了 Node.js 在 CommonJS 模式下的工作方式,并额外检查 .ts.d.ts 文件。

有许多 TSConfig 标志会影响 TypeScript 内部的模块策略,包括 moduleResolutionbaseUrlpathsrootDirs

要了解这些策略的详细信息,可以参考模块解析

TypeScript 的模块输出选项

有两个选项会影响生成的 JavaScript 输出:

  • target:确定哪些 JS 特性会被降级(转换为在较旧的 JavaScript 运行环境中运行的代码),哪些保持不变
  • module:确定用于模块间交互的代码

你选择哪个 target,要看你的 TypeScript 代码要在哪种 JavaScript 运行时环境下执行。这可能取决于:你要兼容的最老的网页浏览器,你要使用的 Node.js 的最低版本,或者你的运行时环境有什么特殊的限制 - 比如 Electron。

所有模块之间的通信都是通过模块加载器进行的,编译器选项 module 确定使用哪个模块加载器。在运行时,模块加载器负责在执行模块之前定位和执行它的所有依赖项。

例如,以下是一个使用 ES 模块语法的 TypeScript 文件,展示了几种不同的 module 选项:

// @filename: constants.ts
export const valueOfPi = 3.142;
// @filename: index.ts
// ---cut---
import { valueOfPi } from './constants.js';

export const twoPi = valueOfPi * 2;

ES2020

// @showEmit
// @module: es2020
// @noErrors
import { valueOfPi } from './constants.js';

export const twoPi = valueOfPi * 2;

CommonJS

// @showEmit
// @module: commonjs
// @noErrors
import { valueOfPi } from './constants.js';

export const twoPi = valueOfPi * 2;

UMD

// @showEmit
// @module: umd
// @noErrors
import { valueOfPi } from './constants.js';

export const twoPi = valueOfPi * 2;

请注意,ES2020 在功能上与原始的 index.ts 相同。

你可以在 TSConfig 的 module 配置参考页面中查看所有可用选项及其生成的 JavaScript 代码。

TypeScript 命名空间

TypeScript 有自己的模块格式,称为 命名空间,它早于 ES 模块的标准化。这种语法具有许多用于创建复杂定义文件的有用功能,并且仍然在 DefinitelyTyped 中得到广泛使用。虽然没有被弃用,但大多数命名空间中的功能也存在于 ES 模块中,因此我们建议你使用 ES 模块,以与 JavaScript 的发展方向保持一致。你可以在命名空间参考页面了解更多关于命名空间的信息。

声明文件一章的目的是教你如何编写高质量的 TypeScript 声明文件。 我们假设你对 TypeScript 已经有了基本的了解。

如果没有,请先阅读TypeScript 手册 来了解一些基本知识,尤其是类型和模块的部分。

需要编写.d.ts文件的常见场景是为某个 npm 包添加类型信息。 如果是这种情况,你可以直接阅读Modules .d.ts

这篇指南被分成了以下章节。

示例

在编写声明文件时,我们经常遇到以下情况,那就是需要根据代码库提供的示例来编写声明文件。 示例一节展示了了许多常见的 API 模式,以及如何为它们编写声明文件。 该指南面向的是 TypeScript 的初学者,这些人可能并不熟悉 TypeScript 语言的每个特性。

结构

结构一节将帮助你了解常见库的格式以及如何为每种格式书写正确的声明文件。 如果你正在编辑一个已有文件,那么你可能不需要阅读此章节。 如果你在编写新的声明文件,那么强烈建议阅读此章节以理解库的不同格式是如何影响声明文件的编写的。

模版

模版一节里,你能找到一些声明文件,它们对于编写新的声明文件来讲会有所帮助。 如果你已经了解了库的结构,那么可以阅读相应的模版文件:

规范

声明文件里有些常见错误是很容易就可以避免的。 规范一节列出了常见的错误,并且描述了如何检测以及修复它们。 每个人都应该阅读这个章节以了解如何避免常见错误。

深入

针对那些对声明文件底层工作机制感兴趣的老手们,深入一节解释了编写声明文件时的很多高级概念, 并且展示了如何利用这些概念来创建整洁和直观的声明文件。

发布到 npm

发布一节讲解了如何将声明文件发布为 npm 包,以及如何管理包的依赖。

查找与安装声明文件

对于 JavaScript 库的使用者来讲,使用一节提供了一些简单步骤来查找与安装相应的声明文件。

举例

这篇指南的目的是教你如何书写高质量的 TypeScript 声明文件。 我们在这里会展示一些 API 的文档,以及它们的使用示例, 并且阐述了如何为它们书写声明文件。

这些例子是按复杂度递增的顺序组织的。

带属性的对象

文档

全局变量myLib包含一个用于创建祝福的makeGreeting函数, 以及表示祝福数量的numberOfGreetings属性。

代码

let result = myLib.makeGreeting('hello, world');
console.log('The computed greeting is:' + result);

let count = myLib.numberOfGreetings;

声明

使用declare namespace来描述用点表示法访问的类型或值。

declare namespace myLib {
  function makeGreeting(s: string): string;
  let numberOfGreetings: number;
}

函数重载

文档

getWidget函数接收一个数字参数并返回一个组件;或者接收一个字符串参数并返回一个组件数组。

代码

let x: Widget = getWidget(43);

let arr: Widget[] = getWidget('all of them');

声明

declare function getWidget(n: number): Widget;
declare function getWidget(s: string): Widget[];

可重用类型(接口)

文档

当指定一个祝福词时,你必须传入一个GreetingSettings对象。 这个对象具有以下几个属性:

1- greeting:必需的字符串

2- duration: 可选的持续时间(以毫秒表示)

3- color: 可选的字符串,比如'#ff00ff'

代码

greet({
  greeting: 'hello world',
  duration: 4000,
});

声明

使用interface定义一个带有属性的类型。

interface GreetingSettings {
  greeting: string;
  duration?: number;
  color?: string;
}

declare function greet(setting: GreetingSettings): void;

可重用类型(类型别名)

文档

在任何需要祝福词的地方,你可以提供一个string,一个返回string的函数或一个Greeter实例。

代码

function getGreeting() {
  return 'howdy';
}
class MyGreeter extends Greeter {}

greet('hello');
greet(getGreeting);
greet(new MyGreeter());

声明

你可以使用类型别名来定义类型的短名:

type GreetingLike = string | (() => string) | MyGreeter;

declare function greet(g: GreetingLike): void;

组织类型

文档

greeter对象能够记录到文件或显示一个警告。 你可以为.log(...)提供 log 选项以及为.alert(...)提供 alert 选项。

代码

const g = new Greeter('Hello');
g.log({ verbose: true });
g.alert({ modal: false, title: 'Current Greeting' });

声明

使用命名空间组织类型。

declare namespace GreetingLib {
  interface LogOptions {
    verbose?: boolean;
  }
  interface AlertOptions {
    modal: boolean;
    title?: string;
    color?: string;
  }
}

你也可以在一个声明中创建嵌套的命名空间:

declare namespace GreetingLib.Options {
  // Refer to via GreetingLib.Options.Log
  interface Log {
    verbose?: boolean;
  }
  interface Alert {
    modal: boolean;
    title?: string;
    color?: string;
  }
}

文档

你可以通过实例化Greeter对象来创建祝福语,或者继承Greeter对象来自定义祝福语。

代码

const myGreeter = new Greeter('hello, world');
myGreeter.greeting = 'howdy';
myGreeter.showGreeting();

class SpecialGreeter extends Greeter {
  constructor() {
    super('Very special greetings');
  }
}

声明

使用declare class来描述一个类或像类一样的对象。 类可以有属性和方法,就和构造函数一样。

declare class Greeter {
  constructor(greeting: string);

  greeting: string;
  showGreeting(): void;
}

全局变量

文档

全局变量foo包含了存在的组件总数。

代码

console.log('Half the number of widgets is ' + foo / 2);

声明

使用declare var声明变量。 如果变量是只读的,那么可以使用declare const。 你还可以使用declare let,如果变量拥有块级作用域。

/** The number of widgets present */
declare var foo: number;

全局函数

文档

你可以使用一个字符串参数来调用greet函数,并向用户显示一条祝福语。

代码

greet('hello, world');

声明

使用declare function来声明函数。

declare function greet(greeting: string): void;

代码库结构

一般来讲,组织声明文件的方式取决于代码库是如何被使用的。 在 JavaScript 中一个代码库有很多使用方式,这就需要你书写声明文件去匹配它们。 这篇指南涵盖了如何识别常见代码库的模式,以及怎样书写符合相应模式的声明文件。

针对代码库的每种主要的组织模式,在模版一节都有对应的文件。 你可以利用它们帮助你快速上手。

识别代码库的类型

首先,我们先看一下 TypeScript 声明文件能够表示的库的类型。 这里会简单展示每种类型的代码库的使用方式,以及如何去书写,还有一些真实案例。

识别代码库的类型是书写声明文件的第一步。 我们将会给出一些提示,关于怎样通过代码库的使用方法及其源码来识别库的类型。 根据库的文档及组织结构的不同,在这两种方式中可能一个会比另外的一个简单一些。 我们推荐你使用任意你喜欢的方式。

你应该寻找什么?

在为代码库编写声明文件时,你需要问自己以下几个问题。

  1. 如何获取代码库?

    比如,是否只能够从 npm 或 CDN 获取。

  2. 如何导入代码库?

    它是否添加了某个全局对象?它是否使用了requireimport/export语句?

针对不同类型的代码库的示例

模块化代码库

几乎所有的 Node.js 代码库都属于这一类。 这类代码库只能工作在有模块加载器的环境下。 比如,express只能在 Node.js 里工作,所以必须使用 CommonJS 的require函数加载。

ECMAScript 2015(也就是 ES2015,ECMAScript 6 或 ES6),CommonJS 和 RequireJS 具有相似的导入一个模块的写法。 例如,对于 JavaScript CommonJS (Node.js),写法如下:

var fs = require('fs');

对于 TypeScript 或 ES6,import关键字也具有相同的作用:

import * as fs from 'fs';

你通常会在模块化代码库的文档里看到如下说明:

var someLib = require('someLib');

define(..., ['someLib'], function(someLib) {

});

与全局模块一样,你也可能会在 UMD 模块的文档里看到这些例子,因此要仔细查看源码和文档。

从代码上识别模块化代码库

模块化代码库至少会包含以下代表性条目之一:

  • 无条件的调用requiredefine
  • import * as a from 'b';export c;这样的声明
  • 赋值给exportsmodule.exports

它们极少包含:

  • windowglobal的赋值

模块化代码库的模版

有以下四个模版可用:

你应该先阅读module.d.ts以便从整体上了解它们的工作方式。

然后,若一个模块可以当作函数调用,则使用module-function.d.ts

const x = require('foo');
// Note: calling 'x' as a function
const y = x(42);

如果一个模块可以使用new来构造,则使用module-class.d.ts

var x = require('bar');
// Note: using 'new' operator on the imported variable
var y = new x('hello');

如果一个模块在导入后会更改其它的模块,则使用module-plugin.d.ts

const jest = require('jest');
require('jest-matchers-files');

全局代码库

全局代码库可以通过全局作用域来访问(例如,不使用任何形式的import语句)。 许多代码库只是简单地导出一个或多个供使用的全局变量。 比如,如果你使用jQuery,那么可以使用$变量来引用它。

$(() => {
  console.log('hello!');
});

你通常能够在文档里看到如何在 HTML 的 script 标签里引用代码库:

<script src="http://a.great.cdn.for/someLib.js"></script>

目前,大多数流行的全局代码库都以 UMD 代码库发布。 UMD 代码库与全局代码库很难通过文档来识别。 在编写全局代码库的声明文件之前,确保代码库不是 UMD 代码库。

从代码来识别全局代码库

通常,全局代码库的代码十分简单。 一个全局的“Hello, world”代码库可以如下:

function createGreeting(s) {
  return 'Hello, ' + s;
}

或者这样:

window.createGreeting = function (s) {
  return 'Hello, ' + s;
};

在阅读全局代码库的代码时,你会看到:

  • 顶层的var语句或function声明
  • 一个或多个window.someName赋值语句
  • 假设 DOM 相关的原始值documentwindow存在

你不会看到:

  • 检查或使用了模块加载器,如requiredefine
  • CommonJS/Node.js 风格的导入语句,如var fs = require("fs");
  • define(...)调用
  • 描述require或导入代码库的文档

全局代码库的示例

由于将全局代码库转换为 UMD 代码库十分容易,因此很少有代码库仍然使用全局代码库风格。 然而,小型的代码库以及需要使用 DOM 的代码库仍然可以是全局的。

全局代码库的模版

模版文件global.d.ts定义了myLib示例代码库。 请务必阅读脚注:"防止命名冲突"

UMD

一个 UMD 模块既可以用作 ES 模块(使用导入语句),也可以用作全局变量(在缺少模块加载器的环境中使用)。 许多流行的代码库,如Moment.js,都是使用这模式发布的。 例如,在 Node.js 中或使用了 RequireJS 时,你可以这样使用:

import moment = require('moment');
console.log(moment.format());

在纯浏览器环境中,你可以这样使用:

console.log(moment.format());

识别 UMD 代码库

UMD 模块会检查运行环境中是否存在模块加载器。 这是一种常见模式,示例如下:

(function (root, factory) {
    if (typeof define === "function" && define.amd) {
        define(["libName"], factory);
    } else if (typeof module === "object" && module.exports) {
        module.exports = factory(require("libName"));
    } else {
        root.returnExports = factory(root.libName);
    }
}(this, function (b) {

如果你看到代码库中存在类如typeof definetypeof windowtypeof module的检测代码,尤其是在文件的顶端,那么它大概率是 UMD 代码库。

在 UMD 模块的文档中经常会提供在 Node.js 中结合require使用的示例,以及在浏览器中结合<script>标签使用的示例。

UMD 代码库的示例

大多数流行的代码库均提供了 UMD 格式的包。 例如,jQueryMoment.jslodash等。

模版

使用module-plugin.d.ts模版。

全局插件

一个全局插件是全局代码,它们会改变全局对象的结构。 对于全局修改的模块,在运行时存在冲突的可能。

比如,一些库往Array.prototypeString.prototype里添加新的方法。

识别全局插件

全局通常很容易地从它们的文档识别出来。

你会看到像下面这样的例子:

var x = 'hello, world';
// Creates new methods on built-in types
console.log(x.startsWithHello());

var y = [1, 2, 3];
// Creates new methods on built-in types
console.log(y.reverseAndSort());

模版

使用global-plugin.d.ts模版。

全局修改的模块

当一个全局修改的模块被导入的时候,它们会改变全局作用域里的值。 比如,存在一些库它们添加新的成员到String.prototype当导入它们的时候。 这种模式很危险,因为可能造成运行时的冲突, 但是我们仍然可以为它们书写声明文件。

识别全局修改的模块

全局修改的模块通常可以很容易地从它们的文档识别出来。 通常来讲,它们与全局插件相似,但是需要require调用来激活它们的效果。

你可能会看到像下面这样的文档:

// 'require' call that doesn't use its return value
var unused = require('magic-string-time');
/* or */
require('magic-string-time');

var x = 'hello, world';
// Creates new methods on built-in types
console.log(x.startsWithHello());

var y = [1, 2, 3];
// Creates new methods on built-in types
console.log(y.reverseAndSort());

模版

使用global-modifying-module.d.ts模版。

利用依赖

你的代码库可能会有若干种依赖。 本节会介绍如何在声明文件中导入它们。

对全局库的依赖

如果你的代码库依赖于某个全局代码库,则使用/// <reference types="..." />指令:

/// <reference types="someLib" />

function getThing(): someLib.thing;

对模块的依赖

如果你的代码库依赖于某个模块,则使用import语句:

import * as moment from 'moment';

function getThing(): moment;

对 UMD 模块的依赖

全局代码库

如果你的全局代码库依赖于某个 UMD 模块,则使用/// <reference types指令:

/// <reference types="moment" />

function getThing(): moment;

ES 模块或 UMD 模块代码库

如果你的模块或 UMD 代码库依赖于某个 UMD 代码库,则使用import语句:

import * as someLib from 'someLib';

不要使用/// <reference指令来声明对 UMD 代码库的依赖。

脚注

防止命名冲突

注意,虽说可以在全局作用域内定义许多类型。 但我们强烈建议不要这样做,因为当一个工程中存在多个声明文件时,它可能会导致难以解决的命名冲突。

可以遵循的一个简单规则是使用代码库提供的某个全局变量来声明拥有命名空间的类型。 例如,如果代码库提供了全局变量cats,那么可以这样写:

declare namespace cats {
  interface KittySettings {}
}

而不是:

// at top-level
interface CatsKittySettings {}

这样做会保证代码库可以被转换成 UMD 模块,且不会影响声明文件的使用者。

ES6 对模块插件的影响

一些插件会对已有模块的顶层导出进行添加或修改。 这在 CommonJS 以及其它模块加载器里是合法的,但 ES6 模块是不可改变的,因此该模式是不可行的。 因为,TypeScript 是模块加载器无关的,所以在编译时不会对该行为加以限制,但是开发者若想要转换到 ES6 模块加载器则需要注意这一点。

ES6 对模块调用签名的影响

许多代码库,如 Express,将自身导出为可调用的函数。 例如,Express 的典型用法如下:

import exp = require('express');
var app = exp();

在 ES6 模块加载器中,顶层对象(此例中就exp)只能拥有属性; 顶层的模块对象永远不能够被调用。

最常见的解决方案是为可调用的/可构造的对象定义一个default导出; 有些模块加载器会自动检测这种情况并且将顶层对象替换为default导出。 如果在 tsconfig.json 里启用了"esModuleInterop": true,那么 Typescript 会自动为你处理。

模板

最佳实践

常规类型

NumberStringBooleanSymbolObject

不要使用以下类型NumberStringBooleanSymbolObject。 这些类型表示是非原始的封箱后的对象类型,它们几乎没有在 JavaScript 代码里被正确地使用过。

/* 错误 */
function reverse(s: String): String;

应该使用numberstringbooleansymbol类型。

/* 正确 */
function reverse(s: string): string;

使用非原始的object类型来代替Object类型(在 TypeScript 2.2 中新增

泛型

不要定义没有使用过类型参数的泛型类型。 更多详情请参考:TypeScript FAQ page

any

请尽量不要使用any类型,除非你正在将 JavaScript 代码迁移到 TypeScript 代码。 编译器实际上会将any视作“对其关闭类型检查”。 使用它与在每个变量前使用@ts-ignore注释是一样的。 它只在首次将 JavaScript 工程迁移到 TypeScript 工程时有用,因为你可以把还没有迁移完的实体标记为any类型,但在完整的 TypeScript 工程中,这样做就会禁用掉类型检查。

如果你不清楚要接收什么类型的数据,或者你希望接收任意类型并直接向下传递而不使用它,那么就可以使用unknown类型。

回调函数类型

回调函数的返回值类型

不要为返回值会被忽略的回调函数设置返回值类型any

/* 错误 */
function fn(x: () => any) {
  x();
}

应该为返回值会被忽略的回调函数设置返回值类型void

/* 正确 */
function fn(x: () => void) {
  x();
}

原因:使用void相对安全,因为它能防止不小心使用了未经检查的x的返回值:

function fn(x: () => void) {
  var k = x(); // oops! meant to do something else
  k.doSomething(); // error, but would be OK if the return type had been 'any'
}

回调函数里的可选参数

不要在回调函数里使用可选参数,除非这是你想要的:

/* 错误 */
interface Fetcher {
  getObject(done: (data: any, elapsedTime?: number) => void): void;
}

这里有具体的意义:done回调函数可以用 1 个参数或 2 个参数调用。 代码的大意是说该回调函数不关注是否有elapsedTime参数, 但是不需要把这个参数定义为可选参数来达到此目的 -- 因为总是允许提供一个接收较少参数的回调函数。

应该将回调函数定义为无可选参数:

/* 正确 */
interface Fetcher {
  getObject(done: (data: any, elapsedTime: number) => void): void;
}

重载与回调函数

不要因回调函数的参数数量不同而编写不同的重载。

/* WRONG */
declare function beforeAll(action: () => void, timeout?: number): void;
declare function beforeAll(
  action: (done: DoneFn) => void,
  timeout?: number
): void;

应该只为最大数量参数的情况编写一个重载:

/* 正确 */
declare function beforeAll(
  action: (done: DoneFn) => void,
  timeout?: number
): void;

原因:回调函数总是允许忽略某个参数的,因此没必要为缺少可选参数的情况编写重载。 为缺少可选参数的情况提供重载可能会导致类型错误的回调函数被传入,因为它会匹配到第一个重载。

函数重载

顺序

不要把模糊的重载放在具体的重载前面:

/* 错误 */
declare function fn(x: any): any;
declare function fn(x: HTMLElement): number;
declare function fn(x: HTMLDivElement): string;

var myElem: HTMLDivElement;
var x = fn(myElem); // x: any, wat?

应该将重载排序,把具体的排在模糊的之前:

/* 正确 */
declare function fn(x: HTMLDivElement): string;
declare function fn(x: HTMLElement): number;
declare function fn(x: any): any;

var myElem: HTMLDivElement;
var x = fn(myElem); // x: string, :)

原因:当解析函数调用的时候,TypeScript 会选择匹配到的第一个重载。 当位于前面的重载比后面的“更模糊”,那么后面的会被隐藏且不会被选用。

使用可选参数

不要因为只有末尾参数不同而编写不同的重载:

/* WRONG */
interface Example {
  diff(one: string): number;
  diff(one: string, two: string): number;
  diff(one: string, two: string, three: boolean): number;
}

应该尽可能使用可选参数:

/* OK */
interface Example {
  diff(one: string, two?: string, three?: boolean): number;
}

注意,这只在返回值类型相同的情况是没问题的。

原因:有以下两个重要原因。

TypeScript 解析签名兼容性时会查看是否某个目标签名能够使用原参数调用, 且允许额外的参数。 下面的代码仅在签名被正确地使用可选参数定义时才会暴露出一个 bug:

function fn(x: (a: string, b: number, c: number) => void) {}
var x: Example;
// When written with overloads, OK -- used first overload
// When written with optionals, correctly an error
fn(x.diff);

第二个原因是当使用了 TypeScript “严格检查 null” 的特性时。 因为未指定的参数在 JavaScript 里表示为undefined,通常明确地为可选参数传入一个undefined不会有问题。 这段代码在严格 null 模式下可以工作:

var x: Example;
// When written with overloads, incorrectly an error because of passing 'undefined' to 'string'
// When written with optionals, correctly OK
x.diff('something', true ? undefined : 'hour');

使用联合类型

不要仅因某个特定位置上的参数类型不同而定义重载:

/* 错误 */
interface Moment {
  utcOffset(): number;
  utcOffset(b: number): Moment;
  utcOffset(b: string): Moment;
}

应该尽可能地使用联合类型:

/* 正确 */
interface Moment {
  utcOffset(): number;
  utcOffset(b: number | string): Moment;
}

注意,我们没有让b成为可选的,因为签名的返回值类型不同。

原因:这对于那些为该函数传入了值的使用者来说很重要。

function fn(x: string): void;
function fn(x: number): void;
function fn(x: number | string) {
  // When written with separate overloads, incorrectly an error
  // When written with union types, correctly OK
  return moment().utcOffset(x);
}

深入

组织模块以提供你想要的 API 结构是比较难的。 比如,你可能想要这样一个模块,可以用或不用new来创建不同的类型,在不同层级上暴露出不同的命名类型,且模块对象上还带有一些属性。

阅读这篇指南后,你就会了解如何编写复杂的声明文件来提供友好的 API 。 这篇指南针对于模块(或 UMD)代码库,因为它们的选择具有更高的可变性。

核心概念

如果你理解了一些关于 TypeScript 是如何工作的核心概念, 那么你就能够为任何结构书写声明文件。

类型

如果你正在阅读这篇指南,你可能已经大概了解 TypeScript 里的类型指是什么。 明确一下,类型通过以下方式引入:

  • 类型别名声明(type sn = number | string;
  • 接口声明(interface I { x: number[]; }
  • 类声明(class C { }
  • 枚举声明(enum E { A, B, C }
  • 指向某个类型的import声明

以上每种声明形式都会创建一个新的类型名称。

与类型相比,你可能已经理解了什么是值。 值是运行时的名字,它可以在表达式里引用。 比如let x = 5;创建了一个名为x的值。

同样地,以下方式能够创建值:

  • letconst,和var声明
  • 包含值的namespacemodule声明
  • enum声明
  • class声明
  • 指向值的import声明
  • function声明

命名空间

类型可以存在于命名空间里。 比如,有这样的声明let x: A.B.C, 我们就认为C类型来自于A.B命名空间。

这个区别虽细微但很重要 -- 这里,A.B不是必需的类型或值。

简单的组合:一个名字,多种意义

一个给定的名字A,我们可以找出三种不同的意义:一个类型,一个值或一个命名空间。 要如何去解析这个名字要看它所在的上下文是怎样的。 比如,在声明let m: A.A = A;中,A首先被当做命名空间,然后做为类型名,最后是值。 这些意义最终可能会指向完全不同的声明!

这看上去让人迷惑,但是只要我们不过度的重载这还是很方便的。 下面让我们来看看一些有用的组合行为。

内置组合

眼尖的读者可能会注意到,比如,class同时出现在类型列表里。 class C { }声明创建了两个东西: 类型C指向类的实例结构, C指向类构造函数。 枚举声明拥有相似的行为。

用户定义组合

假设我们写了模块文件foo.d.ts:

export var SomeVar: { a: SomeType };
export interface SomeType {
  count: number;
}

这样使用它:

import * as foo from './foo';
let x: foo.SomeType = foo.SomeVar.a;
console.log(x.count);

这可以很好地工作,但是我们知道SomeTypeSomeVar密切相关 因此我们想让它们有相同的名字。 我们可以使用组合通过相同的名字Bar表示这两种不同的对象(值和对象):

export var Bar: { a: Bar };
export interface Bar {
  count: number;
}

这提供了使用解构的机会:

import { Bar } from './foo';
let x: Bar = Bar.a;
console.log(x.count);

再次地,这里我们使用Bar做为类型和值。 注意我们没有声明Bar值为Bar类型 -- 它们是独立的。

高级组合

有一些声明能够通过多个声明组合。 比如,class C { }interface C { }可以同时存在并且都可以做为C类型的属性。

只要不产生冲突就是合法的。 一个普通的规则是值总是会和同名的其它值产生冲突,除非它们在不同命名空间里,类型冲突则发生在使用类型别名声明的情况下(type s = string),命名空间永远不会发生冲突。

让我们看看如何使用。

通过interface添加

我们可以使用一个interface向另一个interface声明里添加额外成员:

interface Foo {
  x: number;
}
// ... elsewhere ...
interface Foo {
  y: number;
}
let a: Foo = ...;
console.log(a.x + a.y); // OK

这同样作用于类:

class Foo {
  x: number;
}
// ... elsewhere ...
interface Foo {
  y: number;
}
let a: Foo = ...;
console.log(a.x + a.y); // OK

注意我们不能使用接口往类型别名里添加成员(type s = string;

通过namespace添加

namespace声明可以用来添加新类型,值和命名空间,只要不出现冲突即可。

比如,我们可以添加静态成员到一个类:

class C {}
// ... elsewhere ...
namespace C {
  export let x: number;
}
let y = C.x; // OK

注意在这个例子里,我们添加一个值到C静态部分(它的构造函数)。 这里因为我们添加了一个,且其它值的容器是另一个值(类型包含于命名空间,命名空间包含于另外的命名空间)。

我们还可以给类添加一个命名空间类型:

class C {}
// ... elsewhere ...
namespace C {
  export interface D {}
}
let y: C.D; // OK

在这个例子里,直到我们写了namespace声明才有了命名空间C。 做为命名空间的C不会与类创建的值C或类型C相互冲突。

最后,我们可以进行不同的合并通过namespace声明。

namespace X {
  export interface Y {}
  export class Z {}
}

// ... elsewhere ...
namespace X {
  export var Y: number;
  export namespace Z {
    export class C {}
  }
}
type X = string;

在这个例子里,第一个代码块创建了以下名字与含义:

  • 一个值X(因为namespace声明包含一个值,Z
  • 一个命名空间X(因为namespace声明包含一个类型,Y
  • 在命名空间X里的类型Y
  • 在命名空间X里的类型Z(类的实例结构)
  • X的一个属性值Z(类的构造函数)

第二个代码块创建了以下名字与含义:

  • Ynumber类型),它是值X的一个属性
  • 一个命名空间Z
  • Z,它是值X的一个属性
  • X.Z命名空间下的类型C
  • X.Z的一个属性值C
  • 类型X

使用export =import

一个重要的原则是exportimport声明会导出或导入目标的所有含义

发布

现在我们已经按照指南里的步骤写好了一个声明文件,是时候把它发布到 npm 了。 有两种主要方式用来将声明文件发布到 npm:

  1. 与你的 npm 包捆绑在一起,或
  2. 发布到 npm 上的@types organization

如果声明文件是由你写的源码生成的,那么就将声明文件与源码一起发布。 TypeScript 工程和 JavaScript 工程都可以使用--declaration选项来生成声明文件。

否则,我们推荐你将声明文件提交到 DefinitelyTyped,它会被发布到 npm 的@types里。

包含声明文件到你的 npm 包

如果你的包有一个主.js文件,你还需要在package.json里指定主声明文件。 设置types属性指向捆绑在一起的声明文件。 比如:

{
  "name": "awesome",
  "author": "Vandelay Industries",
  "version": "1.0.0",
  "main": "./lib/main.js",
  "types": "./lib/main.d.ts"
}

注意"typings""types"具有相同的意义,也可以使用它。

同样要注意的是如果主声明文件名是index.d.ts并且位置在包的根目录里(与index.js并列),你就不需要使用"types"属性指定了。

依赖

所有的依赖是由 npm 管理的。 确保所依赖的声明包都在package.json"dependencies"里指明了。 比如,假设我们写了一个包,它依赖于 Browserify 和 TypeScript。

{
  "name": "browserify-typescript-extension",
  "author": "Vandelay Industries",
  "version": "1.0.0",
  "main": "./lib/main.js",
  "types": "./lib/main.d.ts",
  "dependencies": {
    "browserify": "latest",
    "@types/browserify": "latest",
    "typescript": "next"
  }
}

这里,我们的包依赖于browserifytypescript包。 browserify没有把它的声明文件捆绑在它的 npm 包里,所以我们需要依赖于@types/browserify得到它的声明文件。 而typescript则相反,它把声明文件放在了 npm 包里,因此我们不需要依赖额外的包。

我们的包要从这两个包里暴露出声明文件,因此browserify-typescript-extension的用户也需要这些依赖。 正因此,我们使用"dependencies"而不是"devDependencies",否则用户将需要手动安装那些包。 如果我们只是在写一个命令行应用,并且我们的包不会被当做一个库使用的话,那么就可以使用devDependencies

危险信号

/// <reference path="..." />

不要在声明文件里使用/// <reference path="..." />

/// <reference path="../typescript/lib/typescriptServices.d.ts" />
....

应该使用/// <reference types="..." />代替

/// <reference types="typescript" />
....

务必阅读利用依赖一节了解详情。

打包所依赖的声明

如果你的类型声明依赖于另一个包:

  • 不要把依赖的包放进你的包里,保持它们在各自的文件里。
  • 不要将声明拷贝到你的包里。
  • 应该依赖在 npm 上的类型声明包,如果依赖包没包含它自己的声明文件的话。

使用typesVersions选择版本

当 TypeScript 打开一个package.json文件来决定要读取哪个文件,它首先会检查typesVersions字段。

带有typesVersions字段的package.json可能如下:

{
  "name": "package-name",
  "version": "1.0",
  "types": "./index.d.ts",
  "typesVersions": {
    ">=3.1": { "*": ["ts3.1/*"] }
  }
}

package.json告诉 TypeScript 去检查当前正在运行的 TypeScript 版本。 如果是 3.1 及以上版本,则会相对于package.json的位置来读取ts3.1目录的内容。 这就是{ "*": ["ts3.1/*"] }的含义 - 如果你熟悉路径映射的话,它们是相似的工作方式。

上例中,如果我们从"package-name"导入,当 TypeScript 版本为 3.1 时,TypeScript 会尝试解析[...]/node_modules/package-name/ts3.1/index.d.ts(及其它相应路径)。 如果导入的是package-name/foo,那么会尝试加载[...]/node_modules/package-name/ts3.1/foo.d.ts[...]/node_modules/package-name/ts3.1/foo/index.d.ts

那么如果不是在 TypeScript 3.1 环境中呢? 如果typesVersions中的每个字段都无法匹配,TypeScript 会回退到types字段,因此在 TypeScript 3.0 及之前的版本中会加载[...]/node_modules/package-name/index.d.ts文件。

匹配行为

TypeScript 是根据 Node.js 的语言化版本来进行编译器及语言版本匹配的。

存在多个字段

typesVersions支持同时指定多个字段,每个字段都指定了匹配的范围。

{
  "name": "package-name",
  "version": "1.0",
  "types": "./index.d.ts",
  "typesVersions": {
    ">=3.2": { "*": ["ts3.2/*"] },
    ">=3.1": { "*": ["ts3.1/*"] }
  }
}

由于指定的范围有发生重叠的潜在风险,因此声明文件的解析与指定的顺序是相关的。 也就是说,虽然>=3.2>=3.1都匹配 TypeScript 3.2 及以上版本,但调换顺序后会有不同的行为,因此上例不同于下例。

{
  "name": "package-name",
  "version": "1.0",
  "types": "./index.d.ts",
  "typesVersions": {
    // NOTE: this doesn't work!
    ">=3.1": { "*": ["ts3.1/*"] },
    ">=3.2": { "*": ["ts3.2/*"] }
  }
}

发布到@types

@types里的包是使用types-publisher 工具DefinitelyTyped里自动发布的。 如果想让你的包发布为@types包,提交一个 pull request 到https://github.com/DefinitelyTyped/DefinitelyTyped。 更多详情请参考contribution guidelines page

使用

下载

想要获取声明文件只需要用到 npm。

比如,想要获取 lodash 库的声明文件,只需使用下面的命令:

npm install --save @types/lodash

如果一个 npm 包像Publishing里介绍的一样已经包含其声明文件,那就不必再去下载相应的@types包了。

使用

下载完后,就可以直接在 TypeScript 里使用 lodash 了。 不论是在模块里还是全局代码里使用。

比如,你已经npm install安装了声明文件,你可以使用导入:

import * as _ from 'lodash';
_.padStart('Hello TypeScript!', 20, ' ');

或者如果你没有使用模块,那么你只需使用全局的变量_

_.padStart('Hello TypeScript!', 20, ' ');

查找

大多数情况下,类型声明包的名字总是与其在npm上的包的名字相同,但是有@types/前缀。 但如果你需要的话,你可以在https://aka.ms/types上查找你喜欢的库。

注意:如果你要找的声明文件不存在,你可以贡献一份,这样就方便了下一位开发者。 查看 DefinitelyTyped 贡献指南页了解详情。

JavaScript文件里的类型检查

TypeScript 2.3以后的版本支持使用--checkJs.js文件进行类型检查和错误提示。

你可以通过添加// @ts-nocheck注释来忽略类型检查;相反,你可以通过去掉--checkJs设置并添加一个// @ts-check注释来选择检查某些.js文件。 你还可以使用// @ts-ignore来忽略本行的错误。 如果你使用了tsconfig.json,JS检查将遵照一些严格检查标记,如noImplicitAnystrictNullChecks等。 但因为JS检查是相对宽松的,在使用严格标记时可能会有些出乎意料的情况。

对比.js文件和.ts文件在类型检查上的差异,有如下几点需要注意:

用JSDoc类型表示类型信息

.js文件里,类型可以和在.ts文件里一样被推断出来。 同样地,当类型不能被推断时,它们可以通过JSDoc来指定,就好比在.ts文件里那样。 如同TypeScript,--noImplicitAny会在编译器无法推断类型的位置报错。 (除了对象字面量的情况;后面会详细介绍)

JSDoc注解修饰的声明会被设置为这个声明的类型。比如:

/** @type {number} */
var x;

x = 0;      // OK
x = false;  // Error: boolean is not assignable to number

你可以在这里找到所有JSDoc支持的模式,JSDoc文档

属性的推断来自于类内的赋值语句

ES2015没提供声明类属性的方法。属性是动态赋值的,就像对象字面量一样。

.js文件里,编译器从类内部的属性赋值语句来推断属性类型。 属性的类型是在构造函数里赋的值的类型,除非它没在构造函数里定义或者在构造函数里是undefinednull。 若是这种情况,类型将会是所有赋的值的类型的联合类型。 在构造函数里定义的属性会被认为是一直存在的,然而那些在方法,存取器里定义的属性被当成可选的。

class C {
    constructor() {
        this.constructorOnly = 0
        this.constructorUnknown = undefined
    }
    method() {
        this.constructorOnly = false // error, constructorOnly is a number
        this.constructorUnknown = "plunkbat" // ok, constructorUnknown is string | undefined
        this.methodOnly = 'ok'  // ok, but methodOnly could also be undefined
    }
    method2() {
        this.methodOnly = true  // also, ok, methodOnly's type is string | boolean | undefined
    }
}

如果一个属性从没在类内设置过,它们会被当成未知的。

如果类的属性只是读取用的,那么就在构造函数里用JSDoc声明它的类型。 如果它稍后会被初始化,你甚至都不需要在构造函数里给它赋值:

class C {
    constructor() {
        /** @type {number | undefined} */
        this.prop = undefined;
        /** @type {number | undefined} */
        this.count;
    }
}

let c = new C();
c.prop = 0;          // OK
c.count = "string";  // Error: string is not assignable to number|undefined

构造函数等同于类

ES2015以前,Javascript使用构造函数代替类。 编译器支持这种模式并能够将构造函数识别为ES2015的类。 属性类型推断机制和上面介绍的一致。

function C() {
    this.constructorOnly = 0
    this.constructorUnknown = undefined
}
C.prototype.method = function() {
    this.constructorOnly = false // error
    this.constructorUnknown = "plunkbat" // OK, the type is string | undefined
}

支持CommonJS模块

.js文件里,TypeScript能识别出CommonJS模块。 对exportsmodule.exports的赋值被识别为导出声明。 相似地,require函数调用被识别为模块导入。例如:

// same as `import module "fs"`
const fs = require("fs");

// same as `export function readFile`
module.exports.readFile = function(f) {
  return fs.readFileSync(f);
}

对JavaScript文件里模块语法的支持比在TypeScript里宽泛多了。 大部分的赋值和声明方式都是允许的。

类,函数和对象字面量是命名空间

.js文件里的类是命名空间。 它可以用于嵌套类,比如:

class C {
}
C.D = class {
}

ES2015之前的代码,它可以用来模拟静态方法:

function Outer() {
  this.y = 2
}
Outer.Inner = function() {
  this.yy = 2
}

它还可以用于创建简单的命名空间:

var ns = {}
ns.C = class {
}
ns.func = function() {
}

同时还支持其它的变化:

// 立即调用的函数表达式
var ns = (function (n) {
  return n || {};
})();
ns.CONST = 1

// defaulting to global
var assign = assign || function() {
  // code goes here
}
assign.extra = 1

对象字面量是开放的

.ts文件里,用对象字面量初始化一个变量的同时也给它声明了类型。 新的成员不能再被添加到对象字面量中。 这个规则在.js文件里被放宽了;对象字面量具有开放的类型,允许添加并访问原先没有定义的属性。例如:

var obj = { a: 1 };
obj.b = 2;  // Allowed

对象字面量的表现就好比具有一个默认的索引签名[x:string]: any,它们可以被当成开放的映射而不是封闭的对象。

与其它JS检查行为相似,这种行为可以通过指定JSDoc类型来改变,例如:

/** @type {{a: number}} */
var obj = { a: 1 };
obj.b = 2;  // Error, type {a: number} does not have property b

null,undefined,和空数组的类型是any或any[]

任何用nullundefined初始化的变量,参数或属性,它们的类型是any,就算是在严格null检查模式下。 任何用[]初始化的变量,参数或属性,它们的类型是any[],就算是在严格null检查模式下。 唯一的例外是像上面那样有多个初始化器的属性。

function Foo(i = null) {
    if (!i) i = 1;
    var j = undefined;
    j = 2;
    this.l = [];
}
var foo = new Foo();
foo.l.push(foo.i);
foo.l.push("end");

函数参数是默认可选的

由于在ES2015之前无法指定可选参数,因此.js文件里所有函数参数都被当做是可选的。 使用比预期少的参数调用函数是允许的。

需要注意的一点是,使用过多的参数调用函数会得到一个错误。

例如:

function bar(a, b) {
  console.log(a + " " + b);
}

bar(1);       // OK, second argument considered optional
bar(1, 2);
bar(1, 2, 3); // Error, too many arguments

使用JSDoc注解的函数会被从这条规则里移除。 使用JSDoc可选参数语法来表示可选性。比如:

/**
 * @param {string} [somebody] - Somebody's name.
 */
function sayHello(somebody) {
    if (!somebody) {
        somebody = 'John Doe';
    }
    console.log('Hello ' + somebody);
}

sayHello();

arguments推断出的var-args参数声明

如果一个函数的函数体内有对arguments的引用,那么这个函数会隐式地被认为具有一个var-arg参数(比如:(...arg: any[]) => any))。使用JSDoc的var-arg语法来指定arguments的类型。

/** @param {...number} args */
function sum(/* numbers */) {
    var total = 0
    for (var i = 0; i < arguments.length; i++) {
      total += arguments[i]
    }
    return total
}

未指定的类型参数默认为any

由于JavaScript里没有一种自然的语法来指定泛型参数,因此未指定的参数类型默认为any

在extends语句中:

例如,React.Component被定义成具有两个类型参数,PropsState。 在一个.js文件里,没有一个合法的方式在extends语句里指定它们。默认地参数类型为any

import { Component } from "react";

class MyComponent extends Component {
    render() {
        this.props.b; // Allowed, since this.props is of type any
    }
}

使用JSDoc的@augments来明确地指定类型。例如:

import { Component } from "react";

/**
 * @augments {Component<{a: number}, State>}
 */
class MyComponent extends Component {
    render() {
        this.props.b; // Error: b does not exist on {a:number}
    }
}

在JSDoc引用中:

JSDoc里未指定的类型参数默认为any

/** @type{Array} */
var x = [];

x.push(1);        // OK
x.push("string"); // OK, x is of type Array<any>

/** @type{Array.<number>} */
var y = [];

y.push(1);        // OK
y.push("string"); // Error, string is not assignable to number

在函数调用中

泛型函数的调用使用arguments来推断泛型参数。有时候,这个流程不能够推断出类型,大多是因为缺少推断的源;在这种情况下,类型参数类型默认为any。例如:

var p = new Promise((resolve, reject) => { reject() });

p; // Promise<any>;

支持的JSDoc

下面的列表列出了当前所支持的JSDoc注解,你可以用它们在JavaScript文件里添加类型信息。

注意,没有在下面列出的标记(例如@async)都是还不支持的。

  • @type
  • @param (or @arg or @argument)
  • @returns (or @return)
  • @typedef
  • @callback
  • @template
  • @class (or @constructor)
  • @this
  • @extends (or @augments)
  • @enum

它们代表的意义与usejsdoc.org上面给出的通常是一致的或者是它的超集。 下面的代码描述了它们的区别并给出了一些示例。

@type

可以使用@type标记并引用一个类型名称(原始类型,TypeScript里声明的类型,或在JSDoc里@typedef标记指定的) 可以使用任何TypeScript类型和大多数JSDoc类型。

/**
 * @type {string}
 */
var s;

/** @type {Window} */
var win;

/** @type {PromiseLike<string>} */
var promisedString;

// You can specify an HTML Element with DOM properties
/** @type {HTMLElement} */
var myElement = document.querySelector(selector);
element.dataset.myData = '';

@type可以指定联合类型—例如,stringboolean类型的联合。

/**
 * @type {(string | boolean)}
 */
var sb;

注意,括号是可选的。

/**
 * @type {string | boolean}
 */
var sb;

有多种方式来指定数组类型:

/** @type {number[]} */
var ns;
/** @type {Array.<number>} */
var nds;
/** @type {Array<number>} */
var nas;

还可以指定对象字面量类型。 例如,一个带有a(字符串)和b(数字)属性的对象,使用下面的语法:

/** @type {{ a: string, b: number }} */
var var9;

可以使用字符串和数字索引签名来指定map-likearray-like的对象,使用标准的JSDoc语法或者TypeScript语法。

/**
 * A map-like object that maps arbitrary `string` properties to `number`s.
 *
 * @type {Object.<string, number>}
 */
var stringToNumber;

/** @type {Object.<number, object>} */
var arrayLike;

这两个类型与TypeScript里的{ [x: string]: number }{ [x: number]: any }是等同的。编译器能识别出这两种语法。

可以使用TypeScript或Closure语法指定函数类型。

/** @type {function(string, boolean): number} Closure syntax */
var sbn;
/** @type {(s: string, b: boolean) => number} Typescript syntax */
var sbn2;

或者直接使用未指定的Function类型:

/** @type {Function} */
var fn7;
/** @type {function} */
var fn6;

Closure的其它类型也可以使用:

/**
 * @type {*} - can be 'any' type
 */
var star;
/**
 * @type {?} - unknown type (same as 'any')
 */
var question;

转换

TypeScript借鉴了Closure里的转换语法。 在括号表达式前面使用@type标记,可以将一种类型转换成另一种类型

/**
 * @type {number | string}
 */
var numberOrString = Math.random() < 0.5 ? "hello" : 100;
var typeAssertedNumber = /** @type {number} */ (numberOrString)

导入类型

可以使用导入类型从其它文件中导入声明。 这个语法是TypeScript特有的,与JSDoc标准不同:

/**
 * @param p { import("./a").Pet }
 */
function walk(p) {
    console.log(`Walking ${p.name}...`);
}

导入类型也可以使用在类型别名声明中:

/**
 * @typedef { import("./a").Pet } Pet
 */

/**
 * @type {Pet}
 */
var myPet;
myPet.name;

导入类型可以用在从模块中得到一个值的类型。

/**
 * @type {typeof import("./a").x }
 */
var x = require("./a").x;

@param@returns

@param语法和@type相同,但增加了一个参数名。 使用[]可以把参数声明为可选的:

// Parameters may be declared in a variety of syntactic forms
/**
 * @param {string}  p1 - A string param.
 * @param {string=} p2 - An optional param (Closure syntax)
 * @param {string} [p3] - Another optional param (JSDoc syntax).
 * @param {string} [p4="test"] - An optional param with a default value
 * @return {string} This is the result
 */
function stringsStringStrings(p1, p2, p3, p4){
  // TODO
}

函数的返回值类型也是类似的:

/**
 * @return {PromiseLike<string>}
 */
function ps(){}

/**
 * @returns {{ a: string, b: number }} - May use '@returns' as well as '@return'
 */
function ab(){}

@typedef, @callback, 和 @param

@typedef可以用来声明复杂类型。 和@param类似的语法。

/**
 * @typedef {Object} SpecialType - creates a new type named 'SpecialType'
 * @property {string} prop1 - a string property of SpecialType
 * @property {number} prop2 - a number property of SpecialType
 * @property {number=} prop3 - an optional number property of SpecialType
 * @prop {number} [prop4] - an optional number property of SpecialType
 * @prop {number} [prop5=42] - an optional number property of SpecialType with default
 */
/** @type {SpecialType} */
var specialTypeObject;

可以在第一行上使用objectObject

/**
 * @typedef {object} SpecialType1 - creates a new type named 'SpecialType1'
 * @property {string} prop1 - a string property of SpecialType1
 * @property {number} prop2 - a number property of SpecialType1
 * @property {number=} prop3 - an optional number property of SpecialType1
 */
/** @type {SpecialType1} */
var specialTypeObject1;

@param允许使用相似的语法。 注意,嵌套的属性名必须使用参数名做为前缀:

/**
 * @param {Object} options - The shape is the same as SpecialType above
 * @param {string} options.prop1
 * @param {number} options.prop2
 * @param {number=} options.prop3
 * @param {number} [options.prop4]
 * @param {number} [options.prop5=42]
 */
function special(options) {
  return (options.prop4 || 1001) + options.prop5;
}

@callback@typedef相似,但它指定函数类型而不是对象类型:

/**
 * @callback Predicate
 * @param {string} data
 * @param {number} [index]
 * @returns {boolean}
 */
/** @type {Predicate} */
const ok = s => !(s.length % 2);

当然,所有这些类型都可以使用TypeScript的语法@typedef在一行上声明:

/** @typedef {{ prop1: string, prop2: string, prop3?: number }} SpecialType */
/** @typedef {(data: string, index?: number) => boolean} Predicate */

@template

使用@template声明泛型:

/**
 * @template T
 * @param {T} x - A generic parameter that flows through to the return type
 * @return {T}
 */
function id(x){ return x }

用逗号或多个标记来声明多个类型参数:

/**
 * @template T,U,V
 * @template W,X
 */

还可以在参数名前指定类型约束。 只有列表的第一项类型参数会被约束:

/**
 * @template {string} K - K must be a string or string literal
 * @template {{ serious(): string }} Seriousalizable - must have a serious method
 * @param {K} key
 * @param {Seriousalizable} object
 */
function seriousalize(key, object) {
  // ????
}

@constructor

编译器通过this属性的赋值来推断构造函数,但你可以让检查更严格提示更友好,你可以添加一个@constructor标记:

/**
 * @constructor
 * @param {number} data
 */
function C(data) {
  this.size = 0;
  this.initialize(data); // Should error, initializer expects a string
}
/**
 * @param {string} s
 */
C.prototype.initialize = function (s) {
  this.size = s.length
}

var c = new C(0);
var result = C(1); // C should only be called with new

通过@constructorthis将在构造函数C里被检查,因此你在initialize方法里得到一个提示,如果你传入一个数字你还将得到一个错误提示。如果你直接调用C而不是构造它,也会得到一个错误。

不幸的是,这意味着那些既能构造也能直接调用的构造函数不能使用@constructor

@this

编译器通常可以通过上下文来推断出this的类型。但你可以使用@this来明确指定它的类型:

/**
 * @this {HTMLElement}
 * @param {*} e
 */
function callbackForLater(e) {
    this.clientHeight = parseInt(e) // should be fine!
}

@extends

当JavaScript类继承了一个基类,无处指定类型参数的类型。而@extends标记提供了这样一种方式:

/**
 * @template T
 * @extends {Set<T>}
 */
class SortableSet extends Set {
  // ...
}

注意@extends只作用于类。当前,无法实现构造函数继承类的情况。

@enum

@enum标记允许你创建一个对象字面量,它的成员都有确定的类型。不同于JavaScript里大多数的对象字面量,它不允许添加额外成员。

/** @enum {number} */
const JSDocState = {
  BeginningOfLine: 0,
  SawAsterisk: 1,
  SavingComments: 2,
}

注意@enum与TypeScript的@enum大不相同,它更加简单。然而,不同于TypeScript的枚举,@enum可以是任何类型:

/** @enum {function(number): number} */
const Math = {
  add1: n => n + 1,
  id: n => -n,
  sub1: n => n - 1,
}

更多示例

var someObj = {
  /**
   * @param {string} param1 - Docs on property assignments work
   */
  x: function(param1){}
};

/**
 * As do docs on variable assignments
 * @return {Window}
 */
let someFunc = function(){};

/**
 * And class methods
 * @param {string} greeting The greeting to use
 */
Foo.prototype.sayHi = (greeting) => console.log("Hi!");

/**
 * And arrow functions expressions
 * @param {number} x - A multiplier
 */
let myArrow = x => x * x;

/**
 * Which means it works for stateless function components in JSX too
 * @param {{a: string, b: number}} test - Some param
 */
var fc = (test) => <div>{test.a.charAt(0)}</div>;

/**
 * A parameter can be a class constructor, using Closure syntax.
 *
 * @param {{new(...args: any[]): object}} C - The class to register
 */
function registerClass(C) {}

/**
 * @param {...string} p1 - A 'rest' arg (array) of strings. (treated as 'any')
 */
function fn10(p1){}

/**
 * @param {...string} p1 - A 'rest' arg (array) of strings. (treated as 'any')
 */
function fn9(p1) {
  return p1.join();
}

已知不支持的模式

在值空间中将对象视为类型是不可以的,除非对象创建了类型,如构造函数。

function aNormalFunction() {

}
/**
 * @type {aNormalFunction}
 */
var wrong;
/**
 * Use 'typeof' instead:
 * @type {typeof aNormalFunction}
 */
var right;

对象字面量属性上的=后缀不能指定这个属性是可选的:

/**
 * @type {{ a: string, b: number= }}
 */
var wrong;
/**
 * Use postfix question on the property name instead:
 * @type {{ a: string, b?: number }}
 */
var right;

Nullable类型只在启用了strictNullChecks检查时才启作用:

/**
 * @type {?number}
 * With strictNullChecks: true -- number | null
 * With strictNullChecks: off  -- number
 */
var nullable;

Non-nullable类型没有意义,以其原类型对待:

/**
 * @type {!number}
 * Just has type number
 */
var normal;

不同于JSDoc类型系统,TypeScript只允许将类型标记为包不包含null。 没有明确的Non-nullable -- 如果启用了strictNullChecks,那么number是非null的。 如果没有启用,那么number是可以为null的。

工程配置

tsconfig.json

概述

如果一个目录下存在一个tsconfig.json文件,那么它意味着这个目录是TypeScript项目的根目录。 tsconfig.json文件中指定了用来编译这个项目的根文件和编译选项。 一个项目可以通过以下方式之一来编译:

使用tsconfig.json

  • 不带任何输入文件的情况下调用tsc,编译器会从当前目录开始去查找tsconfig.json文件,逐级向上搜索父目录。
  • 不带任何输入文件的情况下调用tsc,且使用命令行参数--project(或-p)指定一个包含tsconfig.json文件的目录。

当命令行上指定了输入文件时,tsconfig.json文件会被忽略。

示例

tsconfig.json示例文件:

  • 使用"files"属性
{
    "compilerOptions": {
        "module": "commonjs",
        "noImplicitAny": true,
        "removeComments": true,
        "preserveConstEnums": true,
        "sourceMap": true
    },
    "files": [
        "core.ts",
        "sys.ts",
        "types.ts",
        "scanner.ts",
        "parser.ts",
        "utilities.ts",
        "binder.ts",
        "checker.ts",
        "emitter.ts",
        "program.ts",
        "commandLineParser.ts",
        "tsc.ts",
        "diagnosticInformationMap.generated.ts"
    ]
}
  • 使用"include""exclude"属性

    {
        "compilerOptions": {
            "module": "system",
            "noImplicitAny": true,
            "removeComments": true,
            "preserveConstEnums": true,
            "outFile": "../../built/local/tsc.js",
            "sourceMap": true
        },
        "include": [
            "src/**/*"
        ],
        "exclude": [
            "node_modules",
            "**/*.spec.ts"
        ]
    }
    

细节

"compilerOptions"可以被忽略,这时编译器会使用默认值。在这里查看完整的编译器选项列表。

"files"指定一个包含相对或绝对文件路径的列表。 "include""exclude"属性指定一个文件glob匹配模式列表。 支持的glob通配符有:

  • * 匹配0或多个字符(不包括目录分隔符)
  • ? 匹配一个任意字符(不包括目录分隔符)
  • **/ 递归匹配任意子目录

如果一个glob模式里的某部分只包含*.*,那么仅有支持的文件扩展名类型被包含在内(比如默认.ts.tsx,和.d.ts, 如果allowJs设置能true还包含.js.jsx)。

如果"files""include"都没有被指定,编译器默认包含当前目录和子目录下所有的TypeScript文件(.ts, .d.ts.tsx),排除在"exclude"里指定的文件。JS文件(.js.jsx)也被包含进来如果allowJs被设置成true。 如果指定了"files""include",编译器会将它们结合一并包含进来。 使用"outDir"指定的目录下的文件永远会被编译器排除,除非你明确地使用"files"将其包含进来(这时就算用exclude指定也没用)。

使用"include"引入的文件可以使用"exclude"属性过滤。 然而,通过"files"属性明确指定的文件却总是会被包含在内,不管"exclude"如何设置。 如果没有特殊指定,"exclude"默认情况下会排除node_modulesbower_componentsjspm_packages<outDir>目录。

任何被"files""include"指定的文件所引用的文件也会被包含进来。 A.ts引用了B.ts,因此B.ts不能被排除,除非引用它的A.ts"exclude"列表中。

需要注意编译器不会去引入那些可能做为输出的文件;比如,假设我们包含了index.ts,那么index.d.tsindex.js会被排除在外。 通常来讲,不推荐只有扩展名的不同来区分同目录下的文件。

tsconfig.json文件可以是个空文件,那么所有默认的文件(如上面所述)都会以默认配置选项编译。

在命令行上指定的编译选项会覆盖在tsconfig.json文件里的相应选项。

@typestypeRootstypes

默认所有_可见的_"@types"包会在编译过程中被包含进来。 node_modules/@types文件夹下以及它们子文件夹下的所有包都是_可见的_; 也就是说,./node_modules/@types/../node_modules/@types/../../node_modules/@types/等等。

如果指定了typeRoots只有typeRoots下面的包才会被包含进来。 比如:

{
   "compilerOptions": {
       "typeRoots" : ["./typings"]
   }
}

这个配置文件会包含_所有_./typings下面的包,而不包含./node_modules/@types里面的包。

如果指定了types,只有被列出来的包才会被包含进来。 比如:

{
   "compilerOptions": {
        "types" : ["node", "lodash", "express"]
   }
}

这个tsconfig.json文件将_仅会_包含 ./node_modules/@types/node./node_modules/@types/lodash./node_modules/@types/express。/@types/。 node_modules/@types/*里面的其它包不会被引入进来。

指定"types": []来禁用自动引入@types包。

注意,自动引入只在你使用了全局的声明(相反于模块)时是重要的。 如果你使用import "foo"语句,TypeScript仍然会查找node_modulesnode_modules/@types文件夹来获取foo包。

使用extends继承配置

tsconfig.json文件可以利用extends属性从另一个配置文件里继承配置。

extendstsconfig.json文件里的顶级属性(与compilerOptionsfilesinclude,和exclude一样)。 extends的值是一个字符串,包含指向另一个要继承文件的路径。

在原文件里的配置先被加载,然后被来自继承文件里的配置重写。 如果发现循环引用,则会报错。

来自所继承配置文件的filesincludeexclude_覆盖_源配置文件的属性。

配置文件里的相对路径在解析时相对于它所在的文件。

比如:

configs/base.json

{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}

tsconfig.json

{
  "extends": "./configs/base",
  "files": [
    "main.ts",
    "supplemental.ts"
  ]
}

tsconfig.nostrictnull.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "strictNullChecks": false
  }
}

compileOnSave

在最顶层设置compileOnSave标记,可以让IDE在保存文件的时候根据tsconfig.json重新生成文件。

{
    "compileOnSave": true,
    "compilerOptions": {
        "noImplicitAny" : true
    }
}

要想支持这个特性需要Visual Studio 2015, TypeScript1.8.4以上并且安装atom-typescript插件。

模式

到这里查看模式: http://json.schemastore.org/tsconfig.

工程引用

工程引用是TypeScript 3.0的新特性,它支持将TypeScript程序的结构分割成更小的组成部分。

这样可以改善构建时间,强制在逻辑上对组件进行分离,更好地组织你的代码。

TypeScript 3.0还引入了tsc的一种新模式,即--build标记,它与工程引用协同工作可以加速TypeScript的构建。

一个工程示例

让我们来看一个非常普通的工程,并瞧瞧工程引用特性是如何帮助我们更好地组织代码的。 假设这个工程具有两个模块:converterunites,以及相应的测试代码:

/src/converter.ts
/src/units.ts
/test/converter-tests.ts
/test/units-tests.ts
/tsconfig.json

测试文件导入相应的实现文件并进行测试:

// converter-tests.ts
import * as converter from "../converter";

assert.areEqual(converter.celsiusToFahrenheit(0), 32);

在之前,这种使用单一tsconfig文件的结构会稍显笨拙:

  • 实现文件也可以导入测试文件
  • 无法同时构建testsrc,除非把src也放在输出文件夹中,但通常并不想这样做
  • 仅对实现文件的_内部_细节进行改动,必需再次对测试进行_类型检查_,尽管这是根本不必要的
  • 仅对测试文件进行改动,必需再次对实现文件进行_类型检查_,尽管其实什么都没有变

你可以使用多个tsconfig文件来解决_部分_问题,但是又会出现新问题:

  • 缺少内置的实时检查,因此你得多次运行tsc
  • 多次调用tsc会增加我们等待的时间
  • tsc -w不能一次在多个配置文件上运行

工程引用可以解决全部这些问题,而且还不止。

何为工程引用?

tsconfig.json增加了一个新的顶层属性references。它是一个对象的数组,指明要引用的工程:

{
    "compilerOptions": {
        // The usual
    },
    "references": [
        { "path": "../src" }
    ]
}

每个引用的path属性都可以指向到包含tsconfig.json文件的目录,或者直接指向到配置文件本身(名字是任意的)。

当你引用一个工程时,会发生下面的事:

  • 导入引用工程中的模块实际加载的是它_输出_的声明文件(.d.ts)。
  • 如果引用的工程生成一个outFile,那么这个输出文件的.d.ts文件里的声明对于当前工程是可见的。
  • 构建模式(后文)会根据需要自动地构建引用的工程。

当你拆分成多个工程后,会显著地加速类型检查和编译,减少编辑器的内存占用,还会改善程序在逻辑上进行分组。

composite

引用的工程必须启用新的composite设置。 这个选项用于帮助TypeScript快速确定引用工程的输出文件位置。 若启用composite标记则会发生如下变动:

  • 对于rootDir设置,如果没有被显式指定,默认为包含tsconfig文件的目录
  • 所有的实现文件必须匹配到某个include模式或在files数组里列出。如果违反了这个限制,tsc会提示你哪些文件未指定。
  • 必须开启declaration选项。

declarationMaps

我们增加了对declaration source maps的支持。 如果启用--declarationMap,在某些编辑器上,你可以使用诸如“Go to Definition”,重命名以及跨工程编辑文件等编辑器特性。

prependoutFile

你可以在引用中使用prepend选项来启用前置某个依赖的输出:

   "references": [
       { "path": "../utils", "prepend": true }
   ]

前置工程会将工程的输出添加到当前工程的输出之前。 它对.js文件和.d.ts文件都有效,source map文件也同样会正确地生成。

tsc永远只会使用磁盘上已经存在的文件来进行这个操作,因此你可能会创建出一个无法生成正确输出文件的工程,因为有些工程的输出可能会在结果文件中重覆了多次。 例如:

   A
  ^ ^
 /   \
B     C
 ^   ^
  \ /
   D

这种情况下,不能前置引用,因为在D的最终输出里会有两份A存在 - 这可能会发生未知错误。

关于工程引用的说明

工程引用在某些方面需要你进行权衡.

因为有依赖的工程要使用它的依赖生成的.d.ts,因此你必须要检查相应构建后的输出_或_在下载源码后进行构建,然后才能在编辑器里自由地导航。 我们是在操控幕后的.d.ts生成过程,我们应该减少这种情况,但是目前还们建议提示开发者在下载源码后进行构建。

此外,为了兼容已有的构建流程,tsc_不会_自动地构建依赖项,除非启用了--build选项。 下面让我们看看--build

TypeScript构建模式

在TypeScript工程里支持增量构建是个期待已久的功能。 在TypeScrpt 3.0里,你可以在tsc上使用--build标记。 它实际上是个新的tsc入口点,它更像是一个构建的协调员而不是简简单单的编译器。

运行tsc --build(简写tsc -b)会执行如下操作:

  • 找到所有引用的工程
  • 检查它们是否为最新版本
  • 按顺序构建非最新版本的工程

可以给tsc -b指定多个配置文件地址(例如:tsc -b src test)。 如同tsc -p,如果配置文件名为tsconfig.json,那么文件名则可省略。

tsc -b命令行

你可以指令任意数量的配置文件:

 > tsc -b                                # Run the tsconfig.json in the current directory
 > tsc -b src                            # Run src/tsconfig.json
 > tsc -b foo/prd.tsconfig.json bar  # Run foo/prd.tsconfig.json and bar/tsconfig.json

不需要担心命令行上指定的文件顺序 - tsc会根据需要重新进行排序,被依赖的项会优先构建。

tsc -b还支持其它一些选项:

  • --verbose:打印详细的日志(可以与其它标记一起使用)
  • --dry: 显示将要执行的操作但是并不真正进行这些操作
  • --clean: 删除指定工程的输出(可以与--dry一起使用)
  • --force: 把所有工程当作非最新版本对待
  • --watch: 观察模式(可以与--verbose一起使用)

说明

一般情况下,就算代码里有语法或类型错误,tsc也会生成输出(.js.d.ts),除非你启用了noEmitOnError选项。 这在增量构建系统里就不好了 - 如果某个过期的依赖里有一个新的错误,那么你只能看到它_一次_,因为后续的构建会跳过这个最新的工程。 正是这个原因,tsc -b的作用就好比在所有工程上启用了noEmitOnError

如果你想要提交所有的构建输出(.js, .d.ts, .d.ts.map等),你可能需要运行--force来构建,因为一些源码版本管理操作依赖于源码版本管理工具保存的本地拷贝和远程拷贝的时间戳。

MSBuild

如果你的工程使用msbuild,你可以用下面的方式开启构建模式。

    <TypeScriptBuildMode>true</TypeScriptBuildMode>

将这段代码添加到proj文件。它会自动地启用增量构建模式和清理工作。

注意,在使用tsconfig.json / -p时,已存在的TypeScript工程属性会被忽略 - 因此所有的设置需要在tsconfig文件里进行。

一些团队已经设置好了基于msbuild的构建流程,并且tsconfig文件具有和它们匹配的工程一致的_隐式_图序。 若你的项目如此,那么可以继续使用msbuildtsc -p以及工程引用;它们是完全互通的。

指导

整体结构

tsconfig.json多了以后,通常会使用配置文件继承来集中管理公共的编译选项。 这样你就可以在一个文件里更改配置而不必在多个文件中进行修改。

另一个最佳实践是有一个solution级别的tsconfig.json文件,它仅仅用于引用所有的子工程。 它用于提供一个简单的入口;比如,在TypeScript源码里,我们可以简单地运行tsc -b src来构建所有的节点,因为我们在src/tsconfig.json文件里列出了所有的子工程。 注意从3.0开始,如果tsconfig.json文件里有至少一个工程引用reference,那么files数组为空的话也不会报错。

你可以在TypeScript源码仓库里看到这些模式 - 阅读src/tsconfig_base.jsonsrc/tsconfig.jsonsrc/tsc/tsconfig.json

相对模块的结构

通常地,将代码转成使用相对模块并不需要改动太多。 只需在某个给定父目录的每个子目录里放一个tsconfig.json文件,并相应添加reference。 然后将outDir指定为输出目录的子目录或将rootDir指定为所有工程的某个公共根目录。

outFile的结构

使用了outFile的编译输出结构十分灵活,因为相对路径是无关紧要的。 要注意的是,你通常不需要使用prepend - 因为这会改善构建时间并结省I/O。 TypeScript项目本身是一个好的参照 - 我们有一些“library”的工程和一些“endpoint”工程,“endpoint”工程会确保足够小并仅仅导入它们需要的“library”。

NPM 包的类型

编译选项

编译选项

选项类型默认值描述
--allowJsbooleanfalse允许编译javascript文件。
--allowSyntheticDefaultImportsbooleanmodule === "system"或设置了--esModuleInterop允许从没有设置默认导出的模块中默认导入。这并不影响代码的输出,仅为了类型检查。
--allowUnreachableCodebooleanfalse不报告执行不到的代码错误。
--allowUnusedLabelsbooleanfalse不报告未使用的标签错误。
--alwaysStrictbooleanfalse以严格模式解析并为每个源文件生成"use strict"语句
--baseUrlstring解析非相对模块名的基准目录。查看模块解析文档了解详情。
--build -bbooleanfalse使用Project References来构建此工程及其依赖工程。注意这个标记与本页内其它标记不兼容。详情参考这里
--charsetstring"utf8"输入文件的字符集。
--checkJsbooleanfalse在.js文件中报告错误。与--allowJs配合使用。
--compositebooleantrue确保TypeScript能够找到编译当前工程所需要的引用工程的输出位置。
--declaration -dbooleanfalse生成相应的.d.ts文件。
--declarationDirstring生成声明文件的输出路径。
--diagnosticsbooleanfalse显示诊断信息。
--disableSizeLimitbooleanfalse禁用JavaScript工程体积大小的限制
--emitBOMbooleanfalse在输出文件的开头加入BOM头(UTF-8 Byte Order Mark)。
--emitDecoratorMetadata[1]booleanfalse给源码里的装饰器声明加上设计类型元数据。查看issue #2577了解更多信息。
--experimentalDecorators[1]booleanfalse启用实验性的ES装饰器。
--extendedDiagnosticsbooleanfalse显示详细的诊段信息。
--forceConsistentCasingInFileNamesbooleanfalse禁止对同一个文件的不一致的引用。
--generateCpuProfilestringprofile.cpuprofile在指定目录生成CPU资源使用报告。若传入的是已创建的目录名,将在此目录下生成以时间戳命名的报告。
--help -h打印帮助信息。
--importHelpersstringtslib导入辅助工具函数(比如__extends__rest等)
--importsNotUsedAsValuesstringremove用于设置针对于类型导入的代码生成和代码检查的行为。"remove""preserve"设置了是否对未使用的导入了模块副作用的导入语句生成相关代码,"error"则强制要求只用作类型的模块导入必须使用import type语句。
--inlineSourceMapbooleanfalse生成单个sourcemaps文件,而不是将每sourcemaps生成不同的文件。
--inlineSourcesbooleanfalse将代码与sourcemaps生成到一个文件中,要求同时设置了--inlineSourceMap--sourceMap属性。
--init初始化TypeScript项目并创建一个tsconfig.json文件。
--isolatedModulesbooleanfalse执行额外检查以确保单独编译(如transpileModule@babel/plugin-transform-typescript)是安全的。
--jsxstring"preserve".tsx文件里支持JSX:"react""preserve""react-native"。查看JSX
--jsxFactorystring"React.createElement"指定生成目标为react JSX时,使用的JSX工厂函数,比如React.createElementh
--libstring[]编译过程中需要引入的库文件的列表。 可能的值为: ► ES5ES6ES2015ES7ES2016ES2017ES2018ESNextDOMDOM.IterableWebWorkerScriptHostES2015.CoreES2015.CollectionES2015.GeneratorES2015.IterableES2015.PromiseES2015.ProxyES2015.ReflectES2015.SymbolES2015.Symbol.WellKnownES2016.Array.IncludeES2017.objectES2017.IntlES2017.SharedMemoryES2017.StringES2017.TypedArraysES2018.IntlES2018.PromiseES2018.RegExpESNext.AsyncIterableESNext.ArrayESNext.IntlESNext.Symbol 注意:如果--lib没有指定默认注入的库的列表。默认注入的库为: ► 针对于--target ES5DOM,ES5,ScriptHost ► 针对于--target ES6DOM,ES6,DOM.Iterable,ScriptHost
--listEmittedFilesbooleanfalse打印出编译后生成文件的名字。
--listFilesbooleanfalse编译过程中打印文件名。
--localestring(platform specific)显示错误信息时使用的语言,比如:en-us。
--mapRootstring为调试器指定指定sourcemap文件的路径,而不是使用生成时的路径。当.map文件是在运行时指定的,并不同于js文件的地址时使用这个标记。指定的路径会嵌入到sourceMap里告诉调试器到哪里去找它们。使用此标识并不会新创建指定目录并生成map文件在指定路径下。而是增加一个构建后的步骤,把相应文件移动到指定路径下。
--maxNodeModuleJsDepthnumber0node_modules依赖的最大搜索深度并加载JavaScript文件。仅适用于--allowJs
--module -mstringtarget === "ES6" ? "ES6" : "commonjs"指定生成哪个模块系统代码:"None""CommonJS""AMD""System""UMD""ES6""ES2015"。 ► 只有"AMD""System"能和--outFile一起使用。 ►"ES6""ES2015"可使用在目标输出为"ES5"或更低的情况下。
--moduleResolutionstringmodule === "AMD" or "System" or "ES6" ? "Classic" : "Node"决定如何处理模块。或者是"Node"对于Node.js/io.js,或者是"Classic"(默认)。查看模块解析了解详情。
--newLinestring(platform specific)当生成文件时指定行结束符:"crlf"(windows)或"lf"(unix)。
--noEmitbooleanfalse不生成输出文件。
--noEmitHelpersbooleanfalse不在输出文件中生成用户自定义的帮助函数代码,如__extends
--noEmitOnErrorbooleanfalse报错时不生成输出文件。
--noErrorTruncationbooleanfalse不截短错误消息。
--noFallthroughCasesInSwitchbooleanfalse报告switch语句的fallthrough错误。(即,不允许switch的case语句贯穿)
--noImplicitAnybooleanfalse在表达式和声明上有隐含的any类型时报错。
--noImplicitReturnsbooleanfalse不是函数的所有返回路径都有返回值时报错。
--noImplicitThisbooleanfalsethis表达式的值为any类型的时候,生成一个错误。
--noImplicitUseStrictbooleanfalse模块输出中不包含"use strict"指令。
--noLibbooleanfalse不包含默认的库文件(lib.d.ts)。
--noResolvebooleanfalse不把/// <reference``>或模块导入的文件加到编译文件列表。
--noStrictGenericChecksbooleanfalse禁用在函数类型里对泛型签名进行严格检查。
--noUnusedLocalsbooleanfalse若有未使用的局部变量则抛错。
--noUnusedParametersbooleanfalse若有未使用的参数则抛错。
--outstring弃用。使用 --outFile 代替。
--outDirstring重定向输出目录。
--outFilestring将输出文件合并为一个文件。合并的顺序是根据传入编译器的文件顺序和///<reference``>import的文件顺序决定的。查看输出文件顺序文档了解详情
paths[2]Object模块名到基于baseUrl的路径映射的列表。查看模块解析文档了解详情。
--preserveConstEnumsbooleanfalse保留constenum声明。查看const enums documentation了解详情。
--preserveSymlinksbooleanfalse不把符号链接解析为其真实路径;将符号链接文件视为真正的文件。
--preserveWatchOutputbooleanfalse保留watch模式下过时的控制台输出。
--pretty[1]booleanfalse给错误和消息设置样式,使用颜色和上下文。
--project -pstring编译指定目录下的项目。这个目录应该包含一个tsconfig.json文件来管理编译。查看tsconfig.json文档了解更多信息。
--reactNamespacestring"React"当目标为生成"react" JSX时,指定createElement__spread的调用对象
--removeCommentsbooleanfalse删除所有注释,除了以/!*开头的版权信息。
--rootDirstring(common root directory is computed from the list of input files)仅用来控制输出的目录结构--outDir
rootDirs[2]string[]根(root)文件夹列表,表示运行时组合工程结构的内容。查看模块解析文档了解详情。
--showConfigbooleanfalse不真正执行build,而是显示build使用的配置文件信息。
--skipDefaultLibCheckbooleanfalse忽略库的默认声明文件的类型检查。
--skipLibCheckbooleanfalse忽略所有的声明文件(*.d.ts)的类型检查。
--sourceMapbooleanfalse生成相应的.map文件。
--sourceRootstring指定TypeScript源文件的路径,以便调试器定位。当TypeScript文件的位置是在运行时指定时使用此标记。路径信息会被加到sourceMap里。
--strictbooleanfalse启用所有严格检查选项。 包含--noImplicitAny, --noImplicitThis, --alwaysStrict, --strictBindCallApply, --strictNullChecks, --strictFunctionTypes--strictPropertyInitialization.
--strictFunctionTypesbooleanfalse禁用函数参数双向协变检查。
--strictPropertyInitializationbooleanfalse确保类的非undefined属性已经在构造函数里初始化。若要令此选项生效,需要同时启用--strictNullChecks
--strictNullChecksbooleanfalse在严格的null检查模式下,nullundefined值不包含在任何类型里,只允许用它们自己和any来赋值(有个例外,undefined可以赋值到void)。
--suppressExcessPropertyErrors[1]booleanfalse阻止对对象字面量的额外属性检查。
--suppressImplicitAnyIndexErrorsbooleanfalse阻止--noImplicitAny对缺少索引签名的索引对象报错。查看issue #1232了解详情。
--target -tstring"ES3"指定ECMAScript目标版本"ES3"(默认),"ES5""ES6"/"ES2015""ES2016""ES2017""ES2018""ES2019""ES2020""ESNext"。 注意:"ESNext"最新的生成目标列表为ES proposed features
--traceResolutionbooleanfalse生成模块解析日志信息
--typesstring[]要包含的类型声明文件名列表。查看@types,--typeRoots和--types章节了解详细信息。
--typeRootsstring[]要包含的类型声明文件路径列表。查看@types,--typeRoots和--types章节了解详细信息。
--version -v打印编译器版本号。
--watch -w在监视模式下运行编译器。会监视输出文件,在它们改变时重新编译。监视文件和目录的具体实现可以通过环境变量进行配置。详情请看配置 Watch
  • [1] 这些选项是试验性的。
  • [2] 这些选项只能在tsconfig.json里使用,不能在命令行使用。

相关信息

配置 Watch

编译器支持使用环境变量配置如何监视文件和目录的变化。

使用TSC_WATCHFILE环境变量来配置文件监视

选项描述
PriorityPollingInterval使用fs.watchFile但针对源码文件,配置文件和消失的文件使用不同的轮询间隔
DynamicPriorityPolling使用动态队列,对经常被修改的文件使用较短的轮询间隔,对未修改的文件使用较长的轮询间隔
UseFsEvents使用 fs.watch,它使用文件系统事件(但在不同的系统上可能不一定准确)来查询文件的修改/创建/删除。注意少数的系统如Linux,对监视者的数量有限制,如果使用fs.watch创建监视失败那么将通过fs.watchFile来创建监视
UseFsEventsWithFallbackDynamicPolling此选项与UseFsEvents类似,只不过当使用fs.watch创建监视失败后,回退到使用动态轮询队列进行监视(如DynamicPriorityPolling介绍的那样)
UseFsEventsOnParentDirectory此选项通过fs.watch(使用系统文件事件)监视文件的父目录,因此CPU占用率低但也会降低精度
默认 (无指定值)如果环境变量TSC_NONPOLLING_WATCHER设置为true,监视文件的父目录(如同UseFsEventsOnParentDirectory)。否则,使用fs.watchFile监视文件,超时时间为250ms

使用TSC_WATCHDIRECTORY环境变量来配置目录监视

在那些Nodejs原生就不支持递归监视目录的平台上,我们会根据TSC_WATCHDIRECTORY的不同选项递归地创建对子目录的监视。 注意在那些原生就支持递归监视目录的平台上(如Windows),这个环境变量会被忽略。

选项描述
RecursiveDirectoryUsingFsWatchFile使用fs.watchFile监视目录和子目录,它是一个轮询监视(消耗CPU周期)
RecursiveDirectoryUsingDynamicPriorityPolling使用动态轮询队列来获取目录与其子目录的改变
默认 (无指定值)使用fs.watch来监视目录及其子目录

背景

在编译器中--watch的实现依赖于Nodejs提供的fs.watchfs.watchFile,两者各有优缺点。

fs.watch使用文件系统事件通知文件及目录的变化。 但是它依赖于操作系统,且事件通知并不完全可靠,在很多操作系统上的行为难以预料。 还可能会有创建监视个数的限制,如Linux系统,在包含大量文件的程序中监视器个数很快被耗尽。 但也正是因为它使用文件系统事件,不需要占用过多的CPU周期。 典型地,编译器使用fs.watch来监视目录(比如配置文件里声明的源码目录,无法进行模块解析的目录)。 这样就可以处理改动通知不准确的问题。 但递归地监视仅在Windows和OSX系统上支持。 这就意味着在其它系统上要使用替代方案。

fs.watchFile使用轮询,因此涉及到CPU周期。 但是这是最可靠的获取文件/目录状态的机制。 典型地,编译器使用fs.watchFile监视源文件,配置文件和消失的文件(失去文件引用),这意味着对CPU的使用依赖于程序里文件的数量。

在MSBuild里使用编译选项

概述

编译选项可以在使用MSBuild的项目里通过MSBuild属性指定。

例子

  <PropertyGroup Condition="'$(Configuration)' == 'Debug'">
    <TypeScriptRemoveComments>false</TypeScriptRemoveComments>
    <TypeScriptSourceMap>true</TypeScriptSourceMap>
  </PropertyGroup>
  <PropertyGroup Condition="'$(Configuration)' == 'Release'">
    <TypeScriptRemoveComments>true</TypeScriptRemoveComments>
    <TypeScriptSourceMap>false</TypeScriptSourceMap>
  </PropertyGroup>
  <Import
      Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\TypeScript\Microsoft.TypeScript.targets"
      Condition="Exists('$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\TypeScript\Microsoft.TypeScript.targets')" />

映射

编译选项MSBuild属性名称可用值
--allowJsMSBuild不支持此选项
--allowSyntheticDefaultImportsTypeScriptAllowSyntheticDefaultImports布尔值
--allowUnreachableCodeTypeScriptAllowUnreachableCode布尔值
--allowUnusedLabelsTypeScriptAllowUnusedLabels布尔值
--alwaysStrictTypeScriptAlwaysStrict布尔值
--baseUrlTypeScriptBaseUrl文件路径
--charsetTypeScriptCharset
--declarationTypeScriptGeneratesDeclarations布尔值
--declarationDirTypeScriptDeclarationDir文件路径
--diagnosticsMSBuild不支持此选项
--disableSizeLimitMSBuild不支持此选项
--emitBOMTypeScriptEmitBOM布尔值
--emitDecoratorMetadataTypeScriptEmitDecoratorMetadata布尔值
--experimentalAsyncFunctionsTypeScriptExperimentalAsyncFunctions布尔值
--experimentalDecoratorsTypeScriptExperimentalDecorators布尔值
--forceConsistentCasingInFileNamesTypeScriptForceConsistentCasingInFileNames布尔值
--helpMSBuild不支持此选项
--importHelpersTypeScriptImportHelpers布尔值
--inlineSourceMapTypeScriptInlineSourceMap布尔值
--inlineSourcesTypeScriptInlineSources布尔值
--initMSBuild不支持此选项
--isolatedModulesTypeScriptIsolatedModules布尔值
--jsxTypeScriptJSXEmitreactreact-nativepreserve
--jsxFactoryTypeScriptJSXFactory有效的名字
--libTypeScriptLib逗号分隔的字符串列表
--listEmittedFilesMSBuild不支持此选项
--listFilesMSBuild不支持此选项
--localeautomatic自动设置为PreferredUILang值
--mapRootTypeScriptMapRoot文件路径
--maxNodeModuleJsDepthMSBuild不支持此选项
--moduleTypeScriptModuleKindAMDCommonJsUMDSystemES6
--moduleResolutionTypeScriptModuleResolutionClassicNode
--newLineTypeScriptNewLineCRLFLF
--noEmitMSBuild不支持此选项
--noEmitHelpersTypeScriptNoEmitHelpers布尔值
--noEmitOnErrorTypeScriptNoEmitOnError布尔值
--noFallthroughCasesInSwitchTypeScriptNoFallthroughCasesInSwitch布尔值
--noImplicitAnyTypeScriptNoImplicitAny布尔值
--noImplicitReturnsTypeScriptNoImplicitReturns布尔值
--noImplicitThisTypeScriptNoImplicitThis布尔值
--noImplicitUseStrictTypeScriptNoImplicitUseStrict布尔值
--noStrictGenericChecksTypeScriptNoStrictGenericChecks布尔值
--noUnusedLocalsTypeScriptNoUnusedLocals布尔值
--noUnusedParametersTypeScriptNoUnusedParameters布尔值
--noLibTypeScriptNoLib布尔值
--noResolveTypeScriptNoResolve布尔值
--outTypeScriptOutFile文件路径
--outDirTypeScriptOutDir文件路径
--outFileTypeScriptOutFile文件路径
--pathsMSBuild不支持此选项
--preserveConstEnumsTypeScriptPreserveConstEnums布尔值
--preserveSymlinksTypeScriptPreserveSymlinks布尔值
--listEmittedFilesMSBuild不支持此选项
--prettyMSBuild不支持此选项
--reactNamespaceTypeScriptReactNamespace字符串
--removeCommentsTypeScriptRemoveComments布尔值
--rootDirTypeScriptRootDir文件路径
--rootDirsMSBuild不支持此选项
--skipLibCheckTypeScriptSkipLibCheck布尔值
--skipDefaultLibCheckTypeScriptSkipDefaultLibCheck布尔值
--sourceMapTypeScriptSourceMap文件路径
--sourceRootTypeScriptSourceRoot文件路径
--strictTypeScriptStrict布尔值
--strictFunctionTypesTypeScriptStrictFunctionTypes布尔值
--strictNullChecksTypeScriptStrictNullChecks布尔值
--stripInternalTypeScriptStripInternal布尔值
--suppressExcessPropertyErrorsTypeScriptSuppressExcessPropertyErrors布尔值
--suppressImplicitAnyIndexErrorsTypeScriptSuppressImplicitAnyIndexErrors布尔值
--targetTypeScriptTargetES3ES5,或ES6
--traceResolutionMSBuild不支持此选项
--typesMSBuild不支持此选项
--typeRootsMSBuild不支持此选项
--watchMSBuild不支持此选项
MSBuild only optionTypeScriptAdditionalFlags任何编译选项

我使用的Visual Studio版本里支持哪些选项?

查找 C:\Program Files (x86)\MSBuild\Microsoft\VisualStudio\v$(VisualStudioVersion)\TypeScript\Microsoft.TypeScript.targets 文件。 可用的MSBuild XML标签与相应的tsc编译选项的映射都在那里。

ToolsVersion

工程文件里的<TypeScriptToolsVersion>1.7</TypeScriptToolsVersion>属性值表明了构建时使用的编译器的版本号(这个例子里是1.7) 这样就允许一个工程在不同的机器上使用相同版本的编译器进行构建。

如果没有指定TypeScriptToolsVersion,则会使用机器上安装的最新版本的编译器去构建。

如果用户使用的是更新版本的TypeScript,则会在首次加载工程的时候看到一个提示升级工程的对话框。

TypeScriptCompileBlocked

如果你使用其它的构建工具(比如,gulp, grunt等等)并且使用VS做为开发和调试工具,那么在工程里设置<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>。 这样VS只会提供给你编辑的功能,而不会在你按F5的时候去构建。

与其它构建工具整合

构建工具

Babel

安装

npm install @babel/cli @babel/core @babel/preset-typescript --save-dev

.babelrc

{
  "presets": ["@babel/preset-typescript"]
}

使用命令行工具

./node_modules/.bin/babel --out-file bundle.js src/index.ts

package.json

{
  "scripts": {
    "build": "babel --out-file bundle.js main.ts"
  },
}

在命令行上运行Babel

npm run build

Browserify

安装

npm install tsify

使用命令行交互

browserify main.ts -p [ tsify --noImplicitAny ] > bundle.js

使用API

var browserify = require("browserify");
var tsify = require("tsify");

browserify()
    .add('main.ts')
    .plugin('tsify', { noImplicitAny: true })
    .bundle()
    .pipe(process.stdout);

更多详细信息:smrq/tsify

Duo

安装

npm install duo-typescript

使用命令行交互

duo --use duo-typescript entry.ts

使用API

var Duo = require('duo');
var fs = require('fs')
var path = require('path')
var typescript = require('duo-typescript');

var out = path.join(__dirname, "output.js")

Duo(__dirname)
    .entry('entry.ts')
    .use(typescript())
    .run(function (err, results) {
        if (err) throw err;
        // Write compiled result to output file
        fs.writeFileSync(out, results.code);
    });

更多详细信息:frankwallis/duo-typescript

Grunt

安装

npm install grunt-ts

基本Gruntfile.js

module.exports = function(grunt) {
    grunt.initConfig({
        ts: {
            default : {
                src: ["**/*.ts", "!node_modules/**/*.ts"]
            }
        }
    });
    grunt.loadNpmTasks("grunt-ts");
    grunt.registerTask("default", ["ts"]);
};

更多详细信息:TypeStrong/grunt-ts

Gulp

安装

npm install gulp-typescript

基本gulpfile.js

var gulp = require("gulp");
var ts = require("gulp-typescript");

gulp.task("default", function () {
    var tsResult = gulp.src("src/*.ts")
        .pipe(ts({
              noImplicitAny: true,
              out: "output.js"
        }));
    return tsResult.js.pipe(gulp.dest('built/local'));
});

更多详细信息:ivogabe/gulp-typescript

Jspm

安装

npm install -g jspm@beta

注意:目前jspm的0.16beta版本支持TypeScript

更多详细信息:TypeScriptSamples/jspm

Webpack

安装

npm install ts-loader --save-dev

Webpack 2 webpack.config.js 基础配置

module.exports = {
    entry: "./src/index.tsx",
    output: {
        path: '/',
        filename: "bundle.js"
    },
    resolve: {
        extensions: [".tsx", ".ts", ".js", ".json"]
    },
    module: {
        rules: [
            // all files with a '.ts' or '.tsx' extension will be handled by 'ts-loader'
            { test: /\.tsx?$/, use: ["ts-loader"], exclude: /node_modules/ }
        ]
    }
}

Webpack 1 webpack.config.js 基础配置

module.exports = {
    entry: "./src/index.tsx",
    output: {
        filename: "bundle.js"
    },
    resolve: {
        // Add '.ts' and '.tsx' as a resolvable extension.
        extensions: ["", ".webpack.js", ".web.js", ".ts", ".tsx", ".js"]
    },
    module: {
        loaders: [
            // all files with a '.ts' or '.tsx' extension will be handled by 'ts-loader'
            { test: /\.tsx?$/, loader: "ts-loader" }
        ]
    }
};

查看更多关于ts-loader的详细信息

或者

MSBuild

更新工程文件,包含本地安装的Microsoft.TypeScript.Default.props(在顶端)和Microsoft.TypeScript.targets(在底部)文件:

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <!-- Include default props at the top -->
  <Import
      Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\TypeScript\Microsoft.TypeScript.Default.props"
      Condition="Exists('$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\TypeScript\Microsoft.TypeScript.Default.props')" />

  <!-- TypeScript configurations go here -->
  <PropertyGroup Condition="'$(Configuration)' == 'Debug'">
    <TypeScriptRemoveComments>false</TypeScriptRemoveComments>
    <TypeScriptSourceMap>true</TypeScriptSourceMap>
  </PropertyGroup>
  <PropertyGroup Condition="'$(Configuration)' == 'Release'">
    <TypeScriptRemoveComments>true</TypeScriptRemoveComments>
    <TypeScriptSourceMap>false</TypeScriptSourceMap>
  </PropertyGroup>

  <!-- Include default targets at the bottom -->
  <Import
      Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\TypeScript\Microsoft.TypeScript.targets"
      Condition="Exists('$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\TypeScript\Microsoft.TypeScript.targets')" />
</Project>

关于配置MSBuild编译器选项的更多详细信息,请参考:在MSBuild里使用编译选项

NuGet

  • 右键点击 -> Manage NuGet Packages
  • 查找Microsoft.TypeScript.MSBuild
  • 点击Install
  • 安装完成后,Rebuild。

更多详细信息请参考Package Manager Dialogusing nightly builds with NuGet

在太平洋标准时间的每日午夜,TypeScript 代码仓库中master 分支上的代码会自动构建并发布到 npm 上。 下面将介绍如何获取并结合你的工具来使用它。

使用 npm

npm install -g typescript@next

更新 IDE 来使用每日构建

你还可以配置 IDE 来使用每日构建。 首先你需要通过 npm 来安装代码包。 你可以进行全局安装或者安装到本地的node_modules目录下。

在下面的内容中,我们假设你已经安装好了typescript@next

Visual Studio Code

参考以下示例来更新.vscode/settings.json

"typescript.tsdk": "<path to your folder>/node_modules/typescript/lib"

更多详情请参考 VSCode 文档

Sublime Text

参考以下示例来更新Settings - User

"typescript_tsdk": "<path to your folder>/node_modules/typescript/lib"

更多详情请参考 如何在 Sublime Text 里安装 TypeScript 插件

Visual Studio 2013 和 2015

注意:绝大多数的变更不需要你安装新版本的 VS TypeScript 插件。

目前,每日构建中没有包含完整的插件安装包,但是我们正在试着提供这样的安装包。

  1. 下载 VSDevMode.ps1 脚本。

    同时也可以参考 wiki 文档: 使用自定义的语言服务文件

  2. 打开 PowerShell 命令行窗口,并运行:

针对 VS 2015:

VSDevMode.ps1 14 -tsScript <path to your folder>/node_modules/typescript/lib

针对 VS 2013:

VSDevMode.ps1 12 -tsScript <path to your folder>/node_modules/typescript/lib

IntelliJ IDEA (Mac)

前往 Preferences > Languages & Frameworks > TypeScript

TypeScript Version:若通过 npm 安装则为:/usr/local/lib/node_modules/typescript/lib

IntelliJ IDEA (Windows)

前往 File > Settings > Languages & Frameworks > TypeScript

TypeScript Version:若通过 npm 安装则为:C:\Users\USERNAME\AppData\Roaming\npm\node_modules\typescript\lib

新增功能

TypeScript 5.7

检查未初始化的变量

长期以来,TypeScript 已经能够在所有先前的分支中捕获变量未初始化的问题。

let result: number;
if (someCondition()) {
  result = doSomeWork();
} else {
  let temporaryWork = doSomeWork();
  temporaryWork *= 2;
  // 忘记赋值给 'result'
}

console.log(result); // 错误:变量 'result' 在使用前未赋值。

不幸的是,在某些情况下这种分析不起作用。 例如,如果变量在单独的函数中访问,类型系统不知道函数何时被调用,而是采取“乐观”的观点,认为变量将被初始化。

function foo() {
  let result: number;
  if (someCondition()) {
    result = doSomeWork();
  } else {
    let temporaryWork = doSomeWork();
    temporaryWork *= 2;
    // 忘记赋值给 'result'
  }

  printResult();

  function printResult() {
    console.log(result); // 这里没有错误。
  }
}

虽然 TypeScript 5.7 仍然对可能已初始化的变量持宽松态度,但当变量从未初始化时,类型系统能够报告错误。

function foo() {
  let result: number;

  // 做了一些工作,但忘记赋值给 'result'

  function printResult() {
    console.log(result); // 错误:变量 'result' 在使用前未赋值。
  }
}

这一变化得益于 GitHub 用户 Zzzen 的贡献!

相对路径的路径重写

有一些工具和运行时允许你“就地”运行 TypeScript 代码,这意味着它们不需要生成输出 JavaScript 文件的构建步骤。 例如,ts-nodetsx、Deno 和 Bun 都支持直接运行 .ts 文件。 最近,Node.js 也在研究通过 --experimental-strip-types(即将 unflagged!)和 --experimental-transform-types 来支持这种功能。 这非常方便,因为它允许我们更快地迭代,而不用担心重新运行构建任务。

不过,在使用这些模式时需要注意一些复杂性。 为了与所有这些工具最大限度地兼容,在运行时导入“就地”运行的 TypeScript 文件时必须使用适当的 TypeScript 扩展名。 例如,要导入名为 foo.ts 的文件,我们必须在 Node 的新实验性支持中编写以下内容:

// main.ts

import * as foo from './foo.ts'; // <- 这里需要 foo.ts,而不是 foo.js

通常,TypeScript 会在此情况下发出错误,因为它期望我们导入输出文件。 由于某些工具确实允许 .ts 导入,TypeScript 已经支持这种导入风格,并通过一个名为 --allowImportingTsExtensions 的选项支持了一段时间。 这工作得很好,但如果我们需要从这些 .ts 文件生成 .js 文件会发生什么? 这是库作者的要求,他们需要能够仅分发 .js 文件,但到目前为止,TypeScript 一直避免重写任何路径。

为了支持这种场景,我们添加了一个新的编译器选项 --rewriteRelativeImportExtensions。 当导入路径是相对的(以 ./../ 开头),以 TypeScript 扩展名(.ts.tsx.mts.cts)结尾,并且是非声明文件时,编译器会将路径重写为相应的 JavaScript 扩展名(.js.jsx.mjs.cjs)。

// 在 --rewriteRelativeImportExtensions 下...

// 这些将被重写。
import * as foo from './foo.ts';
import * as bar from '../someFolder/bar.mts';

// 这些不会以任何方式被重写。
import * as a from './foo';
import * as b from 'some-package/file.ts';
import * as c from '@some-scope/some-package/file.ts';
import * as d from '#/file.ts';
import * as e from './file.js';

这使我们能够编写可以就地运行的 TypeScript 代码,然后在准备好时将其编译为 JavaScript。

现在,我们注意到 TypeScript 通常避免重写路径。 这有几个原因,但最明显的一个是动态导入。 如果开发人员编写了以下内容,处理 import 接收的路径并不容易。 事实上,不可能覆盖任何依赖项中 import 的行为。

function getPath() {
  if (Math.random() < 0.5) {
    return './foo.ts';
  } else {
    return './foo.js';
  }
}

let myImport = await import(getPath());

另一个问题是(如上所述)只有相对路径会被重写,并且它们是被“天真地”重写。 这意味着任何依赖于 TypeScript 的 baseUrlpaths 的路径都不会被重写:

// tsconfig.json

{
    "compilerOptions": {
        "module": "nodenext",
        // ...
        "paths": {
            "@/*": ["./src/*"]
        }
    }
}
// 不会被转换,不会工作。
import * as utilities from '@/utilities.ts';

任何可能通过 package.jsonexportsimports 字段解析的路径也不会被重写。

// package.json
{
    "name": "my-package",
    "imports": {
        "#root/*": "./dist/*"
    }
}
// 不会被转换,不会工作。
import * as utilities from '#root/utilities.ts';

因此,如果你一直在使用多包相互引用的工作区风格布局,你可能需要使用带有作用域自定义条件条件导出

// my-package/package.json

{
    "name": "my-package",
    "exports": {
        ".": {
            "@my-package/development": "./src/index.ts",
            "import": "./lib/index.js"
        },
        "./*": {
            "@my-package/development": "./src/*.ts",
            "import": "./lib/*.js"
        }
    }
}

任何时候你想导入 .ts 文件,你可以使用 node --conditions=@my-package/development 运行它。

注意我们为条件 @my-package/development 使用的“命名空间”或“作用域”。 这是一个临时的解决方案,以避免依赖项可能也使用 development 条件时的冲突。 如果每个人都在他们的包中提供了 development,那么解析可能会尝试解析到 .ts 文件,而这不一定有效。 这个想法类似于 Colin McDonnell 的文章《TypeScript 单体仓库中的实时类型》中描述的内容,以及 tshy 的从源代码加载的指南

有关此功能如何工作的更多详细信息,请阅读此处的更改

支持 --target es2024--lib es2024

TypeScript 5.7 现在支持 --target es2024,允许用户以 ECMAScript 2024 运行时为目标。 此目标主要启用了新的 --lib es2024,其中包含许多 SharedArrayBufferArrayBufferObject.groupByMap.groupByPromise.withResolvers 等功能。 它还将 Atomics.waitAsync--lib es2022 移动到 --lib es2024

请注意,作为 SharedArrayBufferArrayBuffer 更改的一部分,两者现在有些分歧。 为了弥合差距并保留底层缓冲区类型,所有 TypedArray(如 Uint8Array 等)现在也是泛型的

interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike> {
  // ...
}

每个 TypedArray 现在都包含一个名为 TArrayBuffer 的类型参数,尽管该类型参数有一个默认的类型参数,因此我们可以继续引用 Int32Array 而无需显式写出 Int32Array<ArrayBufferLike>

如果你在此更新过程中遇到任何问题,你可能需要更新 @types/node

这项工作主要由 Kenta Moriuchi 提供!

在编辑器中搜索祖先配置文件以确定项目所有权

当使用 TSServer(如 Visual Studio 或 VS Code)在编辑器中加载 TypeScript 文件时,编辑器会尝试找到“拥有”该文件的相关 tsconfig.json 文件。 为此,它会从正在编辑的文件向上遍历目录树,查找任何名为 tsconfig.json 的文件。

以前,此搜索会在找到第一个 tsconfig.json 文件时停止; 然而,想象一下如下的项目结构:

project/
├── src/
│   ├── foo.ts
│   ├── foo-test.ts
│   ├── tsconfig.json
│   └── tsconfig.test.json
└── tsconfig.json

这里的想法是 src/tsconfig.json 是项目的“主”配置文件,而 src/tsconfig.test.json 是用于运行测试的配置文件。

// src/tsconfig.json
{
  "compilerOptions": {
    "outDir": "../dist"
  },
  "exclude": ["**/*.test.ts"]
}
// src/tsconfig.test.json
{
  "compilerOptions": {
    "outDir": "../dist/test"
  },
  "include": ["**/*.test.ts"],
  "references": [{ "path": "./tsconfig.json" }]
}
// tsconfig.json
{
  // 这是一个“工作区风格”或“解决方案风格”的 tsconfig。
  // 它不指定任何文件,而是引用所有实际项目。
  "files": [],
  "references": [
    { "path": "./src/tsconfig.json" },
    { "path": "./src/tsconfig.test.json" }
  ]
}

这里的问题是,当编辑 foo-test.ts 时,编辑器会找到 project/src/tsconfig.json 作为“拥有”配置文件——但这并不是我们想要的! 如果遍历在此停止,这可能不是我们想要的。 以前避免这种情况的唯一方法是将 src/tsconfig.json 重命名为 src/tsconfig.src.json,然后所有文件都会命中引用每个可能项目的顶级 tsconfig.json

project/
├── src/
│   ├── foo.ts
│   ├── foo-test.ts
│   ├── tsconfig.src.json
│   └── tsconfig.test.json
└── tsconfig.json

为了避免强迫开发人员这样做,TypeScript 5.7 现在继续向上遍历目录树,以找到其他合适的 tsconfig.json 文件用于编辑器场景。这可以为项目的组织方式和配置文件的结构提供更多的灵活性。

你可以在 GitHub 上获取有关实现的更多详细信息 这里这里

编辑器中复合项目的更快项目所有权检查

想象一下具有以下结构的大型代码库:

packages
├── graphics/
│   ├── tsconfig.json
│   └── src/
│       └── ...
├── sound/
│   ├── tsconfig.json
│   └── src/
│       └── ...
├── networking/
│   ├── tsconfig.json
│   └── src/
│       └── ...
├── input/
│   ├── tsconfig.json
│   └── src/
│       └── ...
└── app/
    ├── tsconfig.json
    ├── some-script.js
    └── src/
        └── ...

packages 中的每个目录都是一个单独的 TypeScript 项目,而 app 目录是依赖于所有其他项目的主项目。

// app/tsconfig.json
{
  "compilerOptions": {
    // ...
  },
  "include": ["src"],
  "references": [
    { "path": "../graphics/tsconfig.json" },
    { "path": "../sound/tsconfig.json" },
    { "path": "../networking/tsconfig.json" },
    { "path": "../input/tsconfig.json" }
  ]
}

现在注意到我们在 app 目录中有文件 some-script.js。当我们在编辑器中打开 some-script.js 时,TypeScript 语言服务(它也处理 JavaScript 文件的编辑器体验!)必须确定该文件属于哪个项目,以便应用正确的设置。

在这种情况下,最近的 tsconfig.json 不包括 some-script.js,但 TypeScript 会继续询问“app/tsconfig.json 引用的项目之一是否可能包括 some-script.js?”。为此,TypeScript 之前会逐个加载每个项目,并在找到包含 some-script.js 的项目时停止。即使 some-script.js 不包括在根文件集中,TypeScript 仍然会解析项目中的所有文件,因为某些根文件仍然可以间接引用 some-script.js

随着时间的推移,我们发现这种行为在较大的代码库中导致了极端且不可预测的行为。开发人员会打开杂散的脚本文件,并发现自己等待整个代码库被打开。

幸运的是,每个可以被另一个(非工作区)项目引用的项目都必须启用一个名为 composite 的标志,该标志强制执行一条规则,即所有输入源文件必须事先已知。因此,在探测复合项目时,TypeScript 5.7 只会检查文件是否属于该项目的根文件集。这应该可以避免这种常见的最坏情况行为。

有关更多信息,请参阅此处的更改

--module nodenext 中验证 JSON 导入

--module nodenext 下从 .json 文件导入时,TypeScript 现在将强制执行某些规则以防止运行时错误。

首先,任何 JSON 文件导入都需要包含 type: "json" 的导入属性。

import myConfig from "./myConfig.json";
//                   ~~~~~~~~~~~~~~~~~
// ❌ 错误:当 'module' 设置为 'NodeNext' 时,将 JSON 文件导入 ECMAScript 模块需要 'type: "json"' 导入属性。

import myConfig from "./myConfig.json" with { type: "json" };
//                                          ^^^^^^^^^^^^^^^^
// ✅ 这是可以的,因为我们提供了 `type: "json"`

除此之外,TypeScript 不会生成“命名”导出,并且 JSON 导入的内容只能通过默认导出访问。

// ✅ 这是可以的:
import myConfigA from "./myConfig.json" with { type: "json" };
let version = myConfigA.version;

///////////

import * as myConfigB from "./myConfig.json" with { type: "json" };

// ❌ 这是不可以的:
let version = myConfig.version;

// ✅ 这是可以的:
let version = myConfig.default.version;

有关此更改的更多信息,请参阅此处

支持 Node.js 中的 V8 编译缓存

Node.js 22 支持一个新的 API,称为 module.enableCompileCache()。此 API 允许运行时在工具的第一次运行后重用一些解析和编译工作。

TypeScript 5.7 现在利用此 API,以便它可以更快地开始做有用的工作。在我们的一些测试中,我们见证了运行 tsc --version 的速度提高了约 2.5 倍。

基准测试 1:node ./built/local/_tsc.js --version(无缓存)
  时间(平均值 ± 标准差):122.2 ms ± 1.5 ms [用户:101.7 ms,系统:13.0 ms]
  范围(最小 … 最大):119.3 ms … 132.3 ms,200 次运行

基准测试 2:node ./built/local/tsc.js --version(有缓存)
  时间(平均值 ± 标准差):48.4 ms ± 1.0 ms [用户:34.0 ms,系统:11.1 ms]
  范围(最小 … 最大):45.7 ms … 52.8 ms,200 次运行

总结
  node ./built/local/tsc.js --version 运行速度比 node ./built/local/_tsc.js --version 快 2.52 ± 0.06 倍

有关更多信息,请参阅此处的 Pull Request

重要的行为变化

本节概述了一些需要注意的重要变化,作为升级的一部分,应该理解并加以确认。有时,它会突出弃用、移除以及新的限制条件。它也可能包含功能性改进的 Bug 修复,但这些改进也可能通过引入新的错误影响现有构建。

lib.d.ts

为 DOM 生成的类型可能会影响代码库的类型检查。有关更多信息,请查看与 DOM 和 lib.d.ts 更新相关的链接问题,以了解此版本 TypeScript 的更新内容

TypedArrays 现在是基于 ArrayBufferLike 的泛型

在 ECMAScript 2024 中,SharedArrayBuffer 和 ArrayBuffer 的类型稍微有所不同。为了填补这一差距并保留底层缓冲区类型,所有 TypedArrays(如 Uint8Array 等)现在也变为泛型

interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike> {
  // ...
}

现在每个 TypedArray 都包含一个名为 TArrayBuffer 的类型参数,虽然该类型参数有默认的类型参数,这样用户可以继续使用 Int32Array,而不需要显式地写出 Int32Array<ArrayBufferLike>

如果在更新过程中遇到如下错误:

error TS2322: Type 'Buffer' is not assignable to type 'Uint8Array<ArrayBufferLike>'.
error TS2345: Argument of type 'Buffer' is not assignable to parameter of type 'Uint8Array<ArrayBufferLike>'.
error TS2345: Argument of type 'ArrayBufferLike' is not assignable to parameter of type 'ArrayBuffer'.
error TS2345: Argument of type 'Buffer' is not assignable to parameter of type 'string | ArrayBufferView | Stream | Iterable<string | ArrayBufferView> | AsyncIterable<string | ArrayBufferView>'.

那么,您可能需要更新 @types/node

您可以在GitHub 上阅读有关此更改的具体内容

在类中使用非字面量方法名创建索引签名

TypeScript 现在对类中的方法具有更一致的行为,尤其是当它们使用非字面量计算属性名声明时。例如,在以下代码中:

declare const symbolMethodName: symbol;

export class A {
  [symbolMethodName]() {
    return 1;
  }
}

之前,TypeScript 将类视为如下:

export class A {}

换句话说,从类型系统的角度来看,[symbolMethodName] 对类 A 的类型没有任何贡献。

TypeScript 5.7 现在更加有意义地处理 [symbolMethodName]() {} 方法,并生成一个索引签名。因此,上面的代码被解释为类似以下代码:

export class A {
  [x: symbol]: () => number;
}

这种行为与对象字面量中的属性和方法一致。

有关此更改的详细信息,请阅读此处.

对返回 nullundefined 的函数更多的隐式 any 错误

当函数表达式由返回泛型类型的签名进行上下文类型推断时,TypeScript 现在在 noImplicitAny 模式下适当地提供隐式 any 错误,但在 strictNullChecks 之外。

declare var p: Promise<number>;
const p2 = p.catch(() => null);
//                 ~~~~~~~~~~
// error TS7011: Function expression, which lacks return-type annotation, implicitly has an 'any' return type.

有关此更改的更多细节,请查看此处

接下来的计划

我们将在不久后发布有关下一版本 TypeScript 的详细计划。如果您正在寻找最新的修复和功能,我们让您可以轻松使用 npm 上的 nightly 构建版本的 TypeScript,并且我们还发布了一个扩展,可以在 Visual Studio Code 中使用这些 nightly 版本。

否则,我们希望 TypeScript 5.7 能为您的编程带来愉悦体验。祝编程愉快!

TypeScript 5.6

禁止空值和真值检查

也许你曾经编写过正则表达式却忘记调用 .test(...)

if (/0x[0-9a-f]/) {
  // 哎呀!这个代码块总是会执行。
  // ...
}

或者你可能不小心写了 =>(创建一个箭头函数)而不是 >=(大于或等于运算符):

if (x => 0) {
  // 哎呀!这个代码块总是会执行。
  // ...
}

或者你可能尝试使用 ?? 设置默认值,但混淆了 ?? 和比较运算符(如 <)的优先级:

function isValid(
  value: string | number,
  options: any,
  strictness: 'strict' | 'loose'
) {
  if (strictness === 'loose') {
    value = +value;
  }
  return value < options.max ?? 100;
  // 哎呀!这被解析为 (value < options.max) ?? 100
}

或者你可能在复杂表达式中放错了括号:

if (
  isValid(primaryValue, 'strict') ||
  isValid(secondaryValue, 'strict') ||
  isValid(primaryValue, 'loose' || isValid(secondaryValue, 'loose'))
) {
  //                           ^^^^ 👀 我们忘记闭合 ')' 了吗?
}

这些示例都没有实现作者的意图,但它们都是有效的 JavaScript 代码。 此前,TypeScript 也默默地接受了这些示例。

通过一些实验,我们发现许多错误可以通过标记上述可疑示例来捕获。 在 TypeScript 5.6 中,当编译器可以从语法上确定真值或空值检查总是以特定方式求值时,它会报错。 因此,在上述示例中,你将开始看到错误:

if (/0x[0-9a-f]/) {
  //  ~~~~~~~~~~~~
  // 错误:这种表达式总是为真。
}

if (x => 0) {
  //  ~~~~~~
  // 错误:这种表达式总是为真。
}

function isValid(
  value: string | number,
  options: any,
  strictness: 'strict' | 'loose'
) {
  if (strictness === 'loose') {
    value = +value;
  }
  return value < options.max ?? 100;
  //     ~~~~~~~~~~~~~~~~~~~
  // 错误:?? 的右操作数不可达,因为左操作数永远不会为空。
}

if (
  isValid(primaryValue, 'strict') ||
  isValid(secondaryValue, 'strict') ||
  isValid(primaryValue, 'loose' || isValid(secondaryValue, 'loose'))
) {
  //                    ~~~~~~~
  // 错误:这种表达式总是为真。
}

通过启用 ESLint 的 no-constant-binary-expression 规则也可以实现类似的效果,但 TypeScript 的新检查与 ESLint 规则并不完全重叠,我们认为将这些检查内置到 TypeScript 中具有很大的价值。

需要注意的是,某些表达式仍然被允许,即使它们总是为真或为空。 特别是 truefalse01 仍然被允许,尽管它们总是为真或假,因为像以下代码:

while (true) {
  doStuff();

  if (something()) {
    break;
  }

  doOtherStuff();
}

仍然是惯用且有用的,而像以下代码:

if (true || inDebuggingOrDevelopmentEnvironment()) {
  // ...
}

在迭代或调试代码时也很有用。

如果你对这个功能的实现或它捕获的错误类型感兴趣,可以查看实现该功能的 Pull Request

迭代器辅助方法

JavaScript 有可迭代对象(通过调用 [Symbol.iterator]() 并获取迭代器来遍历的对象)和迭代器(具有 next() 方法的对象,可以在遍历时调用以获取下一个值)。 通常,当你将它们放入 for/of 循环或将它们展开到新数组中时,你不需要考虑这些。 但 TypeScript 确实用 IterableIterator 类型(甚至 IterableIterator,它同时兼具两者!)来建模这些对象,这些类型描述了 for/of 等结构所需的最小成员集。

可迭代对象(和 IterableIterator)很好,因为它们可以在 JavaScript 的许多地方使用——但许多人发现自己缺少像 mapfilterreduce 这样的数组方法。 这就是为什么 ECMAScript 最近提出了一项提案,将许多数组方法(以及更多)添加到 JavaScript 中生成的大多数 IterableIterator 上。

例如,每个生成器现在都会生成一个同时具有 maptake 方法的对象:

function* positiveIntegers() {
  let i = 1;
  while (true) {
    yield i;
    i++;
  }
}

const evenNumbers = positiveIntegers().map(x => x * 2);

// 输出:
//    2
//    4
//    6
//    8
//   10
for (const value of evenNumbers.take(5)) {
  console.log(value);
}

对于 MapSetkeys()values()entries() 方法也是如此:

function invertKeysAndValues<K, V>(map: Map<K, V>): Map<V, K> {
  return new Map(map.entries().map(([k, v]) => [v, k]));
}

你还可以扩展新的 Iterator 对象:

/**
 * 提供一个无限的 `0` 流。
 */
class Zeroes extends Iterator<number> {
  next() {
    return { value: 0, done: false } as const;
  }
}

const zeroes = new Zeroes();

// 转换为无限的 `1` 流。
const ones = zeroes.map(x => x + 1);

你可以使用 Iterator.from 将任何现有的可迭代对象或迭代器适配为这种新类型:

Iterator.from(...).filter(someFunction);

所有这些新方法都可以在较新的 JavaScript 运行时中使用,或者你可以使用新的 Iterator 对象的 polyfill。

严格的内置迭代器检查(和 --strictBuiltinIteratorReturn

当你调用 Iterator<T, TReturn>next() 方法时,它会返回一个具有 valuedone 属性的对象。这通过 IteratorResult 类型建模:

type IteratorResult<T, TReturn = any> =
  | IteratorYieldResult<T>
  | IteratorReturnResult<TReturn>;

interface IteratorYieldResult<TYield> {
  done?: false;
  value: TYield;
}

interface IteratorReturnResult<TReturn> {
  done: true;
  value: TReturn;
}

这里的命名灵感来自生成器函数的工作方式。 生成器函数可以生成值,然后返回一个最终值——但两者之间的类型可能无关。

function abc123() {
  yield 'a';
  yield 'b';
  yield 'c';
  return 123;
}

const iter = abc123();

iter.next(); // { value: "a", done: false }
iter.next(); // { value: "b", done: false }
iter.next(); // { value: "c", done: false }
iter.next(); // { value: 123, done: true }

随着新的 IteratorObject 类型的引入,我们发现允许安全实现 IteratorObject 存在一些困难。 同时,IteratorResultTReturnany(默认值!)的情况下存在长期的不安全性。 例如,假设我们有一个 IteratorResult<string, any>。 如果我们最终访问此类型的值,我们将得到 string | any,这实际上就是 any

function* uppercase(iter: Iterator<string, any>) {
  while (true) {
    const { value, done } = iter.next();
    yield value.toUppercase(); // 哎呀!忘记先检查 `done` 并且拼错了 `toUpperCase`

    if (done) {
      return;
    }
  }
}

目前,要在每个迭代器上修复此问题而不引入大量破坏是很困难的,但我们至少可以在大多数创建的 IteratorObject 上修复它。

TypeScript 5.6 引入了一个新的内置类型 BuiltinIteratorReturn 和一个新的 --strict-mode 标志 --strictBuiltinIteratorReturn。 每当在 lib.d.ts 中使用 IteratorObject 时,它们总是用 BuiltinIteratorReturn 类型表示 TReturn(尽管你会更常见到更具体的 MapIteratorArrayIteratorSetIterator 等)。

interface MapIterator<T>
  extends IteratorObject<T, BuiltinIteratorReturn, unknown> {
  [Symbol.iterator](): MapIterator<T>;
}

// ...

interface Map<K, V> {
  // ...

  /**
   * 返回一个包含 map 中每个条目的键值对的可迭代对象。
   */
  entries(): MapIterator<[K, V]>;

  /**
   * 返回 map 中键的可迭代对象。
   */
  keys(): MapIterator<K>;

  /**
   * 返回 map 中值的可迭代对象。
   */
  values(): MapIterator<V>;
}

默认情况下,BuiltinIteratorReturnany,但当启用 --strictBuiltinIteratorReturn 时(可能通过 --strict),它是 undefined。 在这种新模式下,如果我们使用 BuiltinIteratorReturn,前面的示例现在会正确报错:

function* uppercase(iter: Iterator<string, BuiltinIteratorReturn>) {
  while (true) {
    const { value, done } = iter.next();
    yield value.toUppercase();
    //    ~~~~~ ~~~~~~~~~~~
    // error!┃      ┃
    //       ┃      ┗━ 类型 'string' 上不存在属性 'toUppercase'。你是否指的是 'toUpperCase'?
    //       ┃
    //       ┗━ 'value' 可能为 'undefined'。

    if (done) {
      return;
    }
  }
}

你通常会在 lib.d.ts 中看到 BuiltinIteratorReturnIteratorObject 配对使用。 一般来说,我们建议在你自己的代码中尽可能明确 TReturn

更多信息,你可以在这里阅读该功能的详细信息。

支持任意模块标识符

JavaScript 允许模块以字符串字面量的形式导出无效标识符名称的绑定:

const banana = "🍌";

export { banana as "🍌" };

同样,它也允许模块使用这些任意名称导入并将其绑定到有效标识符:

import { "🍌" as banana } from "./foo"

/**
 * 吃吃吃
 */
function eat(food: string) {
    console.log("Eating", food);
};

eat(banana);

这看起来像是一个可爱的派对技巧(如果你像我们一样在派对上很有趣),但它对于与其他语言的互操作性(通常通过 JavaScript/WebAssembly 边界)很有用,因为其他语言可能对有效标识符的构成有不同的规则。它对于生成代码的工具(如 esbuild 的 inject 功能)也很有用。

TypeScript 5.6 现在允许你在代码中使用这些任意模块标识符! 我们要感谢 Evan Wallace 为 TypeScript 贡献了这一更改!

--noUncheckedSideEffectImports 选项

在 JavaScript 中,可以在不实际导入任何值的情况下导入模块:

import 'some-module';

这些导入通常被称为副作用导入,因为它们唯一有用的行为是通过执行某些副作用(如注册全局变量或将 polyfill 添加到原型)来提供的。

在 TypeScript 中,这种语法有一个相当奇怪的怪癖:如果导入可以解析为有效的源文件,那么 TypeScript 会加载并检查该文件。 另一方面,如果找不到源文件,TypeScript 会默默地忽略该导入!

这种行为令人惊讶,但它部分源于对 JavaScript 生态系统中模式的建模。 例如,这种语法也用于 bundler 中的特殊加载器来加载 CSS 或其他资源。 你的 bundler 可能配置为通过编写如下内容来包含特定的 .css 文件:

import './button-component.css';

export function Button() {
  // ...
}

尽管如此,这掩盖了副作用导入中潜在的拼写错误。 这就是为什么 TypeScript 5.6 引入了一个新的编译器选项 --noUncheckedSideEffectImports 来捕获这些情况。 当启用 --noUncheckedSideEffectImports 时,如果 TypeScript 找不到副作用导入的源文件,它将报错。

import 'oops-this-module-does-not-exist';
//     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 错误:找不到模块 'oops-this-module-does-not-exist' 或其对应的类型声明。

启用此选项后,一些原本可以工作的代码现在可能会收到错误,例如上面的 CSS 示例。 为了解决这个问题,只想为资源编写副作用导入的用户可能更适合编写带有通配符说明符的环境模块声明。 它会放在一个全局文件中,看起来像这样:

// ./src/globals.d.ts

// 将所有 CSS 文件识别为模块导入。
declare module '*.css' {}

事实上,你的项目中可能已经有这样的文件了! 例如,运行类似 vite init 的命令可能会创建一个类似的 vite-env.d.ts 文件。

虽然此选项目前默认关闭,但我们鼓励用户尝试使用它!

更多信息,请查看此功能的实现。

--noCheck 选项

TypeScript 5.6 引入了一个新的编译器选项 --noCheck,它允许你跳过对所有输入文件的类型检查。 这在执行生成输出文件所需的语义分析时避免了不必要的类型检查。

一个场景是将 JavaScript 文件生成与类型检查分开,以便两者可以作为单独的阶段运行。 例如,你可以在迭代时运行 tsc --noCheck,然后运行 tsc --noEmit 进行彻底的类型检查。 你还可以并行运行这两个任务,甚至在 --watch 模式下,但请注意,如果你真的同时运行它们,你可能需要指定一个单独的 --tsBuildInfoFile 路径。

--noCheck 对于以类似方式生成声明文件也很有用。 在指定了 --noCheck 的项目中,如果项目符合 --isolatedDeclarations,TypeScript 可以快速生成声明文件而无需进行类型检查。 生成的声明文件将完全依赖于快速的语法转换。

请注意,在指定了 --noCheck 但项目未使用 --isolatedDeclarations 的情况下,TypeScript 可能仍会执行尽可能多的类型检查以生成 .d.ts 文件。 从这个意义上说,--noCheck 有点用词不当; 然而,该过程将比完整的类型检查更懒,仅计算未注释声明的类型。 这应该比完整的类型检查快得多。

noCheck 也可以通过 TypeScript API 作为标准选项使用。 在内部,transpileModuletranspileDeclaration 已经使用 noCheck 来加速(至少在 TypeScript 5.5 中)。 现在,任何构建工具都应该能够利用该标志,采取各种自定义策略来协调和加速构建。

更多信息,请参阅 TypeScript 5.5 中为 noCheck 内部提速所做的工作,以及使其在命令行和 API 中公开的相关工作。

允许在中间错误时继续构建

TypeScript 的项目引用概念允许你将代码库组织为多个项目并创建它们之间的依赖关系。 在 --build 模式下运行 TypeScript 编译器(或简写为 tsc -b)是实际跨项目构建并确定需要编译的项目和文件的内置方式。

以前,使用 --build 模式会假设 --noEmitOnError 并在遇到任何错误时立即停止构建。 这意味着如果任何“上游”依赖项有构建错误,“下游”项目将永远不会被检查和构建。 理论上,这是一种非常合理的方法——如果一个项目有错误,它不一定处于其依赖项的一致状态。

实际上,这种僵化使得升级等工作变得痛苦。 例如,如果 projectB 依赖于 projectA,那么更熟悉 projectB 的人无法主动升级他们的代码,直到他们的依赖项升级完毕。 他们被 projectA 的升级工作所阻碍。

从 TypeScript 5.6 开始,--build 模式将继续构建项目,即使依赖项中存在中间错误。 在存在中间错误的情况下,它们将被一致地报告,并且输出文件将尽最大努力生成;但是,构建将继续完成指定的项目。

如果你想在第一个出现错误的项目上停止构建,你可以使用一个名为 --stopOnBuildErrors 的新标志。这在 CI 环境中运行时,或者在迭代一个被其他项目严重依赖的项目时非常有用。

请注意,为了实现这一点,TypeScript 现在总是为 --build 调用中的任何项目生成 .tsbuildinfo 文件(即使未指定 --incremental/--composite)。 这是为了跟踪 --build 的调用状态以及未来需要执行的工作。

你可以在这里阅读有关此更改的更多信息。

编辑器中的区域优先诊断

当 TypeScript 的语言服务被请求获取文件的诊断信息(如错误、建议和弃用)时,它通常需要检查整个文件。 大多数情况下这没问题,但在非常大的文件中,这可能会导致延迟。 这可能令人沮丧,因为修复拼写错误应该感觉是一个快速操作,但在足够大的文件中可能需要几秒钟。

为了解决这个问题,TypeScript 5.6 引入了一个名为区域优先诊断或区域优先检查的新功能。 编辑器现在不仅可以请求一组文件的诊断信息,还可以提供给定文件的相关区域——其意图是这通常是用户当前可见的文件区域。 TypeScript 语言服务器然后可以选择提供两组诊断信息:一组用于该区域,另一组用于整个文件。 这使得在大型文件中编辑感觉更加响应迅速,因为你不会等待那么长时间让红色波浪线消失。

对于一些具体数字,在我们的测试中,TypeScript 自己的 checker.ts 的完整语义诊断响应耗时 3330 毫秒。 相比之下,第一个基于区域的诊断响应耗时 143 毫秒! 而剩余的全文件响应耗时约 3200 毫秒,这对于快速编辑来说可以产生巨大的差异。

此功能还包括大量工作,以使诊断在整个体验中更一致地报告。 由于我们的类型检查器利用缓存来避免工作,相同类型之间的后续检查通常会有不同的(通常更短的)错误消息。 从技术上讲,懒惰的乱序检查可能会导致诊断在编辑器中的两个位置之间报告不同——甚至在此功能之前——但我们不想加剧这个问题。 通过最近的工作,我们已经消除了许多这些错误不一致。

目前,此功能在 Visual Studio Code 中可用于 TypeScript 5.6 及更高版本。

更多详细信息,请查看此功能的实现和说明。

细粒度的提交字符

TypeScript 的语言服务现在为每个补全项提供自己的提交字符。 提交字符是特定字符,当键入时,它们会自动提交当前建议的补全项。

这意味着随着时间的推移,当你键入某些字符时,你的编辑器将更频繁地提交当前建议的补全项。 例如,以下代码:

declare let food: {
    eat(): any;
}

let f = (foo/**/

如果我们的光标位于 /**/,我们正在编写的代码可能是 let f = (food.eat())let f = (foo, bar) => foo + bar。 你可以想象,编辑器可能会根据我们接下来键入的字符自动完成不同的内容。 例如,如果我们键入句点/点字符 (.),我们可能希望编辑器用变量 food 完成; 但如果我们键入逗号字符 (,),我们可能正在编写箭头函数中的参数。

不幸的是,以前 TypeScript 只是向编辑器发出信号,表明当前文本可能定义了一个新的参数名称,因此没有安全的提交字符。 因此,即使很明显编辑器应该用单词 food 自动完成,按下 . 也不会做任何事情。

TypeScript 现在明确列出了每个完成项的安全提交字符。 虽然这不会立即改变你的日常体验,但支持这些提交字符的编辑器应该会随着时间的推移看到行为改进。 要立即看到这些改进,你现在可以在 Visual Studio Code Insiders 中使用 TypeScript 夜间扩展。 在上面的代码中按下 . 会正确自动完成 food

更多信息,请参阅添加提交字符的 Pull Request 以及我们根据上下文调整提交字符的工作。

自动导入的排除模式

TypeScript 的语言服务现在允许你指定一个正则表达式模式列表,这些模式将过滤掉某些说明符的自动导入建议。 例如,如果你想排除像 lodash 这样的包的所有“深层”导入,你可以在 Visual Studio Code 中配置以下首选项:

{
  "typescript.preferences.autoImportSpecifierExcludeRegexes": ["^lodash/.*$"]
}

或者反过来,你可能希望禁止从包的入口点导入:

{
  "typescript.preferences.autoImportSpecifierExcludeRegexes": ["^lodash$"]
}

你甚至可以通过以下设置避免 node: 导入:

{
  "typescript.preferences.autoImportSpecifierExcludeRegexes": ["^node:"]
}

要指定某些正则表达式标志(如 iu),你需要用斜杠包围你的正则表达式。 当提供包围斜杠时,你需要转义其他内部斜杠。

{
  "typescript.preferences.autoImportSpecifierExcludeRegexes": [
    "^./lib/internal", // 不需要转义
    "/^.\\/lib\\/internal/", // 需要转义 - 注意前导和尾随斜杠
    "/^.\\/lib\\/internal/i" // 需要转义 - 我们需要斜杠来提供 'i' 正则表达式标志
  ]
}

相同的设置可以通过 VS Code 中的 javascript.preferences.autoImportSpecifierExcludeRegexes 应用于 JavaScript。

请注意,虽然此选项可能与 typescript.preferences.autoImportFileExcludePatterns 有一些重叠,但存在差异。 现有的 autoImportFileExcludePatterns 接受一个排除文件路径的 glob 模式列表。 这对于许多你想避免从特定文件和目录自动导入的场景可能更简单,但这并不总是足够的。 例如,如果你使用 @types/node 包,同一个文件声明了 fsnode:fs,所以我们不能使用 autoImportExcludePatterns 来过滤掉其中一个。

新的 autoImportSpecifierExcludeRegexes 特定于模块说明符(我们在导入语句中编写的特定字符串),所以我们可以编写一个模式来排除 fsnode:fs 而不排除另一个。 更重要的是,我们可以编写模式来强制自动导入更喜欢不同的说明符样式(例如,更喜欢 ./foo/bar.js 而不是 #foo/bar.js)。

更多信息,请查看此功能的实现。

值得注意的行为变化

本节重点介绍一些值得注意的变化,这些变化应在任何升级中被确认和理解。有时它会突出显示弃用、移除和新限制。它还可以包含功能上改进的错误修复,但这些修复也可能通过引入新错误影响现有构建。

lib.d.ts

为 DOM 生成的类型可能会影响你的代码库的类型检查。更多信息,请参阅与 DOM 和 lib.d.ts 更新相关的此版本 TypeScript 的问题。

始终写入 .tsbuildinfo

为了启用 --build 在依赖项中存在中间错误时继续构建项目,并支持命令行上的 --noCheck,TypeScript 现在总是为 --build 调用中的任何项目生成 .tsbuildinfo 文件。 无论是否实际启用了 --incremental,都会发生这种情况。更多信息请参见此处。

尊重 node_modules 中的文件扩展名和 package.json

在 Node.js 实现 ECMAScript 模块支持之前(v12),TypeScript 从来没有一个好的方法来知道它在 node_modules 中找到的 .d.ts 文件是代表作为 CommonJS 还是 ECMAScript 模块编写的 JavaScript 文件。当绝大多数 npm 只是 CommonJS 时,这并没有引起太多问题——如果有疑问,TypeScript 可以假设一切都像 CommonJS 一样运行。不幸的是,如果这种假设是错误的,它可能会允许不安全的导入:

// node_modules/dep/index.d.ts
export declare function doSomething(): void;

// index.ts
// 如果 "dep" 是 CommonJS 模块,这是可以的,但如果
// 它是 ECMAScript 模块,则会失败——即使在捆绑器中!
import dep from 'dep';
dep.doSomething();

在实践中,这种情况并不常见。但在 Node.js 开始支持 ECMAScript 模块以来的几年里,npm 上 ESM 的份额有所增长。幸运的是,Node.js 还引入了一种机制,可以帮助 TypeScript 确定文件是 ECMAScript 模块还是 CommonJS 模块:.mjs.cjs 文件扩展名以及 package.json 中的 "type" 字段。TypeScript 4.7 添加了对理解这些指示器的支持,以及编写 .mts.cts 文件的支持;然而,TypeScript 只会在 --module node16--module nodenext 下读取这些指示器,因此对于使用 --module esnext--moduleResolution bundler 的人来说,上面的不安全导入仍然是一个问题。

为了解决这个问题,TypeScript 5.6 收集模块格式信息,并使用它来解析所有模块模式(除了 amdumdsystem)中的歧义。格式特定的文件扩展名(.mts.cts)在任何地方都被尊重,并且 package.json 中的 "type" 字段在 node_modules 依赖项中被查阅,无论模块设置如何。以前,技术上可以将 CommonJS 输出生成到 .mjs 文件中,反之亦然:

// main.mts
export default 'oops';

// $ tsc --module commonjs main.mts
// main.mjs
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = 'oops';

现在,.mts 文件永远不会生成 CommonJS 输出,而 .cts 文件永远不会生成 ESM 输出。

请注意,这种行为的大部分在 TypeScript 5.5 的预发布版本中提供(实现细节在此),但在 5.6 中,此行为仅扩展到 node_modules 中的文件。

更多详细信息,请参阅此更改。

计算属性的正确覆盖检查

以前,标记为 override 的计算属性没有正确检查基类成员的存在。同样,如果你使用 noImplicitOverride,如果你忘记向计算属性添加 override 修饰符,你不会收到错误。

TypeScript 5.6 现在正确检查这两种情况下的计算属性。

const foo = Symbol('foo');
const bar = Symbol('bar');

class Base {
  [bar]() {}
}

class Derived extends Base {
  override [foo]() {}
  //           ~~~~~
  // 错误:此成员不能有 'override' 修饰符,因为它未在基类 'Base' 中声明。

  [bar]() {}
  //  ~~~~~
  // 在 noImplicitOverride 下的错误:此成员必须具有 'override' 修饰符,因为它覆盖了基类 'Base' 中的成员。
}

此修复由 Oleksandr Tarasiuk 在此 Pull Request 中贡献。

TypeScript 5.5

推断的类型谓词

TypeScript 的控制流分析在跟踪变量类型在代码中的变化时表现得非常出色:

interface Bird {
  commonName: string;
  scientificName: string;
  sing(): void;
}

// Maps country names -> national bird.
// Not all nations have official birds (looking at you, Canada!)
declare const nationalBirds: Map<string, Bird>;

function makeNationalBirdCall(country: string) {
  const bird = nationalBirds.get(country); // bird has a declared type of Bird | undefined
  if (bird) {
    bird.sing(); // bird has type Bird inside the if statement
  } else {
    // bird has type undefined here.
  }
}

通过让你处理 undefined 情况,TypeScript 促使你编写更健壮的代码。

在过去,这种类型细化在数组上更难应用。在所有以前的 TypeScript 版本中,这都会是一个错误:

function makeBirdCalls(countries: string[]) {
  // birds: (Bird | undefined)[]
  const birds = countries
    .map(country => nationalBirds.get(country))
    .filter(bird => bird !== undefined);

  for (const bird of birds) {
    bird.sing(); // error: 'bird' is possibly 'undefined'.
  }
}

代码是完全没有问题的:我们已经过滤掉了数组中所有的 undefined 值。 但是 TypeScript 却无法跟踪这些变化。

TypeScript 5.5 可以处理这种情况:

function makeBirdCalls(countries: string[]) {
  // birds: Bird[]
  const birds = countries
    .map(country => nationalBirds.get(country))
    .filter(bird => bird !== undefined);

  for (const bird of birds) {
    bird.sing(); // ok!
  }
}

注意 birds 变量的更精确类型。

因为 TypeScript 现在能够为 filter 函数推断出类型谓词,所以这段代码才能工作。 你可以将代码提出到独立的函数中以便能清晰地看出这些:

// function isBirdReal(bird: Bird | undefined): bird is Bird
function isBirdReal(bird: Bird | undefined) {
  return bird !== undefined;
}

bird is Bird 是类型谓词。 它表示,如果函数返回 true,那么结果为 Bird (如果函数返回 false,结果为 undefined)。 Array.prototype.filter 的类型声明能够识别类型谓词,所以最终的结果是你获得了一个更精确的类型,并且代码通过了类型检查器的验证。

如果以下条件成立,TypeScript 会推断一个函数返回一个类型谓词:

  • 函数没有显式的返回类型或类型谓词注解。
  • 函数只有一个返回语句,并且没有隐式返回。
  • 函数不会改变其参数。
  • 函数返回的布尔表达式与参数的类型细化有关。

通常,这种推断方式会如你所预期的那样工作。以下是一些推断类型谓词的更多示例:

// const isNumber: (x: unknown) => x is number
const isNumber = (x: unknown) => typeof x === 'number';

// const isNonNullish: <T>(x: T) => x is NonNullable<T>
const isNonNullish = <T>(x: T) => x != null;

从前,TypeScript 仅会推断出这类函数返回 boolean。 但现在会推断出带类型谓词的签名,例如 x is numberx is NonNullable<T>

类型谓词具有“当且仅当”的语义。 如果函数返回 x is T,那就意味着:

  1. 如果函数返回 true,那么 x 的类型为 T
  2. 如果函数返回 false,那么 x 的类型不为 T

如果你期待得到一个类型谓词但却没有,那么有可能违反了第二条规则。 这通常出现在“真值”检查中:

function getClassroomAverage(
  students: string[],
  allScores: Map<string, number>
) {
  const studentScores = students
    .map(student => allScores.get(student))
    .filter(score => !!score);

  return studentScores.reduce((a, b) => a + b) / studentScores.length;
  //     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // error: Object is possibly 'undefined'.
}

TypeScript 没有为 score => !!score 推断出类型谓词,这是有道理的:如果返回 true,那么 score 是一个数字。但如果返回 false,那么 score 可能是 undefined 或者是数字(特别是 0)。这确实是一个漏洞:如果有学生在测试中得了零分,那么过滤掉他们的分数会导致平均分上升。这样一来,少数人会高于平均水平,而更多的人会感到沮丧!

与第一个例子一样,最好明确地过滤掉 undefined 值:

function getClassroomAverage(
  students: string[],
  allScores: Map<string, number>
) {
  const studentScores = students
    .map(student => allScores.get(student))
    .filter(score => score !== undefined);

  return studentScores.reduce((a, b) => a + b) / studentScores.length; // ok!
}

当对象类型不存在歧义时,“真实性”检查会为对象类型推断出类型谓词。 请记住,函数必须返回一个布尔值才能成为推断类型谓词的候选者:x => !!x 可能会推断出类型谓词,但 x => x 绝对不会。

显式类型谓词依然像以前一样工作。TypeScript 不会检查它是否会推断出相同的类型谓词。 显式类型谓词("is")并不比类型断言("as")更安全。

如果 TypeScript 现在推断出的类型比你期望的更精确,那么这个特性可能会破坏现有的代码。例如:

// Previously, nums: (number | null)[]
// Now, nums: number[]
const nums = [1, 2, 3, null, 5].filter(x => x !== null);

nums.push(null); // ok in TS 5.4, error in TS 5.5

解决方法是使用显式类型注解告诉 TypeScript 你想要的类型:

const nums: (number | null)[] = [1, 2, 3, null, 5].filter(x => x !== null);
nums.push(null); // ok in all versions

更多详情请参考PRDan 的博客

常量索引访问的控制流细化

objkey 是常量时,TypeScript 现在能够细化 obj[key] 形式的表达式。

function f1(obj: Record<string, unknown>, key: string) {
  if (typeof obj[key] === 'string') {
    // Now okay, previously was error
    obj[key].toUpperCase();
  }
}

如上,objkey 都没有修改过,因此 TypeScript 能够在 typeof 检查后将 obj[key] 细化为 string。 更多详情请参考PR

JSDoc @import 标签

如今,在 JavaScript 文件中,如果你只想为类型检查导入某些内容,这显得很繁琐。 JavaScript 开发者不能简单地导入一个名为 SomeType 的类型,如果在运行时该类型不存在的话。

// ./some-module.d.ts
export interface SomeType {
  // ...
}

// ./index.js
import { SomeType } from './some-module'; //  runtime error!

/**
 * @param {SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

SomeType 类型在运行时不存在,因此导入会失败。 开发者可以使用命名空间导入来替代。

import * as someModule from './some-module';

/**
 * @param {someModule.SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

./some-module 仍是在运行时导入 - 可能不是期望的行为。

为避免此问题,开发者通常需要在 JSDoc 里使用 import(...)

/**
 * @param {import("./some-module").SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

如果你想在多处重用该类型,你可以使用 typedef 来减少重覆。

/**
 * @typedef {import("./some-module").SomeType} SomeType
 */

/**
 * @param {SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

对于本地使用 SomeType 的情况是没问题的,但是出现了很多重覆的导入并显得啰嗦。

因此,TypeScript 现在支持了新的 @import 注释标签,它与 ECMAScript 导入语句有相同的语法。

/** @import { SomeType } from "some-module" */

/**
 * @param {SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

此处,我们使用了命名导入。 我们也可将其写为命名空间导入。

/** @import * as someModule from "some-module" */

/**
 * @param {someModule.SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

因为它们是 JSDoc 注释,它们完全不影响运行时行为。

更多详情请参考PR。 感谢 Oleksandr Tarasiuk 的贡献。

正则表达式语法检查

直到现在,TypeScript 通常会跳过代码中的大多数正则表达式。 这是因为正则表达式在技术上具有可扩展的语法,TypeScript 从未努力将正则表达式编译成早期版本的 JavaScript。 尽管如此,这意味着许多常见问题可能会在正则表达式中被忽略,并且它们要么会在运行时转变为错误,要么会悄悄地失败。

但是现在,TypeScript 对正则表达式进行基本的语法检查了!

let myRegex = /@robot(\s+(please|immediately)))? do some task/;
//                                            ~
// error!
// Unexpected ')'. Did you mean to escape it with backslash?

这是一个简单的例子,但这种检查可以捕捉到许多常见的错误。 事实上,TypeScript 的检查略微超出了语法检查。 例如,TypeScript 现在可以捕捉到不存在的后向引用周围的问题。

let myRegex = /@typedef \{import\((.+)\)\.([a-zA-Z_]+)\} \3/u;
//                                                        ~
// error!
// This backreference refers to a group that does not exist.
// There are only 2 capturing groups in this regular expression.

这同样适用于捕获命名的分组。

let myRegex =
  /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<namedImport>/;
//                                                                                        ~~~~~~~~~~~
// error!
// There is no capturing group named 'namedImport' in this regular expression.

TypeScript 现在还会检测到当使用某些 RegExp 功能时,这些功能是否比您的 ECMAScript 目标版本更新。 例如,如果我们在 ES5 目标中使用类似上文中的命名捕获组,将会导致错误。

let myRegex =
  /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<importedEntity>/;
//                                  ~~~~~~~~~~~~         ~~~~~~~~~~~~~~~~
// error!
// Named capturing groups are only available when targeting 'ES2018' or later.

同样适用于某些正则表达式标志。

请注意,TypeScript 的正则表达式支持仅限于正则表达式字面量。 如果您尝试使用字符串字面量调用 new RegExp,TypeScript 将不会检查提供的字符串。

更多详情请参考PR。 感谢 graphemecluster 的贡献。

支持新的 ECMAScript Set 方法

TypeScript 5.5 声明了新提议的 ECMAScript Set 类型。

其中一些方法,比如 unionintersectiondifferencesymmetricDifference,接受另一个 Set 并返回一个新的 Set 作为结果。另一些方法,比如 isSubsetOfisSupersetOfisDisjointFrom,接受另一个 Set 并返回一个布尔值。这些方法都不会改变原始的 Sets

示例:

let fruits = new Set(['apples', 'bananas', 'pears', 'oranges']);
let applesAndBananas = new Set(['apples', 'bananas']);
let applesAndOranges = new Set(['apples', 'oranges']);
let oranges = new Set(['oranges']);
let emptySet = new Set();

////
// union
////

// Set(4) {'apples', 'bananas', 'pears', 'oranges'}
console.log(fruits.union(oranges));

// Set(3) {'apples', 'bananas', 'oranges'}
console.log(applesAndBananas.union(oranges));

////
// intersection
////

// Set(2) {'apples', 'bananas'}
console.log(fruits.intersection(applesAndBananas));

// Set(0) {}
console.log(applesAndBananas.intersection(oranges));

// Set(1) {'apples'}
console.log(applesAndBananas.intersection(applesAndOranges));

////
// difference
////

// Set(3) {'apples', 'bananas', 'pears'}
console.log(fruits.difference(oranges));

// Set(2) {'pears', 'oranges'}
console.log(fruits.difference(applesAndBananas));

// Set(1) {'bananas'}
console.log(applesAndBananas.difference(applesAndOranges));

////
// symmetricDifference
////

// Set(2) {'bananas', 'oranges'}
console.log(applesAndBananas.symmetricDifference(applesAndOranges)); // no apples

////
// isDisjointFrom
////

// true
console.log(applesAndBananas.isDisjointFrom(oranges));

// false
console.log(applesAndBananas.isDisjointFrom(applesAndOranges));

// true
console.log(fruits.isDisjointFrom(emptySet));

// true
console.log(emptySet.isDisjointFrom(emptySet));

////
// isSubsetOf
////

// true
console.log(applesAndBananas.isSubsetOf(fruits));

// false
console.log(fruits.isSubsetOf(applesAndBananas));

// false
console.log(applesAndBananas.isSubsetOf(oranges));

// true
console.log(fruits.isSubsetOf(fruits));

// true
console.log(emptySet.isSubsetOf(fruits));

////
// isSupersetOf
////

// true
console.log(fruits.isSupersetOf(applesAndBananas));

// false
console.log(applesAndBananas.isSupersetOf(fruits));

// false
console.log(applesAndBananas.isSupersetOf(oranges));

// true
console.log(fruits.isSupersetOf(fruits));

// false
console.log(emptySet.isSupersetOf(fruits));

感谢 Kevin Gibbons 的贡献。

孤立的声明

声明文件(即 .d.ts 文件)用于向 TypeScript 描述现有库和模块的结构。 这种轻量级描述包括库的类型签名,但不包含实现细节,例如函数体。 它们被发布出来,以便 TypeScript 在检查你对库的使用时无需分析库本身。 虽然可以手写声明文件,但如果你正在编写带类型的代码,使用 --declaration 让 TypeScript 从源文件自动生成它们会更安全、更简单。

TypeScript 编译器及其 API 一直以来都负责生成声明文件; 然而,有些情况下你可能希望使用其他工具,或者传统的构建流程无法满足需求。

用例:更快的声明生成工具

想象一下,如果你想创建一个更快的工具来生成声明文件,也许作为发布服务或一个新的打包工具的一部分。 虽然有许多快速工具生态系统可以将 TypeScript 转换为 JavaScript,但将 TypeScript 转换为声明文件的工具并不那么丰富。 原因在于 TypeScript 的推断能力允许我们编写代码而不需要显式声明类型,这意味着声明生成可能会变得复杂。

让我们考虑一个简单的例子,一个将两个导入变量相加的函数。

// util.ts
export let one = '1';
export let two = '2';

// add.ts
import { one, two } from './util';
export function add() {
  return one + two;
}

即使我们只想生成 add.d.ts 这个声明文件,TypeScript 也需要深入到另一个导入的文件(util.ts),推断出 onetwo 的类型为字符串,然后计算出两个字符串上的 + 运算符将导致一个字符串返回类型。

// add.d.ts
export declare function add(): string;

虽然这种推断对开发人员体验很重要,但这意味着想要生成声明文件的工具需要复制类型检查器的部分内容,包括推断和解析模块规范器以跟踪导入。

用例:并行的声明生成和类型检查

想象一下,如果你拥有一个包含许多项目的单体库(monorepo)和一个渴望帮助你更快检查代码的多核 CPU。如果我们能够通过在每个核心上运行不同项目来同时检查所有这些项目,那不是太棒了吗?

不幸的是,我们不能自由地将所有工作并行处理。 原因是我们必须按照依赖顺序构建这些项目,因为每个项目都在对其依赖项的声明文件进行检查。 因此,我们必须首先构建依赖项以生成声明文件。 TypeScript 的项目引用功能也是以"拓扑"依赖顺序构建项目集合。

举个例子,如果我们有两个名为 backendfrontend 的项目,它们都依赖一个名为 core 的项目,TypeScript 在构建 core 并生成其声明文件之前,无法开始对 frontendbackend 进行类型检查。

beta-isolated-declarations-deps

在上面的图中,您可以看到我们有一个瓶颈。虽然我们可以并行构建 frontendbackend,但我们需要等待 core 完成构建,然后才能开始任何一个项目的构建。

我们该如何改进呢? 如果一个快速工具可以并行生成所有这些 core 的声明文件,那么 TypeScript 就可以立即跟进,通过并行检查 corefrontendbackend

解决文案:显式的类型

在这两种用例中的共同要求是,我们需要一个跨文件类型检查器来生成声明文件。 这对于工具开发社区来说是一个很大的要求。

作为一个更复杂的例子,如果我们想要以下代码的声明文件…

import { add } from './add';

const x = add();

export function foo() {
  return x;
}

我们需要为 foo 生成一个签名。 这需要查看 foo 的实现。 foo 只是返回 x,所以获取 x 的类型需要查看 add 的实现。 但这可能需要查看 add 的依赖项的实现,依此类推。 我们在这里看到的是,生成声明文件需要大量逻辑来确定可能甚至不在当前文件中的不同位置的类型。

不过,对于寻求快速迭代时间和完全并行构建的开发人员来说,还有另一种思考这个问题的方式。 声明文件仅需要模块的公共 API 类型,换句话说,导出内容的类型。 如果开发人员愿意显式编写导出内容的类型,工具就可以生成声明文件,而无需查看模块的实现 - 也无需重新实现完整的类型检查器。

这就是新的 --isolatedDeclarations 选项发挥作用的地方。 --isolatedDeclarations 在模块无法在没有类型检查器的情况下被可靠转换时报告错误。 简而言之,如果您有一个没有足够注释其导出的文件,TypeScript 将报告错误。

这意味着在上面的例子中,我们将看到类似以下的错误:

export function foo() {
  //              ~~~
  // error! Function must have an explicit
  // return type annotation with --isolatedDeclarations.
  return x;
}

为什么错误是期望的?

因为这意味着 TypeScript 能够

  1. 提前告知其它工具在生成声明文件时是否会有问题
  2. 提供快速修复功能帮助添加缺失的类型注解

然而,这种模式并不要求在所有地方都进行注解。 对于局部变量,可以忽略这些注解,因为它们不会影响公共 API。 例如,以下代码不会产生错误:

import { add } from './add';

const x = add('1', '2'); // no error on 'x', it's not exported.

export function foo(): string {
  return x;
}

在某些表达式中,计算类型是“微不足道的”。

// No error on 'x'.
// It's trivial to calculate the type is 'number'
export let x = 10;

// No error on 'y'.
// We can get the type from the return expression.
export function y() {
  return 20;
}

// No error on 'z'.
// The type assertion makes it clear what the type is.
export function z() {
  return Math.max(x, y()) as number;
}

使用 isolatedDeclarations

isolatedDeclarations 要求设置 declarationcomposite 标志之一。

请注意,isolatedDeclarations 不会改变 TypeScript 的输出方式,只会改变它报告错误的方式。 重要的是,与 isolatedModules 类似,启用 TypeScript 中的该功能不会立即带来本文讨论的潜在好处。 因此,请耐心等待,并期待这一领域的未来发展。 考虑到工具作者的需求,我们还应该认识到,如今,并非所有 TypeScript 的声明输出都能轻松地由其他希望使用它作为指南的工具复制。 这是我们正在积极致力于改进的事项。

此外,独立声明仍然是一个新功能,我们正在积极努力改进体验。 一些情景,比如在类和对象字面量中使用计算属性声明,尚不受 isolatedDeclarations 支持。 请留意这方面的进展,并随时提供反馈。

我们还认为值得指出的是,应该基于具体情况逐案采用 isolatedDeclarations。 在使用 isolatedDeclarations 时可能会失去一些开发人员友好性,因此,如果您的设置没有利用前面提到的两种情景,这可能不是正确的选择。 对于其他人来说,isolatedDeclarations 的工作已经揭示了许多优化和机会,可以解锁不同的并行构建策略。 同时,如果您愿意做出权衡,我们相信随着外部工具变得更广泛可用,isolatedDeclarations 可以成为加快构建流程的强大工具。

更多详情请参考讨论

信用

独立声明的工作是 TypeScript 团队与 Bloomberg 和 Google 内基础设施和工具团队之间长期的合作努力。 像 Google 的 Hana Joo 这样实现了独立声明错误快速修复的个人(更多相关信息即将发布),以及 Ashley Claymore、Jan Kühle、Lisa Velden、Rob Palmer 和 Thomas Chetwin 等人数个月以来一直参与讨论、规范和实施。 但我们特别要提到 Bloomberg 的 Titian Cernicova-Dragomir 提供的大量工作。 Titian 在推动独立声明实现方面发挥了关键作用,并在之前的多年里一直是 TypeScript 项目的贡献者。

更多详情请参考 PR

配置文件中的 ${configDir} 模版变量

在许多代码库中都会重用某个 tsconfig.json 作为其它配置文件的“基础”。 这是通过在 tsconfig.json 文件中使用 extends 字段实现的。

{
    "extends": "../../tsconfig.base.json",
    "compilerOptions": {
        "outDir": "./dist"
    }
}

其中一个问题是,tsconfig.json 文件中的所有路径都是相对于文件本身的位置。 这意味着如果您有一个被多个项目使用的共享 tsconfig.base.json 文件,那么派生项目中的相对路径通常不会有用。 例如,请想象以下 tsconfig.base.json

{
    "compilerOptions": {
        "typeRoots": [
            "./node_modules/@types"
            "./custom-types"
        ],
        "outDir": "dist"
    }
}

如果作者的意图是每个继承此文件的 tsconfig.json 都应:

  1. 输出到相对于派生 tsconfig.jsondist 目录,并且
  2. 有一个相对于派生 tsconfig.jsoncustom-types 目录,

那么这样做是行不通的。 typeRoots 路径将是相对于共享 tsconfig.base.json 文件的位置,而不是继承它的项目。 每个继承此共享文件的项目都需要声明自己的 outDirtypeRoots,并且内容相同。 这可能会让人沮丧,并且在项目之间保持同步可能会很困难。 虽然上面的示例使用了 typeRoots,但这对于路径和其他选项来说是一个常见问题。

为了解决这个问题,TypeScript 5.5 引入了一个新的模板变量 ${configDir}。 当在 tsconfig.jsonjsconfig.json 文件的某些路径字段中写入 ${configDir} 时,此变量将在给定编译中替换为配置文件的所在目录。 这意味着上述 tsconfig.base.json 可以重写为:

{
    "compilerOptions": {
        "typeRoots": [
            "${configDir}/node_modules/@types"
            "${configDir}/custom-types"
        ],
        "outDir": "${configDir}/dist"
    }
}

现在,当一个项目继承此文件时,路径将相对于派生的 tsconfig.json,而不是共享的 tsconfig.base.json 文件。 这使得在项目之间共享配置文件变得更加容易,并确保配置文件更具可移植性。

如果您打算使一个 tsconfig.json 文件可继承,请考虑是否应该用 ${configDir} 替代 ./

更多详情请参考 设计PR

参考 package.json 中的依赖项来生成声明文件

之前,TypeScript 可能经常抛出如下错误:

The inferred type of "X" cannot be named without a reference to "Y". This is likely not portable. A type annotation is necessary.

这通常是由于 TypeScript 的声明文件生成在从未在程序中显式导入的文件内容中发现自身。 如果路径最终变成相对路径,生成对这样的文件的导入可能存在风险。 然而,在 package.json 的依赖项(或 peerDependenciesoptionalDependencies)中具有明确依赖关系的代码库中,在某些解析模式下生成这样的导入应该是安全的。 因此,在 TypeScript 5.5 中,当出现这种情况时,我们更加宽松,许多此类错误应该消失。

更多详情请参考 PR

编辑器和监视模式可靠性改进

TypeScript 已经添加了一些新功能或修复了现有逻辑,使得 --watch 模式和 TypeScript 的编辑器集成感觉更加可靠。 这希望能够转化为更少的 TSServer 或编辑器重新启动。

正确刷新配置文件中的编辑器错误

TypeScript 可以为 tsconfig.json 文件生成错误; 然而,这些错误实际上是在加载项目时生成的,编辑器通常不会直接请求针对 tsconfig.json 文件的这些错误。 虽然这听起来像一个技术细节,但这意味着当在 tsconfig.json 中修复了所有错误时,TypeScript 不会发出新的空错误集,用户将继续看到过时的错误,除非他们重新加载编辑器。

TypeScript 5.5 现在有意发出一个事件来清除这些错误。 更多详情请参考 PR

更好地处理删除操作后紧接着的写操作

一些工具选择删除文件而不是覆盖它们,然后从头开始创建新文件。 例如,在运行 npm ci 时就是这种情况。

尽管这对于那些工具可能是高效的,但对于 TypeScript 的编辑器场景可能会有问题,在这种情况下,删除一个被监视的文件可能会使其及其所有传递依赖项被丢弃。 快速连续删除和创建文件可能导致 TypeScript 拆除整个项目,然后从头开始重新构建。

TypeScript 5.5 现在采用了更加细致的方法,保留已删除项目的部分内容,直到它捕捉到新的创建事件。 这应该使像 npm ci 这样的操作与 TypeScript 协同工作更加顺畅。

更多详情请参考 PR

符号链接在解析失败时会被跟踪

当 TypeScript 无法解析一个模块时,它仍然需要监视所有失败的查找路径,以防该模块在之后被添加。 之前,这种情况不会发生在符号链接的目录中,这在 monorepo 场景中可能导致可靠性问题,例如当一个项目中发生构建但另一个项目中未检测到时。 这一问题应在 TypeScript 5.5 中得到修复,这意味着你不需要那么频繁地重启编辑器。

更多详情请参考 PR

项目引用有助于自动导入

在项目引用设置中,自动导入不再需要至少一个对依赖项目的显式导入。 相反,自动导入补全应适用于你在 tsconfig.jsonreferences 字段中列出的任何内容。

更多详情请参考 PR

TypeScript 5.4

从最后一次赋值以后,在闭包中保留类型细化

TypeScript 通常可以根据您进行的检查来确定变量更具体的类型。 这个过程被称为类型细化。

function uppercaseStrings(x: string | number) {
  if (typeof x === 'string') {
    // TypeScript 知道 'x' 是 'string' 类型
    return x.toUpperCase();
  }
}

一个常见的痛点是被细化的类型不总会在闭包函数中保留。

function getUrls(url: string | URL, names: string[]) {
  if (typeof url === 'string') {
    url = new URL(url);
  }

  return names.map(name => {
    url.searchParams.set('name', name);
    //  ~~~~~~~~~~~~
    // error!
    // Property 'searchParams' does not exist on type 'string | URL'.

    return url.toString();
  });
}

在这里,TypeScript 决定在我们的回调函数中不“安全”地假设 url 实际上是一个 URL 对象,因为它在其他地方发生了变化; 然而,在这种情况下,箭头函数总是在对 url 的赋值之后创建的,并且它也是对 url 的最后一次赋值。

TypeScript 5.4 利用这一点使类型细化变得更加智能。 当在非提升的函数中使用参数和 let 变量时,类型检查器将寻找最后一次赋值点。 如果找到了这样的点,TypeScript 可以安全地从包含函数的外部进行类型细化。 这意味着上面的例子现在可以正常工作了。

请注意,如果变量在嵌套函数的任何地方被赋值,类型细化分析将不会生效。 这是因为无法确定该函数是否会在后续被调用。

function printValueLater(value: string | undefined) {
  if (value === undefined) {
    value = 'missing!';
  }

  setTimeout(() => {
    // Modifying 'value', even in a way that shouldn't affect
    // its type, will invalidate type refinements in closures.
    value = value;
  }, 500);

  setTimeout(() => {
    console.log(value.toUpperCase());
    //          ~~~~~
    // error! 'value' is possibly 'undefined'.
  }, 1000);
}

这将使许多典型的 JavaScript 代码更容易表达出来。 更多详情请参考PR

NoInfer 工具类型

当调用泛型函数时,TypeScript 能够从实际参数推断出类型参数的值。

function doSomething<T>(arg: T) {
  // ...
}

// We can explicitly say that 'T' should be 'string'.
doSomething<string>('hello!');

// We can also just let the type of 'T' get inferred.
doSomething('hello!');

然而,一个挑战是并不总能够清楚推断出“最佳”的类型是什么。 这可能导致 TypeScript 拒绝合理的调用,接受有问题的调用,或者在捕捉到 bug 时报告较差的错误消息。

例如,假设 createStreetLight 函数接收一系列颜色名,以及一个默认颜色名。

function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
  // ...
}

createStreetLight(['red', 'yellow', 'green'], 'red');

当我们传入的 defaultColor 不在 colors 数组里会发生什么? 在这个函数中,colors 被当成“事实来源”,并描述了可以传递给 defaultColor

// Oops! This undesirable, but is allowed!
createStreetLight(['red', 'yellow', 'green'], 'blue');

在这个调用中,类型推断决定 "blue""red""yellow""green" 一样有效。 因此,TypeScript 推断 C 的类型为 "red" | "yellow" | "green" | "blue"。 可以说推断结果让我们感到十分惊讶!

目前人们处理这个问题的一种方式是添加一个独立的类型参数,该参数受现有类型参数的限制。

function createStreetLight<C extends string, D extends C>(
  colors: C[],
  defaultColor?: D
) {}

createStreetLight(['red', 'yellow', 'green'], 'blue');
//                                            ~~~~~~
// error!
// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.

这种方法可以解决问题,但有点尴尬,因为在 createStreetLight 的签名中可能不会在其他地方使用 D。 虽然这种情况不算糟糕,但在签名中只使用一次类型参数通常是一种代码坏味道。

这就是为什么 TypeScript 5.4 引入了一个新的 NoInfer<T> 实用类型。 将一个类型包裹在 NoInfer<...> 中向 TypeScript 发出一个信号,告诉它不要深入匹配内部类型以寻找类型推断的候选项。

使用 NoInfer,我们可以将 createStreetLight 重写为以下形式:

function createStreetLight<C extends string>(
  colors: C[],
  defaultColor?: NoInfer<C>
) {
  // ...
}

createStreetLight(['red', 'yellow', 'green'], 'blue');
//                                            ~~~~~~
// error!
// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.

排除对 defaultColor 类型进行推断的探索意味着 "blue" 永远不会成为推断的候选项,类型检查器可以拒绝它。

具体实现请参考 PR,以及最初实现 PR,感谢Mateusz Burzyński

Object.groupByMap.groupBy

TypeScript 5.4 为 JavaScript 的新静态方法 Object.groupByMap.groupBy 添加了声明。

Object.groupBy 接受一个可迭代对象和一个函数,该函数确定每个元素应该被放置在哪个“组”中。 该函数需要为每个不同的分组生成一个“键”,而 Object.groupBy 使用该键来创建一个对象,其中每个键都映射到一个包含原始元素的数组。

因此:

const array = [0, 1, 2, 3, 4, 5];

const myObj = Object.groupBy(array, (num, index) => {
  return num % 2 === 0 ? 'even' : 'odd';
});

等同于:

const myObj = {
  even: [0, 2, 4],
  odd: [1, 3, 5],
};

Map.groupBy 类似,但生成的是一个 Map 而不是普通对象。 如果您需要 Map 提供的保证、处理期望 Map 的 API,或者需要使用任何类型的键进行分组(而不仅仅是可以作为 JavaScript 属性名的键),那么这可能更可取。

const myObj = Map.groupBy(array, (num, index) => {
  return num % 2 === 0 ? 'even' : 'odd';
});

就像之前一样,你可以用等效的方式创建 myObj

const myObj = new Map();

myObj.set('even', [0, 2, 4]);
myObj.set('odd', [1, 3, 5]);

注意在 Object.groupBy 的例子中,生成的对象使用了所有可选属性。

interface EvenOdds {
    even?: number[];
    odd?: number[];
}

const myObj: EvenOdds = Object.groupBy(...);

myObj.even;
//    ~~~~
// Error to access this under 'strictNullChecks'.

这是因为没法保证 groupBy 生成了所有的键。

注意这些方法仅在将 target 设置为 esnext,或者设置了相应的 lib 选项时才可用。 我们预计它们最终会在稳定的 es2024 目标下可用。

感谢Kevin GibbonsPR

支持在 --moduleResolution bundler--module preserve 时 使用 require()

TypeScript 有一个名为 bundlermoduleResolution 选项,旨在模拟现代打包工具确定导入路径所指向的文件的方式。 该选项的一个限制是它必须与 --module esnext 配对使用,这导致无法使用 import ... = require(...) 语法。

// previously errored
import myModule = require('module/path');

如果您计划只编写标准的 ECMAScript import,这可能看起来并不是很重要,但在使用具有条件导出的包时就会有所不同。

在 TypeScript 5.4 中,当将 module 设置为一个名为 preserve 的新选项时,可以使用 require()

--module preserve--moduleResolution bundler 之间,这两个选项更准确地模拟了像 Bun 等打包工具和运行时环境允许的操作以及它们如何执行模块查找。 实际上,在使用 --module preserve 时,--moduleResolution 选项将会隐式设置为 bundler(以及 --esModuleInterop--resolveJsonModule)。

{
  "compilerOptions": {
    "module": "preserve"
    // ^ also implies:
    // "moduleResolution": "bundler",
    // "esModuleInterop": true,
    // "resolveJsonModule": true,

    // ...
  }
}

--module preserve 下,ECMAScript 的 import 语句将始终按原样输出,而 import ... = require(...) 语句将被输出为 require() 调用(尽管实际上你可能不会使用 TypeScript 进行输出,因为你很可能会使用打包工具来处理你的代码)。 这一点不受包含文件的文件扩展名的影响。 因此,以下代码:

import * as foo from 'some-package/foo';
import bar = require('some-package/bar');

的输出结果会是这样:

import * as foo from 'some-package/foo';
var bar = require('some-package/bar');

这也意味着您选择的语法将指定条件导出的匹配方式。 因此,在上面的示例中,如果 some-packagepackage.json 如下所示:

{
  "name": "some-package",
  "version": "0.0.1",
  "exports": {
    "./foo": {
      "import": "./esm/foo-from-import.mjs",
      "require": "./cjs/foo-from-require.cjs"
    },
    "./bar": {
      "import": "./esm/bar-from-import.mjs",
      "require": "./cjs/bar-from-require.cjs"
    }
  }
}

TypeScript 会将路径解析为 [...]/some-package/esm/foo-from-import.mjs[...]/some-package/cjs/bar-from-require.cjs

更多详情请参考 PR

检查导入属性和断言

导入属性和断言现在会与全局的 ImportAttributes 类型进行检查。 这意味着运行时现在可以更准确地描述导入属性。

// In some global file.
interface ImportAttributes {
    type: "json";
}

// In some other module
import * as ns from "foo" with { type: "not-json" };
//                                     ~~~~~~~~~~
// error!
//
// Type '{ type: "not-json"; }' is not assignable to type 'ImportAttributes'.
//  Types of property 'type' are incompatible.
//    Type '"not-json"' is not assignable to type '"json"'.

感谢 Oleksandr TarasiukPR

快速修复:添加缺失参数

TypeScript 现在提供了一个快速修复选项,可以为被调用时传递了过多参数的函数添加一个新的参数。

x

x

当在多个现有函数之间传递一个新参数时,这将非常有用,而目前这样做可能会很麻烦。

感谢 Oleksandr TarasiukPR

子路径导入支持自动导入

在 Node.js 中,package.json 通过一个名为 imports 的字段支持一种称为“子路径导入”的功能。 这是一种将包内的路径重新映射到其他模块路径的方式。 在概念上,这与路径映射非常相似,某些模块打包工具和加载器支持该功能(TypeScript 通过一个称为 paths 的功能也支持该功能)。 唯一的区别是,子路径导入必须始终以 # 开头。

TypeScript 的自动导入功能以前不会考虑 imports 中的路径,这可能令人沮丧。 相反,用户可能需要在 tsconfig.json 中手动定义路径。 然而,由于 Emma Hamilton 的贡献,TypeScript 的自动导入现在支持子路径导入

即将到来的 TypeScript 5.0 弃用功能

TypeScript 5.0 弃用了以下选项和行为:

  • charset
  • target: ES3
  • importsNotUsedAsValues
  • noImplicitUseStrict
  • noStrictGenericChecks
  • keyofStringsOnly
  • suppressExcessPropertyErrors
  • suppressImplicitAnyIndexErrors
  • out
  • preserveValueImports
  • 工程引用中的 prepend
  • 隐式的系统特定 newLine

为了继续使用这些功能,使用 TypeScript 5.0 + 版本的开发人员必须指定一个名为 ignoreDeprecations 的新选项,其值为 "5.0"

然而,TypScript 5.4 将是这些功能继续正常工作的最后一个版本。 到了 TypeScript 5.5(可能是 2024 年 6 月),它们将变成严格的错误,使用它们的代码将需要进行迁移。

要获取更多信息,您可以在 GitHub 上查阅这个计划,其中包含了如何最佳地适应您的代码库的建议。

值得注意的行为改变

本节重点介绍一系列值得注意的变更,作为升级的一部分,应该予以认识和理解。 有时它会强调弃用、移除和新的限制。 它还可能包含功能性改进的错误修复,但这些修复也可能通过引入新的错误影响现有的构建。

lib.d.ts 变化

DOM 类型有变化

更准确的有条件类型约束

下面的 foo 函数不再允许第二个变量声明。

type IsArray<T> = T extends any[] ? true : false;

function foo<U extends object>(x: IsArray<U>) {
  let first: true = x; // Error
  let second: false = x; // Error, but previously wasn't
}

在之前的版本中,当 TypeScript 检查第二个初始化器时,它需要确定 IsArray<U> 是否可赋值给 false 类型的单元类型。 虽然 IsArray<U> 在任何明显的方式下都不兼容,但 TypeScript 也会考虑该类型的约束。 在形如 T extends Foo ? TrueBranch : FalseBranch 的条件类型中,其中 T 是泛型,类型系统会查看 T 的约束,在 T 本身的位置上进行替代,并决定选择 true 分支还是 false 分支。

但是,这种行为是不准确的,因为它过于急切。即使 T 的约束不能赋值给 Foo,也并不意味着它不会实例化为某个可赋值给 Foo 的类型。 因此,更正确的行为是在无法证明 T 永远不会或总是 extends Foo 的情况下,为条件类型的约束产生一个联合类型。

TypeScript 5.4 采用了这种更准确的行为。 在实践中,这意味着您可能会发现某些条件类型实例与它们的分支不再兼容。

您可以在此处阅读具体的更改内容。

更积极地减少类型变量与原始类型之间的交集

declare function intersect<T, U>(x: T, y: U): T & U;

function foo<T extends 'abc' | 'def'>(x: T, str: string, num: number) {
  // Was 'T & string', now is just 'T'
  let a = intersect(x, str);

  // Was 'T & number', now is just 'never'
  let b = intersect(x, num);

  // Was '(T & "abc") | (T & "def")', now is just 'T'
  let c = Math.random() < 0.5 ? intersect(x, 'abc') : intersect(x, 'def');
}

更多详情请参考 PR

改进了对带有插值的模板字符串的检查

TypeScript 现在更准确地检查字符串是否可赋值给模板字符串类型的占位符位置。

function a<T extends { id: string }>() {
  let x: `-${keyof T & string}`;

  // Used to error, now doesn't.
  x = '-id';
}

这种行为更加理想,但可能会导致使用条件类型等结构的代码出现问题,因为这些规则变化很容易引发观察到的错误。

更多详情请参考 PR

当类型导入与本地值冲突时报错

在之前的版本中,如果对 "Something" 的导入只涉及类型,TypeScript 会在 "isolatedModules" 下允许以下代码。

import { Something } from './some/path';

let Something = 123;

然而,对于单文件编译器来说,假设是否能够"安全"删除 import 并不可靠,即使代码在运行时肯定会失败。 在 TypeScript 5.4 中,这段代码将触发以下类似的错误:

Import 'Something' conflicts with local value, so must be declared with a type-only import when 'isolatedModules' is enabled.

修改方法或者给本地变量重命名,或者为导入语句添加 type 修饰符:

import type { Something } from './some/path';

// or

import { type Something } from './some/path';

更多详情请参考 PR

新的枚举可赋值性检查

在之前的版本中,当两个枚举具有相同的声明名称和枚举成员名称时,它们通常被认为是兼容的。 然而,当这些值是已知的时候,TypeScript 会默默地允许它们具有不同的值。

TypeScript 5.4 通过要求已知的值必须相同来加强这一限制。 这意味着当枚举的值已知时,它们必须具有相同的值。

namespace First {
  export enum SomeEnum {
    A = 0,
    B = 1,
  }
}

namespace Second {
  export enum SomeEnum {
    A = 0,
    B = 2,
  }
}

function foo(x: First.SomeEnum, y: Second.SomeEnum) {
  // Both used to be compatible - no longer the case,
  // TypeScript errors with something like:
  //
  //  Each declaration of 'SomeEnum.B' differs in its value, where '1' was expected but '2' was given.
  x = y;
  y = x;
}

此外,对于一个枚举成员没有静态已知值的情况,还有一些新的限制。 在这些情况下,另一个枚举成员必须至少是隐式数字类型(例如,它没有静态解析的初始化值),或者是显式数字类型(意味着 TypeScript 可以将值解析为某个数字类型)。 从实际角度来看,这意味着字符串枚举成员只能与具有相同值的其他字符串枚举兼容。

namespace First {
  export declare enum SomeEnum {
    A,
    B,
  }
}

namespace Second {
  export declare enum SomeEnum {
    A,
    B = 'some known string',
  }
}

function foo(x: First.SomeEnum, y: Second.SomeEnum) {
  // Both used to be compatible - no longer the case,
  // TypeScript errors with something like:
  //
  //  One value of 'SomeEnum.B' is the string '"some known string"', and the other is assumed to be an unknown numeric value.
  x = y;
  y = x;
}

更多详情请参考 PR

枚举成员名的限制

TypeScript 不再允许枚举成员名使用 Infinity-Infinity,或 NaN

// Errors on all of these:
//
//  An enum member cannot have a numeric name.
enum E {
  Infinity = 0,
  '-Infinity' = 1,
  NaN = 2,
}

更多详情请参考 PR

在具有 any 剩余元素的元组上,更好地保留映射类型

在之前的版本中,将带有 "any" 类型的映射类型应用于元组时,会创建一个 "any" 元素类型。 这是不可取的,并且现在已经修复了这个问题。

Promise.all(['', ...([] as any)]).then(result => {
  const head = result[0]; // 5.3: any, 5.4: string
  const tail = result.slice(1); // 5.3 any, 5.4: any[]
});

更多详情请参考 PRIssueIssue

代码生成变化

虽然这不是一个直接的破坏性变更,但开发人员可能会隐式地依赖于 TypeScript 生成的 JavaScript 或声明文件输出。以下是一些值得注意的变化。

TypeScript 5.3

导入属性(Import Attributes)

TypeScript 5.3 支持了最新的 import attributes 提案。

该特性的一个用例是为运行时提供期望的模块格式信息。

// We only want this to be interpreted as JSON,
// not a runnable/malicious JavaScript file with a `.json` extension.
import obj from "./something.json" with { type: "json" };

TypeScript 不会检查属性内容,因为它们是宿主环境相关的。 TypeScript 会原样保留它们,浏览器和运行时会处理它们。

// TypeScript is fine with this.
// But your browser? Probably not.
import * as foo from "./foo.js" with { type: "fluffy bunny" };

动态的 import() 调用也可以在第二个参数里使用该特性。

const obj = await import('./something.json', {
  with: { type: 'json' },
});

第二个参数的期望类型为 ImportCallOptions,默认只支持一个名为 with 的属性。

请注意,导入属性是之前提案“导入断言”的演进,该提案已在 TypeScript 4.5 中实现。 最明显的区别是使用with关键字而不是assert关键字。 但不太明显的区别是,现在运行时可以自由地使用属性来指导导入路径的解析和解释,而导入断言只能在加载模块后断言某些特性。

随着时间的推移,TypeScript 将逐渐弃用旧的导入断言语法,转而采用导入属性的提议语法。现有的使用assert的代码应该迁移到with关键字。而需要导入属性的新代码应该完全使用with关键字。

感谢 Oleksandr Tarasiuk 实现了这个功能! 也感谢 Wenlu Wang 实现了 import assertions!

稳定支持 import type 上的 resolution-mode

TypeScript 4.7 在 /// <reference types="..." /> 里支持了 resolution-mode 属性, 它用来控制一个描述符是使用 import 还是 require 语义来解析。

/// <reference types="pkg" resolution-mode="require" />

// or

/// <reference types="pkg" resolution-mode="import" />

在 type-only 导入上,导入断言也引入了相应的字段; 然而,它仅在 TypeScript 的夜间版本中得到支持 其原因是在精神上,导入断言并不打算指导模块解析。 因此,这个特性以实验性的方式仅在夜间版本中发布,以获得更多的反馈。

但是,导入属性(Import Attributes)可以指导解析,并且我们也已经看到了有意义的用例, TypeScript 5.3 在 import type 上支持了 resolution-mode

// Resolve `pkg` as if we were importing with a `require()`
import type { TypeFromRequire } from "pkg" with {
    "resolution-mode": "require"
};

// Resolve `pkg` as if we were importing with an `import`
import type { TypeFromImport } from "pkg" with {
    "resolution-mode": "import"
};

export interface MergedType extends TypeFromRequire, TypeFromImport {}

这些导入属性也可以用在 import() 类型上。

export type TypeFromRequire =
    import("pkg", { with: { "resolution-mode": "require" } }).TypeFromRequire;

export type TypeFromImport =
    import("pkg", { with: { "resolution-mode": "import" } }).TypeFromImport;

export interface MergedType extends TypeFromRequire, TypeFromImport {}

更多详情,请参考PR

在所有模块模式中支持 resolution-mode

此前,仅在 moduleResolutionnode16nodenext 时支持 resolution-mode。 为了使查找模块更容易,尤其针对类型,resolution-mode 现在可以在所有其它的 moduleResolution 选项下工作,例如 bundlernode10,甚至在 classic 下也不报错。

更多详情,请参考PR

switch (true) 类型细化

TypeScript 5.3 会针对 switch (true) 里的每一个 case 条件进行类型细化。

function f(x: unknown) {
  switch (true) {
    case typeof x === 'string':
      // 'x' is a 'string' here
      console.log(x.toUpperCase());
    // falls through...

    case Array.isArray(x):
      // 'x' is a 'string | any[]' here.
      console.log(x.length);
    // falls through...

    default:
    // 'x' is 'unknown' here.
    // ...
  }
}

感谢 Mateusz Burzyński 的贡献

类型细化与布尔值的比较

有时,您可能会发现自己在条件语句中直接与 truefalse 进行比较。 通常情况下,这些比较是不必要的,但您可能出于风格上的考虑或为了避免 JavaScript 中真值相关的某些问题而偏好这样做。 不过,之前 TypeScript 在进行类型细化时并不识别这样的形式。

TypeScript 5.3 在类型细化时可以理解这类表达式。

interface A {
  a: string;
}

interface B {
  b: string;
}

type MyType = A | B;

function isA(x: MyType): x is A {
  return 'a' in x;
}

function someFn(x: MyType) {
  if (isA(x) === true) {
    console.log(x.a); // works!
  }
}

感谢 Mateusz Burzyński 的 PR

利用 Symbol.hasInstance 来细化 instanceof

JavaScript 的一个稍微晦涩的特性是可以覆盖 instanceof 运算符的行为。 为此,instanceof 运算符右侧的值需要具有一个名为 Symbol.hasInstance 的特定方法。

class Weirdo {
  static [Symbol.hasInstance](testedValue) {
    // wait, what?
    return testedValue === undefined;
  }
}

// false
console.log(new Thing() instanceof Weirdo);

// true
console.log(undefined instanceof Weirdo);

为了更好地支持 instanceof 的行为,TypeScript 现在会检查是否存在 [Symbol.hasInstance] 方法且被定义为类型判定函数。 如果有的话,instanceof 运算符左侧的值会按照类型判定进行细化。

interface PointLike {
  x: number;
  y: number;
}

class Point implements PointLike {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }

  distanceFromOrigin() {
    return Math.sqrt(this.x ** 2 + this.y ** 2);
  }

  static [Symbol.hasInstance](val: unknown): val is PointLike {
    return (
      !!val &&
      typeof val === 'object' &&
      'x' in val &&
      'y' in val &&
      typeof val.x === 'number' &&
      typeof val.y === 'number'
    );
  }
}

function f(value: unknown) {
  if (value instanceof Point) {
    // Can access both of these - correct!
    value.x;
    value.y;

    // Can't access this - we have a 'PointLike',
    // but we don't *actually* have a 'Point'.
    value.distanceFromOrigin();
  }
}

能够看到例子中,Point 定义了自己的 [Symbol.hasInstance] 方法。 它实际上充当了对称为 PointLike 的单独类型的自定义类型保护。 在函数 f 中,我们能够使用 instanceofvalue 细化为 PointLike,但不能细化到 Point。 这意味着我们可以访问属性 xy,但无法访问 distanceFromOrigin 方法。

更多详情请参考PR

在实例字段上检查 super 属性访问

在 JavaScript 中,能够使用 super 关键字来访问基类中的声明。

class Base {
  someMethod() {
    console.log('Base method called!');
  }
}

class Derived extends Base {
  someMethod() {
    console.log('Derived method called!');
    super.someMethod();
  }
}

new Derived().someMethod();
// Prints:
//   Derived method called!
//   Base method called!

这与 this.someMethod() 是不同的,因为它可能调用的是重写的方法。 这是一个微妙的区别,而且通常情况下,如果一个声明从未被覆盖,这两者可以互换,使得区别更加微妙。

class Base {
  someMethod() {
    console.log('someMethod called!');
  }
}

class Derived extends Base {
  someOtherMethod() {
    // These act identically.
    this.someMethod();
    super.someMethod();
  }
}

new Derived().someOtherMethod();
// Prints:
//   someMethod called!
//   someMethod called!

将它们互换使用的问题在于,super 关键字仅适用于在原型上声明的成员,而不适用于实例属性。 这意味着,如果您编写了 super.someMethod(),但 someMethod 被定义为一个字段,那么您将会得到一个运行时错误!

class Base {
  someMethod = () => {
    console.log('someMethod called!');
  };
}

class Derived extends Base {
  someOtherMethod() {
    super.someMethod();
  }
}

new Derived().someOtherMethod();
//
// Doesn't work because 'super.someMethod' is 'undefined'.

TypeScript 5.3 现在更仔细地检查 super 属性访问/方法调用,以确定它们是否对应于类字段。 如果是这样,我们现在将会得到一个类型检查错误。

这个检查是由 Jack Works 开发!

可以交互的类型内嵌提示

TypeScript 的内嵌提示支持跳转到类型定义! 这便利在代码间跳转变得简单。

更多详情请参考PR

设置偏好 type 自动导入

之前,当 TypeScript 为类型自动生成导入语句时,它会根据配置添加 type 修饰符。 例如,当为 Person 生成自动导入语句时:

export let p: Person;

TypeScript 通常会这样生成 Person 导入:

import { Person } from './types';

export let p: Person;

如果设置了 verbatimModuleSyntax,它会添加 type 修饰符:

import { type Person } from './types';

export let p: Person;

然而,也许你的编辑器不支持这些选项;或者你偏好显式地使用 type 导入。

最近的一项改动,TypeScript 把它变成了针对编辑器的配置项。 在 Visual Studio Code 中,你可以在 "TypeScript › Preferences: Prefer Type Only Auto Imports" 启用该功能,或者在 JSON 配置文件中的 typescript.preferences.preferTypeOnlyAutoImports 设置。

优化:略过 JSDoc 解析

当通过 tsc 运行 TypeScript 时,编译器现在将避免解析 JSDoc。 这不仅减少了解析时间,还减少了存储注释以及垃圾回收所花费的内存使用量。 总体而言,您应该会看到编译速度稍微更快,并在 --watch 模式下获得更快的反馈。

具体改动在这

由于并非每个使用 TypeScript 的工具都需要存储 JSDoc(例如 typescript-eslint 和 Prettier),因此这种解析策略已作为 API 的一部分公开。 这使得这些工具能够获得与 TypeScript 编译器相同的内存和速度改进。 注释解析策略的新选项在 JSDocParsingMode 中进行了描述。 关于此拉取请求的更多信息,请参阅PR

通过比较非规范化的交叉类型进行优化

在 TypeScript 中,联合类型和交叉类型始终遵循特定的形式,其中交叉类型不能包含联合类型。 这意味着当我们在一个联合类型上创建一个交叉类型,例如 A & (B | C),该交叉类型将被规范化为 (A & B) | (A & C)。 然而,在某些情况下,类型系统会保留原始形式以供显示目的使用。

事实证明,原始形式可以用于一些巧妙的快速路径类型比较。

例如,假设我们有 SomeType & (Type1 | Type2 | ... | Type99999NINE),我们想要确定它是否可以赋值给 SomeType。 回想一下,我们实际上没有一个交叉类型作为源类型,而是一个联合类型,看起来像是 (SomeType & Type1) | (SomeType & Type2) | ... | (SomeType & Type99999NINE)。 当检查一个联合类型是否可以赋值给目标类型时,我们必须检查联合类型的每个成员是否可以赋值给目标类型,这可能非常慢。

在 TypeScript 5.3 中,我们查看了我们能够隐藏的原始交叉类型形式。 当我们比较这些类型时,我们会快速检查目标类型是否存在于源交叉类型的任何组成部分中。

更多详情请参考PR

合并 tsserverlibrary.jstypescript.js

TypeScript 本身包含两个库文件:tsserverlibrary.jstypescript.js。 在 tsserverlibrary.js 中有一些仅在其中可用的 API(如 ProjectService API),对某些导入者可能很有用。 尽管如此,这两个是不同的捆绑包,有很多重叠的部分,在包中重复了一些代码。 更重要的是,由于自动导入或肌肉记忆的原因,要始终一致地使用其中一个可能是具有挑战性的。 意外加载两个模块太容易了,而且代码可能在 API 的不同实例上无法正常工作。 即使它可以工作,加载第二个捆绑包会增加资源使用量。

基于此,我们决定合并这两个文件。 typescript.js 现在包含了以前在 tsserverlibrary.js 中的内容,而 tsserverlibrary.js 现在只是重新导出 typescript.js。 在这个合并前后,我们看到了以下包大小的减小:

BeforeAfterDiffDiff (percent)
Packed6.90 MiB5.48 MiB-1.42 MiB-20.61%
Unpacked38.74 MiB30.41 MiB-8.33 MiB-21.50%
BeforeAfterDiffDiff (percent)
lib/tsserverlibrary.d.ts570.95 KiB865.00 B-570.10 KiB-99.85%
lib/tsserverlibrary.js8.57 MiB1012.00 B-8.57 MiB-99.99%
lib/typescript.d.ts396.27 KiB570.95 KiB+174.68 KiB+44.08%
lib/typescript.js7.95 MiB8.57 MiB+637.53 KiB+7.84%

换句话说,这意味着包大小减小了超过 20.5%。

更多详情请参考 PR

TypeScript 5.2

using 声明与显式资源管理

TypeScript 5.2 支持了 ECMAScript 即将引入的新功能 显式资源管理。 让我们探索一下引入该功能的一些动机,并理解这个功能给我们带来了什么。

在创建对象之后需要进行某种形式的“清理”是很常见的。例如,您可能需要关闭网络连接,删除临时文件,或者只是释放一些内存。 让我们来想象一个函数,它创建一个临时文件,对它进行多种操作的读写,然后关闭并删除它。

import * as fs from 'fs';

export function doSomeWork() {
  const path = '.some_temp_file';
  const file = fs.openSync(path, 'w+');

  // use file...

  // Close the file and delete it.
  fs.closeSync(file);
  fs.unlinkSync(path);
}

这看起来不错,但如果需要提前退出会发生什么?

export function doSomeWork() {
  const path = '.some_temp_file';
  const file = fs.openSync(path, 'w+');

  // use file...
  if (someCondition()) {
    // do some more work...

    // Close the file and delete it.
    fs.closeSync(file);
    fs.unlinkSync(path);
    return;
  }

  // Close the file and delete it.
  fs.closeSync(file);
  fs.unlinkSync(path);
}

我们可以看到存在重复的容易忘记的清理代码。 同时无法保证在代码抛出异常时,关闭和删除文件会被执行。 解决办法是用 try/finally 语句包裹整段代码。

export function doSomeWork() {
  const path = '.some_temp_file';
  const file = fs.openSync(path, 'w+');

  try {
    // use file...

    if (someCondition()) {
      // do some more work...
      return;
    }
  } finally {
    // Close the file and delete it.
    fs.closeSync(file);
    fs.unlinkSync(path);
  }
}

虽说这样写更加健壮,但是也为我们的代码增加了一些“噪音”。 如果我们在 finally 块中开始添加更多的清理逻辑,还可能遇到其他的自食其果的问题。 例如,异常可能会阻止其他资源的释放。 这些就是显式资源管理想要解决的问题。 该提案的关键思想是将资源释放(我们试图处理的清理工作)作为 JavaScript 中的一等概念来支持。

首先,增加了一个新的 symbol 名字为 Symbol.dispose,然后可以定义包含 Symbol.dispose 方法的对象。 为了方便,TypeScript 为此定义了一个新的全局类型 Disposable

class TempFile implements Disposable {
  #path: string;
  #handle: number;

  constructor(path: string) {
    this.#path = path;
    this.#handle = fs.openSync(path, 'w+');
  }

  // other methods

  [Symbol.dispose]() {
    // Close the file and delete it.
    fs.closeSync(this.#handle);
    fs.unlinkSync(this.#path);
  }
}

之后可以调用这些方法

export function doSomeWork() {
  const file = new TempFile('.some_temp_file');

  try {
    // ...
  } finally {
    file[Symbol.dispose]();
  }
}

将清理逻辑移动到 TempFile 本身没有带来多大的价值;仅仅是将清理的代码从 finally 提取到方法而已,你总是可以这样做。 但如果该方法有一个众所周知的名字那么 JavaScript 就可以基于此构造其它功能。

这将引出该功能的第一个亮点:using 声明! using 是一个新的关键字,支持声明新的不可变绑定,像 const 一样。 不同点是 using 声明的变量在即将离开其作用域时,它的 Symbol.dispose 方法会被调用!

因此,我们可以这样编写代码:

export function doSomeWork() {
    using file = new TempFile(".some_temp_file");

    // use file...

    if (someCondition()) {
        // do some more work...
        return;
    }
}

看一下 - 没有 try / finally 代码块!至少,我们没有见到。 从功能上讲,这些正是 using 声明要帮我们做的事,但我们不必自己处理它。

你可能熟悉 C# 中的 using, Python 中的 with,Java 中的 try-with-resource 声明。 这些与 JavaScript 中的 using 关键字是相似的,都提供了一种明确的方式来“清理”对象,在它们即将离开作用域时。

using 声明在其所在的作用域的最后才执行清理工作,或在“提前返回”(如 return 语句或 throw 错误)之前执行清理工作。 释放的顺序是先入后出,像栈一样。

function loggy(id: string): Disposable {
    console.log(`Creating ${id}`);

    return {
        [Symbol.dispose]() {
            console.log(`Disposing ${id}`);
        }
    }
}

function func() {
    using a = loggy("a");
    using b = loggy("b");
    {
        using c = loggy("c");
        using d = loggy("d");
    }
    using e = loggy("e");
    return;

    // Unreachable.
    // Never created, never disposed.
    using f = loggy("f");
}

func();
// Creating a
// Creating b
// Creating c
// Creating d
// Disposing d
// Disposing c
// Creating e
// Disposing e
// Disposing b
// Disposing a

using 声明对异常具有适应性;如果抛出了一个错误,那么在资源释放后会重新抛出错误。 另一方面,一个函数体可能正常执行,但是 Symbol.dispose 可能抛出异常。 这种情况下,异常会被重新抛出。

但如果释放之前的逻辑以及释放时的逻辑都抛出了异常会发生什么? 为处理这类情况引入了一个新的类型 SuppressedError,它是 Error 类型的子类型。 SuppressedError 类型的 suppressed 属性保存了上一个错误,同时 error 属性保存了最后抛出的错误。

class ErrorA extends Error {
    name = "ErrorA";
}
class ErrorB extends Error {
    name = "ErrorB";
}

function throwy(id: string) {
    return {
        [Symbol.dispose]() {
            throw new ErrorA(`Error from ${id}`);
        }
    };
}

function func() {
    using a = throwy("a");
    throw new ErrorB("oops!")
}

try {
    func();
}
catch (e: any) {
    console.log(e.name); // SuppressedError
    console.log(e.message); // An error was suppressed during disposal.

    console.log(e.error.name); // ErrorA
    console.log(e.error.message); // Error from a

    console.log(e.suppressed.name); // ErrorB
    console.log(e.suppressed.message); // oops!
}

你可能已经注意到了,在这些例子中使用的都是同步方法。 然而,很多资源释放的场景涉及到异步操作,我们需要等待它们完成才能进行后续的操作。

这就是为什么现在还有一个新的 Symbol.asyncDispose,它带来了另一个亮点 - await using 声明。 它与 using 声明相似,但关键是它查找需要 await 的资源。 它使用名为 Symbol.asyncDispose 的方法,尽管它们也可以操作在任何具有 Symbol.dispose 的对象上操作。 为了方便,TypeScript 引入了全局类型 AsyncDisposable 用来表示拥有异步 dispose 方法的对象。

async function doWork() {
    // Do fake work for half a second.
    await new Promise(resolve => setTimeout(resolve, 500));
}

function loggy(id: string): AsyncDisposable {
    console.log(`Constructing ${id}`);
    return {
        async [Symbol.asyncDispose]() {
            console.log(`Disposing (async) ${id}`);
            await doWork();
        },
    }
}

async function func() {
    await using a = loggy("a");
    await using b = loggy("b");
    {
        await using c = loggy("c");
        await using d = loggy("d");
    }
    await using e = loggy("e");
    return;

    // Unreachable.
    // Never created, never disposed.
    await using f = loggy("f");
}

func();
// Constructing a
// Constructing b
// Constructing c
// Constructing d
// Disposing (async) d
// Disposing (async) c
// Constructing e
// Disposing (async) e
// Disposing (async) b
// Disposing (async) a

如果你期望其他人能够一致地执行清理逻辑,通过使用 DisposableAsyncDisposable 来定义类型可以使你的代码更易于使用。 实际上,存在许多现有的类型,它们拥有 dispose()close() 方法。 例如,Visual Studio Code APIs 定义了 自己的 Disposable 接口。 在浏览器和诸如 Node.js、Deno 和 Bun 等运行时中,API 也可以选择对已经具有清理方法(如文件句柄、连接等)的对象使用 Symbol.disposeSymbol.asyncDispose

现在也许对于库来说这听起来很不错,但对于你的场景来说可能有些过于复杂。如果你需要进行大量的临时清理,创建一个新类型可能会引入过度抽象和关于最佳实践的问题。 例如,再次以我们的 TempFile 示例为例。

class TempFile implements Disposable {
    #path: string;
    #handle: number;

    constructor(path: string) {
        this.#path = path;
        this.#handle = fs.openSync(path, "w+");
    }

    // other methods

    [Symbol.dispose]() {
        // Close the file and delete it.
        fs.closeSync(this.#handle);
        fs.unlinkSync(this.#path);
    }
}

export function doSomeWork() {
    using file = new TempFile(".some_temp_file");

    // use file...

    if (someCondition()) {
        // do some more work...
        return;
    }
}

我们只是想记住调用两个函数,但这是最好的写法吗? 我们应该在构造函数中调用 openSync,创建一个 open() 方法,还是自己传递句柄? 我们是否应该为每个需要执行的操作公开一个方法,还是只将属性公开?

这就引出了这个特性的最后亮点:DisposableStackAsyncDisposableStack。 这些对象非常适用于一次性的清理工作,以及任意数量的清理工作。 DisposableStack 是一个对象,它具有多个方法用于跟踪 Disposable 对象,并且可以接受函数来执行任意的清理工作。 我们还可以将它们分配给 using 变量,因为它们也是 Disposable!所以下面是我们可以编写原始示例的方式。

function doSomeWork() {
    const path = ".some_temp_file";
    const file = fs.openSync(path, "w+");

    using cleanup = new DisposableStack();
    cleanup.defer(() => {
        fs.closeSync(file);
        fs.unlinkSync(path);
    });

    // use file...

    if (someCondition()) {
        // do some more work...
        return;
    }

    // ...
}

在这里,defer() 方法只需要一个回调函数,该回调函数将在 cleanup 释放后运行。 通常,在创建资源后应立即调用 defer(以及其他 DisposableStack 方法,如 useadopt)。 顾名思义,DisposableStack 以类似堆栈的方式处理它所跟踪的所有内容,按照先进后出的顺序进行处理,因此在创建值后立即进行 defer 处理有助于避免奇怪的依赖问题。 AsyncDisposableStack 的工作原理类似,但可以跟踪异步函数和 AsyncDisposable,并且本身也是 AsyncDisposable

在许多方面,defer 方法与 Go、Swift、Zig、Odin 等语言中的 defer 关键字类似,因此其使用约定应该相似。

由于这个特性非常新,大多数运行时环境不会原生支持它。要使用它,您需要为以下内容提供运行时的 polyfills:

  • Symbol.dispose
  • Symbol.asyncDispose
  • DisposableStack
  • AsyncDisposableStack
  • SuppressedError

然而,如果您只对使用 usingawait using 感兴趣,您只需要为内置的 symbol 提供 polyfill,通常以下简单的方法可适用于大多数情况:

Symbol.dispose ??= Symbol('Symbol.dispose');
Symbol.asyncDispose ??= Symbol('Symbol.asyncDispose');

你还需要将编译 target 设置为 es2022 或以下,配置 lib"esnext""esnext.disposable"

{
    "compilerOptions": {
        "target": "es2022",
        "lib": ["es2022", "esnext.disposable", "dom"]
    }
}

更多详情请参考PR

Decorator Metadata

TypeScript 5.2 实现了 ECMAScript 即将引入的新功能 Decorator Metadata

这个功能的关键思想是使装饰器能够轻松地在它们所使用或嵌套的任何类上创建和使用元数据。

在任意的装饰器函数上,现在可以访问上下文对象的 metadata 属性。 metadata 属性是一个普通的对象。 由于 JavaScript 允许我们对其任意添加属性,它可以被用作可由每个装饰器更新的字典。 或者,由于每个 metadata 对象对于每个被装饰的部分来讲是等同的,它可以被用作 Map 的键。 当类的装饰器运行时,这个对象可以通过 Symbol.metadata 访问。

interface Context {
  name: string;
  metadata: Record;
}

function setMetadata(_target: any, context: Context) {
  context.metadata[context.name] = true;
}

class SomeClass {
  @setMetadata
  foo = 123;

  @setMetadata
  accessor bar = 'hello!';

  @setMetadata
  baz() {}
}

const ourMetadata = SomeClass[Symbol.metadata];

console.log(JSON.stringify(ourMetadata));
// { "bar": true, "baz": true, "foo": true }

它可以被应用在不同的场景中。 Metadata 信息可以附加在调试、序列化或者依赖注入的场景中。 由于每个被装饰的类都会生成 metadata 对象,框架可以选择用它们做为 key 来访问 MapWeakMap,或者跟踪它的属性。

例如,我们想通过装饰器来跟踪哪些属性和存取器是可以通过 Json.stringify 序列化的:

import { serialize, jsonify } from './serializer';

class Person {
  firstName: string;
  lastName: string;

  @serialize
  age: number;

  @serialize
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }

  toJSON() {
    return jsonify(this);
  }

  constructor(firstName: string, lastName: string, age: number) {
    // ...
  }
}

此处的意图是,只有 agefullName 可以被序列化,因为它们应用了 @serialize 装饰器。 我们定义了 toJSON 方法来做这件事,但它只是调用了 jsonfy,它会使用 @serialize 创建的 metadata

下面是 ./serialize.ts 可能的定义:

const serializables = Symbol();

type Context =
  | ClassAccessorDecoratorContext
  | ClassGetterDecoratorContext
  | ClassFieldDecoratorContext;

export function serialize(_target: any, context: Context): void {
  if (context.static || context.private) {
    throw new Error('Can only serialize public instance members.');
  }
  if (typeof context.name === 'symbol') {
    throw new Error('Cannot serialize symbol-named properties.');
  }

  const propNames = ((context.metadata[serializables] as
    | string[]
    | undefined) ??= []);
  propNames.push(context.name);
}

export function jsonify(instance: object): string {
  const metadata = instance.constructor[Symbol.metadata];
  const propNames = metadata?.[serializables] as string[] | undefined;
  if (!propNames) {
    throw new Error('No members marked with @serialize.');
  }

  const pairStrings = propNames.map(key => {
    const strKey = JSON.stringify(key);
    const strValue = JSON.stringify((instance as any)[key]);
    return `${strKey}: ${strValue}`;
  });

  return `{ ${pairStrings.join(', ')} }`;
}

该方法有一个局部 symbol 名字为 serializables 用于保存和获取使用 @serializable 标记的属性。 当每次调用 @serializable 时,它都会在 metadata 上保存这些属性名。 当 jsonfy 被调用时,从 metadata 上获取属性列表,之后从实例上获取实际值,最后序列化名和值。

使用 symbol 意味着该数据可以被他人访问。 另一选择是使用 WeakMap 并用该 metadata 对象做为键。 这样可以保持数据的私密性,并且在这种情况下使用更少的类型断言,但其他方面类似。

const serializables = new WeakMap();

type Context =
  | ClassAccessorDecoratorContext
  | ClassGetterDecoratorContext
  | ClassFieldDecoratorContext;

export function serialize(_target: any, context: Context): void {
  if (context.static || context.private) {
    throw new Error('Can only serialize public instance members.');
  }
  if (typeof context.name !== 'string') {
    throw new Error('Can only serialize string properties.');
  }

  let propNames = serializables.get(context.metadata);
  if (propNames === undefined) {
    serializables.set(context.metadata, (propNames = []));
  }
  propNames.push(context.name);
}

export function jsonify(instance: object): string {
  const metadata = instance.constructor[Symbol.metadata];
  const propNames = metadata && serializables.get(metadata);
  if (!propNames) {
    throw new Error('No members marked with @serialize.');
  }
  const pairStrings = propNames.map(key => {
    const strKey = JSON.stringify(key);
    const strValue = JSON.stringify((instance as any)[key]);
    return `${strKey}: ${strValue}`;
  });

  return `{ ${pairStrings.join(', ')} }`;
}

注意,这里的实现没有考虑子类和继承。 留给读者作为练习。

由于该功能比较新,大多数运行时都没实现它。 如果想要使用,则需要使用 Symbol.metadatapolyfill。 例如像下面这样就可以适用大部分场景:

Symbol.metadata ??= Symbol('Symbol.metadata');

你还需要将编译 target 设为 es2022 或以下,配置 lib"esnext" 或者 "esnext.decorators"

{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022", "esnext.decorators", "dom"]
  }
}

感谢 Oleksandr Tarasiuk贡献

命名的和匿名的元组元素

元组类型已经支持了为每个元素定义可选的标签和命名。

type Pair = [first: T, second: T];

这些标签不改变功能 - 它们只是用于增强可读性和工具支持。

然而,TypeScript 之前有个限制是不允许混用有标签和无标签的元素。 换句话说,要么所有元素都没有标签,要么所有元素都有标签。

// ✅ fine - no labels
type Pair1 = [T, T];

// ✅ fine - all fully labeled
type Pair2 = [first: T, second: T];

// ❌ previously an error
type Pair3 = [first: T, T];
//                         ~
// Tuple members must all have names
// or all not have names.

如果是剩余元素就比较烦人了,我们必须要添加标签 rest 或者 tail

// ❌ previously an error
type TwoOrMore_A = [first: T, second: T, ...T[]];
//                                          ~~~~~~
// Tuple members must all have names
// or all not have names.

// ✅
type TwoOrMore_B = [first: T, second: T, rest: ...T[]];

这也意味着这个限制必须在类型系统内部进行强制执行,这意味着 TypeScript 将失去标签。

type HasLabels = [a: string, b: string];
type HasNoLabels = [number, number];
type Merged = [...HasNoLabels, ...HasLabels];
//   ^ [number, number, string, string]
//
//     'a' and 'b' were lost in 'Merged'

在 TypeScript 5.2 中,对元组标签的全有或全无限制已经被取消。 而且现在可以在展开的元组中保留标签。

感谢 Josh GoldbergMateusz Burzyński 的贡献。

更容易地使用联合数组上的方法

在之前版本的 TypeScript 中,在联合数组上调用方法可能很痛苦。

declare let array: string[] | number[];

array.filter(x => !!x);
//    ~~~~~~ error!
// This expression is not callable.
//   Each member of the union type '...' has signatures,
//   but none of those signatures are compatible
//   with each other.

此例中,TypeScript 会检查是否每个版本的 filter 都与 string[]number[] 兼容。 在没有一个连贯的策略的情况下,TypeScript 会束手无策地说:“我无法使其工作”。

在 TypeScript 5.2 里,在放弃之前,联合数组会被特殊对待。 使用每个元素类型构造一个新数组,然后在其上调用方法。

对于上例来说,string[] | number[] 被转换为 (string | number)[](或者是 Array<string | number>),然后在该类型上调用 filter。 有一个注意事项,filter 会产生 Array<string | number> 而不是 string[] | number[]; 但对于新产生的值,出现“出错”的风险较小。

这意味着在以前不能使用的情况下,许多方法如 filterfindsomeeveryreduce 都可以在数组的联合类型上调用。

更多详情请参考PR

拷贝的数组方法

TypeScript 5.2 支持了 ECMAScript 提案 Change Array by Copy

JavaScript 中的数组有很多有用的方法如 sort()splice(),以及 reverse(),这些方法在数组中原地修改元素。 通常,我们想创建一个新数组,还想影响原来的数组。 为达到此目的,你可以使用 slice() 或者展开数组(例如 [...myArray])获取一份拷贝,然后再执行操作。 例如,你可以用 myArray.slice().reverse() 来获取反转的数组的拷贝。

还有一个典型的例子 - 创建一份拷贝,但是修改其中的一个元素。 有许多方法可以实现这一点,但最明显的方法要么是由多个语句组成的...

const copy = myArray.slice();
copy[someIndex] = updatedValue;
doSomething(copy);

要么意图不明显...

doSomething(
  myArray.map((value, index) => (index === someIndex ? updatedValue : value))
);

所有这些对于如此常见的操作来说都很繁琐。 这就是为什么 JavaScript 现在有了 4 个新的方法,执行相同的操作,但不影响原始数据:toSortedtoSplicedtoReversedwith。 前三个方法执行与它们的变异版本相同的操作,但返回一个新的数组。 with 也返回一个新的数组,但其中一个元素被更新(如上所述)。

修改拷贝
myArray.reverse()myArray.toReversed()
myArray.sort((a, b) => ...)myArray.toSorted((a, b) => ...)
myArray.splice(start, deleteCount, ...items)myArray.toSpliced(start, deleteCount, ...items)
myArray[index] = updatedValuemyArray.with(index, updatedValue)

请注意,复制方法始终创建一个新的数组,而修改操作则不一致。

这些方法不仅存在于普通数组上 - 它们还存在于 TypedArray 上,例如 Int32ArrayUint8Array,等。

感谢 Carter SnookPR

symbol 用于 WeakMapWeakSet 的键

现在可以将 symbol 用于 WeakMapWeakSet 的键,它也是 ECMAScript 的新功能

const myWeakMap = new WeakMap();

const key = Symbol();
const someObject = {
  /*...*/
};

// Works! ✅
myWeakMap.set(key, someObject);
myWeakMap.has(key);

这个更新是由 Leo Elmecker-Plakolm 代表 Bloomberg 提供的。我们想向他们表示感谢!

类型导入路径里使用 TypeScript 实现文件扩展名

TypeScript 支持在类型导入路径里使用声明文件扩展名和实现文件扩展名,不论是否启用了 allowImportingTsExtensions

也意味着你现在可以编写 import type 语句并使用 .ts, .mts, .cts 以及 .tsx 文件扩展。

import type { JustAType } from './justTypes.ts';

export function f(param: JustAType) {
  // ...
}

这也意味着,import() 类型(用在 TypeScript 和 JavaScript 的 JSDoc 中) 也可以使用这些扩展名。

/**
 * @param {import("./justTypes.ts").JustAType} param
 */
export function f(param) {
  // ...
}

更多详情请查看 PR

对象成员的逗号补全

在给对象添加新属性时很容易忘记添加逗号。 在之前,如果你忘了写逗号并且请求自动补全,TypeScript 会给出差的不相关的补全结果。

TypeScript 5.2 现在在您缺少逗号时会优雅地提供对象成员的自动补全。 但为了避免语法错误的出现,它还会自动插入缺失的逗号。

更多详情请查看 PR

内联变量重构

TypeScript 5.2 现在具有一种重构方法,可以将变量的内容内联到所有使用位置。

使用“内联变量”重构将消除变量并将所有变量的使用替换为其初始化值。 请注意,这可能会导致初始化程序的副作用在不同的时间运行,并且运行的次数与变量的使用次数相同。

更多详情请查看 PR

可点击的内嵌参数提示

内嵌提示可以让我们一目了然地获取信息,即使它在我们的代码中不存在 —— 比如参数名称、推断类型等等。 在 TypeScript 5.2 中,我们开始使得与内嵌提示进行交互成为可能。 例如,在 Visual Studio Code Insiders 中,您现在可以点击内联提示以跳转到参数的定义处。

更多详情请查看 PR

优化进行中的类型兼容性检查

由于 TypeScript 采用的是结构化的类型系统,通常需要比较类型成员; 然而,递归类型会造成一些问题。例如:

interface A {
  value: A;
  other: string;
}

interface B {
  value: B;
  other: number;
}

在检查 A 是否与 B 类型兼容时,TypeScript 会检查 ABvalue 的类型是否兼容。 此时,类型系统需要停止进一步检查并继续检查其他成员。 为此,类型系统必须跟踪两个类型是否已经相关联。

此前,TypeScript 已经保存了配对类型的栈,并迭代检查类型是否已经关联。 当这个堆栈很浅时,这不是一个问题;但当堆栈不是很浅时,那就是个问题了。

在 TypeScript 5.2 中,一个简单的 Set 就能跟踪这些信息。 在使用了 drizzle 库的测试报告中,这项改动减少了超过 33% 的时间花费!

Benchmark 1: old
  Time (mean ± σ):      3.115 s ±  0.067 s    [User: 4.403 s, System: 0.124 s]
  Range (min … max):    3.018 s …  3.196 s    10 runs

Benchmark 2: new
  Time (mean ± σ):      2.072 s ±  0.050 s    [User: 3.355 s, System: 0.135 s]
  Range (min … max):    1.985 s …  2.150 s    10 runs

Summary
  'new' ran
    1.50 ± 0.05 times faster than 'old'

更多详情请查看 PR

TypeScript 5.1

更易用的隐式返回 undefined 的函数

JavaScript 中,如果一个函数运行结束时没有遇到 return 语句,它会返回 undefined 值。

function foo() {
  // no return
}

// x = undefined
let x = foo();

然而,在之前版本的 TypeScript 中,只有返回值类型为 voidany 的函数可以不带 return 语句。 这意味着,就算明知函数返回 undefined,你也必须包含 return 语句。

//  fine - we inferred that 'f1' returns 'void'
function f1() {
  // no returns
}

//  fine - 'void' doesn't need a return statement
function f2(): void {
  // no returns
}

//  fine - 'any' doesn't need a return statement
function f3(): any {
  // no returns
}

//  error!
// A function whose declared type is neither 'void' nor 'any' must return a value.
function f4(): undefined {
  // no returns
}

如果某些 API 期望函数返回 undefined 值,这可能会让人感到痛苦 —— 你需要至少有一个显式的返回 undefined 语句,或者一个带有显式注释的 return 语句。

declare function takesFunction(f: () => undefined): undefined;

//  error!
// Argument of type '() => void' is not assignable to parameter of type '() => undefined'.
takesFunction(() => {
  // no returns
});

//  error!
// A function whose declared type is neither 'void' nor 'any' must return a value.
takesFunction((): undefined => {
  // no returns
});

//  error!
// Argument of type '() => void' is not assignable to parameter of type '() => undefined'.
takesFunction(() => {
  return;
});

//  works
takesFunction(() => {
  return undefined;
});

//  works
takesFunction((): undefined => {
  return;
});

这种行为非常令人沮丧和困惑,尤其是在调用自己无法控制的函数时。 理解推断 voidundefined 之间的相互作用,以及一个返回 undefined 的函数是否需要 return 语句等等,似乎会分散注意力。

首先,TypeScript 5.1 允许返回 undefined 的函数不包含返回语句。

//  Works in TypeScript 5.1!
function f4(): undefined {
  // no returns
}

//  Works in TypeScript 5.1!
takesFunction((): undefined => {
  // no returns
});

其次,如果一个函数没有返回表达式,并且被传递给期望返回 undefined 值的函数的地方,TypeScript 会推断该函数的返回类型为 undefined

//  Works in TypeScript 5.1!
takesFunction(function f() {
  //                 ^ return type is undefined
  // no returns
});

//  Works in TypeScript 5.1!
takesFunction(function f() {
  //                 ^ return type is undefined

  return;
});

为了解决另一个类似的痛点,在 TypeScript 的 --noImplicitReturns 选项下,只返回 undefined 的函数现在有了类似于 void 的例外情况,在这种情况下,并不是每个代码路径都必须以显式的返回语句结束。

//  Works in TypeScript 5.1 under '--noImplicitReturns'!
function f(): undefined {
  if (Math.random()) {
    // do some stuff...
    return;
  }
}

更多详情请参考 IssuePR

不相关的存取器类型

TypeScript 4.3 支持将成对的 getset 定义为不同的类型。

interface Serializer {
  set value(v: string | number | boolean);
  get value(): string;
}

declare let box: Serializer;

// Allows writing a 'boolean'
box.value = true;

// Comes out as a 'string'
console.log(box.value.toUpperCase());

最初,我们要求 get 的类型是 set 类型的子类型。这意味着:

box.value = box.value;

永远是合法的。

然而,大量现存的和提议的 API 带有毫无关联的 getset 类型。 例如,考虑一个常见的情况 - DOM 中的 style 属性和 CSSStyleRule API。 每条样式规则都有一个 style 属性,它是一个 CSSStyleDeclaration; 然而,如果你尝试给该属性写值,它仅支持字符串。

TypeScript 5.1 现在允许为 getset 访问器属性指定完全不相关的类型,前提是它们具有显式的类型注解。 虽然这个版本的 TypeScript 还没有改变这些内置接口的类型,但 CSSStyleRule 现在可以按以下方式定义:

interface CSSStyleRule {
  // ...

  /** Always reads as a `CSSStyleDeclaration` */
  get style(): CSSStyleDeclaration;

  /** Can only write a `string` here. */
  set style(newValue: string);

  // ...
}

这也允许其他模式,比如要求 set 访问器只接受“有效”的数据,但指定 get 访问器可以返回 undefined,如果某些基础状态还没有被初始化。

class SafeBox {
  #value: string | undefined;

  // Only accepts strings!
  set value(newValue: string) {}

  // Must check for 'undefined'!
  get value(): string | undefined {
    return this.#value;
  }
}

实际上,这与在 --exactOptionalProperties 选项下可选属性的检查方式类似。

更多详情请参考 PR

解耦 JSX 元素和 JSX 标签类型之间的类型检查

TypeScript 在 JSX 方面的一个痛点是对每个 JSX 元素标签的类型要求。 这个 TypeScript 版本使得 JSX 库更准确地描述了 JSX 组件可以返回的内容。 对于许多人来说,这具体意味着可以在 React 中使用异步服务器组件

做为背景知识,JSX 元素是下列其一:

// A self-closing JSX tag
<Foo />

// A regular element with an opening/closing tag
<Bar></Bar>

在类型检查 <Foo /><Bar></Bar> 时,TypeScript 总是查找名为 JSX 的命名空间,并且获取名为 Element 的类型。 换句话说,它查找 JSX.Element

但是为了检查 FooBar 是否是有效的标签名,TypeScript 大致上只需获取由 FooBar 返回或构造的类型,并检查其与 JSX.Element(或另一种叫做 JSX.ElementClass 的类型,如果该类型可构造)的兼容性。

这里的限制意味着如果组件返回或 “render” 比 JSX.Element 更宽泛的类型,则无法使用组件。 例如,一个 JSX 库可能会允许组件返回 strings 或 Promises。

作为一个更具体的例子,未来版本的 React 已经提出了对返回 Promise 的组件的有限支持,但是现有版本的 TypeScript 无法表达这一点,除非有人大幅放宽 JSX.Element 类型。

import * as React from 'react';

async function Foo() {
  return <div></div>;
}

let element = <Foo />;
//             ~~~
// 'Foo' cannot be used as a JSX component.
//   Its return type 'Promise<Element>' is not a valid JSX element.

为了给 library 提供一种表达这种情况的方法,TypeScript 5.1 现在查找一个名为 JSX.ElementType 的类型。ElementType 精确地指定了在 JSX 元素中作为标签使用的内容。 因此现在可以像如下这样定义:

namespace JSX {
    export type ElementType =
        // All the valid lowercase tags
        keyof IntrinsicAttributes
        // Function components
        (props: any) => Element
        // Class components
        new (props: any) => ElementClass;

    export interface IntrinsictAttributes extends /*...*/ {}
    export type Element = /*...*/;
    export type ClassElement = /*...*/;
}

感谢 Sebastian SilbermannPR

带有命名空间的 JSX 属性

TypeScript 支持在 JSX 里使用带有命名空间的属性。

import * as React from "react";

// Both of these are equivalent:
const x = <Foo a:b="hello" />;
const y = <Foo a : b="hello" />;

interface FooProps {
    "a:b": string;
}

function Foo(props: FooProps) {
    return <div>{props["a:b"]}</div>;
}

当名字的第一段是小写名称时,在 JSX.IntrinsicAttributes 上查找带命名空间的标记名是类似的。

// In some library's code or in an augmentation of that library:
namespace JSX {
  interface IntrinsicElements {
    ['a:b']: { prop: string };
  }
}

// In our code:
let x = <a:b prop="hello!" />;

感谢 Oleksandr TarasiukPR

模块解析时考虑 typeRoots

当 TypeScript 的模块解析策略无法解析一个路径时,它现在会相对于 typeRoots 继续解析。

更多详情请参考 PR

在 JSX 标签上链接光标

TypeScript 现在支持 链接编辑 JSX 标签名。 链接编辑(有时称作“光标镜像”)允许编辑器同时自动编辑多个位置。

这个新特性在 TypeScript 和 JavaScript 里都可用,并且可以在 Visual Studio Code Insiders 版本中启用。 在 Visual Studio Code 里,你既可以用设置界面的 Editor: Linked Editing 配置:

x

也可以用 JSON 配置文件中的 editor.linkedEditing

{
  // ...
  "editor.linkedEditing": true
}

这个功能也将在 Visual Studio 17.7 Preview 1 中得到支持。

@param JSDoc 标记的代码片段自动补全

现在,在 TypeScript 和 JavaScript 文件中输入 @param 标签时,TypeScript 提供代码片段自动补全。 这可以帮助在为代码编写文档和添加 JSDoc 类型时,减少打字和文本跳转次数。

更多详情请参考 PR

优化

避免非必要的类型初始化

TypeScript 5.1 现在避免在已知不包含对外部类型参数的引用的对象类型中执行类型实例化。 这有可能减少许多不必要的计算,并将 material-ui 的文档目录的类型检查时间缩短了 50% 以上。

更多详情请参考 PR

联合字面量的反面情况检查

当检查源类型是否是联合类型的一部分时,TypeScript 首先使用该源类型的内部类型标识符进行快速查找。 如果查找失败,则 TypeScript 会检查与联合类型中的每个类型的兼容性。

当将字面量类型与纯字面量类型的联合类型进行关联时,TypeScript 现在可以避免针对联合中的每个其他类型进行完整遍历。 这个假设是安全的,因为 TypeScript 总是将字面量类型内部化/缓存 —— 虽然有一些与“全新”字面量类型相关的边缘情况需要处理。

这个优化可以减少问题代码的类型检查时间从 45 秒到 0.4 秒。

减少在解析 JSDoc 时的扫描函数调用

在旧版本的 TypeScript 中解析 JSDoc 注释时,它们会使用扫描器/标记化程序将注释分解为细粒度的标记,然后将内容拼回到一起。 这对于规范化注释文本可能是有帮助的,使多个空格只折叠成一个; 但这样做会极大地增加“对话”量,意味着解析器和扫描器会非常频繁地来回跳跃,从而增加了 JSDoc 解析的开销。

TypeScript 5.1 已经移动了更多的逻辑来分解 JSDoc 注释到扫描器/标记化程序中。 现在,扫描器直接将更大的内容块返回给解析器,以便根据需要进行处理。

这些更改将几个大约 10Mb 的大部分为散文评论的 JavaScript 文件的解析时间减少了约一半。 对于一个更现实的例子,我们的性能套件对 xstate 的快照减少了约 300 毫秒的解析时间,使其更快地加载和分析。

TypeScript 5.0

装饰器 Decorators

装饰器是即将到来的 ECMAScript 特性,它允许我们定制可重用的类以及类成员。

考虑如下的代码:

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  greet() {
    console.log(`Hello, my name is ${this.name}.`);
  }
}

const p = new Person('Ron');
p.greet();

这里的 greet 很简单,但我们假设它很复杂 - 例如包含异步的逻辑,是递归的,具有副作用等。 不管你把它想像成多么混乱复杂,现在我们想插入一些 console.log 语句来调试 greet

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  greet() {
    console.log('LOG: Entering method.');

    console.log(`Hello, my name is ${this.name}.`);

    console.log('LOG: Exiting method.');
  }
}

这个做法太常见了。 如果有种办法能给每一个类方法都添加打印功能就太好了!

这就是装饰器的用武之地。 让我们编写一个函数 loggedMethod

function loggedMethod(originalMethod: any, _context: any) {
  function replacementMethod(this: any, ...args: any[]) {
    console.log('LOG: Entering method.');
    const result = originalMethod.call(this, ...args);
    console.log('LOG: Exiting method.');
    return result;
  }

  return replacementMethod;
}

"这些 any 是怎么回事?都啥啊?"

先别急 - 这里我们是想简化一下问题,将注意力集中在函数的功能上。 注意一下 loggedMethod 接收原方法(originalMethod)作为参数并返回一个函数:

  1. 打印 "Entering…" 消息
  2. this 值以及所有的参数传递给原方法
  3. 打印 "Exiting..." 消息,并且
  4. 返回原方法的返回值。

现在可以使用 loggedMethod装饰 greet 方法:

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  @loggedMethod
  greet() {
    console.log(`Hello, my name is ${this.name}.`);
  }
}

const p = new Person('Ron');
p.greet();

// 输出:
//
//   LOG: Entering method.
//   Hello, my name is Ron.
//   LOG: Exiting method.

我们刚刚在 greet 上使用了 loggedMethod 装饰器 - 注意一下写法 @loggedMethod。 这样做之后,loggedMethod 被调用时会传入被装饰的目标 target 以及一个上下文对象 context object 作为参数。 因为 loggedMethod 返回了一个新函数,因此这个新函数会替换掉 greet 的原始定义。

loggedMethod 的定义中带有第二个参数。 它就是上下文对象 context object,包含了一些有关于装饰器声明细节的有用信息 - 例如是否为 #private 成员,或者 static,或者方法的名称。 让我们重写 loggedMethod 来使用这些信息,并且打印出被装饰的方法的名字。

function loggedMethod(
  originalMethod: any,
  context: ClassMethodDecoratorContext
) {
  const methodName = String(context.name);

  function replacementMethod(this: any, ...args: any[]) {
    console.log(`LOG: Entering method '${methodName}'.`);
    const result = originalMethod.call(this, ...args);
    console.log(`LOG: Exiting method '${methodName}'.`);
    return result;
  }

  return replacementMethod;
}

我们使用了上下文参数。 TypeScript 提供了名为 ClassMethodDecoratorContext 的类型用于描述装饰器方法接收的上下文对象。

除了元数据外,上下文对象中还提供了一个有用的函数 addInitializer。 它提供了一种方式来 hook 到构造函数的起始位置。

例如在 JavaScript 中,下面的情形很常见:

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;

    this.greet = this.greet.bind(this);
  }

  greet() {
    console.log(`Hello, my name is ${this.name}.`);
  }
}

或者,greet 可以被声明为使用箭头函数初始化的属性。

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  greet = () => {
    console.log(`Hello, my name is ${this.name}.`);
  };
}

这类代码的目的是确保 this 值不会被重新绑定,当 greet 被独立地调用或者在用作回调函数时。

const greet = new Person('Ron').greet;

// 我们不希望下面的调用失败
greet();

我们可以定义一个装饰器来利用 addInitializer 在构造函数里调用 bind

function bound(originalMethod: any, context: ClassMethodDecoratorContext) {
  const methodName = context.name;
  if (context.private) {
    throw new Error(
      `'bound' cannot decorate private properties like ${methodName as string}.`
    );
  }
  context.addInitializer(function () {
    this[methodName] = this[methodName].bind(this);
  });
}

bound 没有返回值 - 因此当它装饰一个方法时,不会影响原先的方法。 但是,它会在字段被初始化前添加一些逻辑。

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  @bound
  @loggedMethod
  greet() {
    console.log(`Hello, my name is ${this.name}.`);
  }
}

const p = new Person('Ron');
const greet = p.greet;

// Works!
greet();

我们将两个装饰器叠在了一起 - @bound@loggedMethod。 这些装饰器以“相反的”顺序执行。 也就是说,@loggedMethod 装饰原始方法 greet@bound 装饰的是 @loggedMethod 的结果。 此例中,这不太重要 - 但如果你的装饰器带有副作用或者期望特定的顺序,那就不一样了。

值得注意的是:如果你在乎代码样式,也可以将装饰器放在同一行上。

@bound @loggedMethod greet() {
  console.log(`Hello, my name is ${this.name}.`);
}

可能不太明显的一点是,你甚至可以定义一个返回装饰器函数的函数。 这样我们可以在一定程序上定制最终的装饰器。 我们可以让 loggedMethod 返回一个装饰器并且定制如何打印消息。

function loggedMethod(headMessage = 'LOG:') {
  return function actualDecorator(
    originalMethod: any,
    context: ClassMethodDecoratorContext
  ) {
    const methodName = String(context.name);

    function replacementMethod(this: any, ...args: any[]) {
      console.log(`${headMessage} Entering method '${methodName}'.`);
      const result = originalMethod.call(this, ...args);
      console.log(`${headMessage} Exiting method '${methodName}'.`);
      return result;
    }

    return replacementMethod;
  };
}

这样做之后,在使用 loggedMethod 装饰器之前需要先调用它。 接下来就可以传入任意字符串作为打印消息的前缀。

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  @loggedMethod('')
  greet() {
    console.log(`Hello, my name is ${this.name}.`);
  }
}

const p = new Person('Ron');
p.greet();

// Output:
//
//    Entering method 'greet'.
//   Hello, my name is Ron.
//    Exiting method 'greet'.

装饰器不仅可以用在方法上! 它们也可以被用在属性/字段,存取器(getter/setter)以及自动存取器。 甚至,类本身也可以被装饰,用于处理子类化和注册。

想深入了解装饰器,可以阅读 Axel Rauschmayer 的文章

更多详情请参考 PR

与旧的实验性的装饰器的差异

如果你有一定的 TypeScript 经验,你会发现 TypeScript 多年前就已经支持了“实验性的”装饰器特性。 虽然实验性的装饰器非常地好用,但是它实现的是旧版本的装饰器规范,并且总是需要启用 --experimentalDecorators 编译器选项。 若没有启用它并且使用了装饰器,TypeScript 会报错。

在未来的一段时间内,--experimentalDecorators 依然会存在; 然而,如果不使用该标记,在新代码中装饰器语法也是合法的。 在 --experimentalDecorators 之外,它们的类型检查和代码生成方式也不同。 类型检查和代码生成规则存在巨大差异,以至于虽然装饰器可以被定义为同时支持新、旧装饰器的行为,但任何现有的装饰器函数都不太可能这样做。

新的装饰器提案与 --emitDecoratorMetadata 的实现不兼容,并且不支持在参数上使用装饰器。 未来的 ECMAScript 提案可能会弥补这个差距。

最后要注意的是:除了可以在 export 关键字之前使用装饰器,还可以在 export 或者 export default 之后使用。 但是不允许混合使用两种风格。

//  allowed
@register
export default class Foo {
  // ...
}

//  also allowed
export default
@register
class Bar {
  // ...
}

//  error - before *and* after is not allowed
@before
@after
export class Bar {
  // ...
}

编写强类型的装饰器

上面的例子 loggedMethodbound 是故意写的简单并且忽略了大量和类型有关的细节。

为装饰器添加类型可能会很复杂。 例如,强类型的 loggedMethod 可能像下面这样:

function loggedMethod<This, Args extends any[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<
    This,
    (this: This, ...args: Args) => Return
  >
) {
  const methodName = String(context.name);

  function replacementMethod(this: This, ...args: Args): Return {
    console.log(`LOG: Entering method '${methodName}'.`);
    const result = target.call(this, ...args);
    console.log(`LOG: Exiting method '${methodName}'.`);
    return result;
  }

  return replacementMethod;
}

我们必须分别给原方法的 this、形式参数和返回值添加类型,上面使用了类型参数 ThisArgs 以及 Return。 装饰器函数到底有多复杂取决于你要确保什么。 但要记住,装饰器被使用的次数远多于被编写的次数,因此强类型的版本是通常希望得到的 - 但我们需要在可读性之间做出取舍,因此要尽量保持简洁。

未来会有更多关于如何编写装饰器的文档 - 但是这篇文章详细介绍了装饰器的工作方式。

const 类型参数

在推断对象类型时,TypeScript 通常会选择一个通用类型。 例如,下例中 names 的推断类型为 string[]

type HasNames = { readonly names: string[] };
function getNamesExactly<T extends HasNames>(arg: T): T['names'] {
  return arg.names;
}

// Inferred type: string[]
const names = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] });

这样做的目的通常是为了允许后面可以进行修改。

然而,根据 getNamesExactly 的具体功能和预期使用方式,通常情况下需要更加具体的类型。

直到现在,API 作者们通常不得不在一些位置上添加 as const 来达到预期的类型推断目的:

// The type we wanted:
//    readonly ["Alice", "Bob", "Eve"]
// The type we got:
//    string[]
const names1 = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] });

// Correctly gets what we wanted:
//    readonly ["Alice", "Bob", "Eve"]
const names2 = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] } as const);

这样做既繁琐又容易忘。 在 TypeScript 5.0 里,你可以为类型参数声明添加 const 修饰符, 这使得 const 形式的类型推断成为默认行为:

type HasNames = { names: readonly string[] };
function getNamesExactly<const T extends HasNames>(arg: T): T['names'] {
  //                       ^^^^^
  return arg.names;
}

// Inferred type: readonly ["Alice", "Bob", "Eve"]
// Note: Didn't need to write 'as const' here
const names = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] });

注意,const 修饰符不会拒绝可修改的值,并且不需要不可变约束。 使用可变类型约束可能会产生令人惊讶的结果。

declare function fnBad<const T extends string[]>(args: T): void;

// 'T' is still 'string[]' since 'readonly ["a", "b", "c"]' is not assignable to 'string[]'
fnBad(['a', 'b', 'c']);

这里,T 的候选推断类型为 readonly ["a", "b", "c"],但是 readonly 只读数组不能用在需要可变数组的地方。 这种情况下,类型推断会回退到类型约束,将数组视为 string[] 类型,因此函数调用仍然会成功。

这个函数更好的定义是使用 readonly string[]

declare function fnGood<const T extends readonly string[]>(args: T): void;

// T is readonly ["a", "b", "c"]
fnGood(['a', 'b', 'c']);

要注意 const 修饰符只影响在函数调用中直接写出的对象、数组和基本表达式的类型推断, 因此,那些无法(或不会)使用 as const 进行修饰的参数在行为上不会有任何变化:

declare function fnGood<const T extends readonly string[]>(args: T): void;
const arr = ['a', 'b', 'c'];

// 'T' is still 'string[]'-- the 'const' modifier has no effect here
fnGood(arr);

更多详情请参考 PRPRPR

extends 支持多个配置文件

在管理多个项目时,拥有一个“基础”配置文件,其他 tsconfig.json 文件可以继承它,这会非常有帮助。 这就是为什么 TypeScript 支持使用 extends 字段来从 compilerOptions 中复制字段的原因。

// packages/front-end/src/tsconfig.json
{
  "extends": "../../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "../lib"
    // ...
  }
}

然而,有时您可能想要从多个配置文件中进行继承。 例如,假设您正在使用一个在 npm 上发布的 TypeScript 基础配置文件。 如果您希望自己所有的项目也使用 npm 上的 @tsconfig/strictest 包中的选项,那么有一个简单的解决方案:让 tsconfig.base.json@tsconfig/strictest 进行扩展:

// tsconfig.base.json
{
  "extends": "@tsconfig/strictest/tsconfig.json",
  "compilerOptions": {
    // ...
  }
}

这在某种程度上是有效的。 如果您的某些工程不想使用 @tsconfig/strictest,那么必须手动禁用这些选项,或者创建一个不继承于 @tsconfig/strictesttsconfig.base.json

为了提高灵活性,TypeScript 5.0 允许 extends 字段指定多个值。 例如,有如下的配置文件:

{
  "extends": ["a", "b", "c"],
  "compilerOptions": {
    // ...
  }
}

这样写就如同是直接继承 c,而 c 继承于 bb 继承于 a。 如果出现冲突,后来者会被采纳。

在下面的例子中,在最终的 tsconfig.jsonstrictNullChecksnoImplicitAny 会被启用。

// tsconfig1.json
{
    "compilerOptions": {
        "strictNullChecks": true
    }
}

// tsconfig2.json
{
    "compilerOptions": {
        "noImplicitAny": true
    }
}

// tsconfig.json
{
    "extends": ["./tsconfig1.json", "./tsconfig2.json"],
    "files": ["./index.ts"]
}

另一个例子,我们可以这样改写最初的示例:

// packages/front-end/src/tsconfig.json
{
  "extends": [
    "@tsconfig/strictest/tsconfig.json",
    "../../../tsconfig.base.json"
  ],
  "compilerOptions": {
    "outDir": "../lib"
    // ...
  }
}

更多详情请参考:PR

所有的 enum 均为联合 enum

在最初 TypeScript 引入枚举类型时,它们只不过是一组同类型的数值常量。

enum E {
  Foo = 10,
  Bar = 20,
}

E.FooE.Bar 唯一特殊的地方在于它们可以赋值给任何期望类型为 E 的地方。 除此之外,它们基本上等同于 number 类型。

function takeValue(e: E) {}

takeValue(E.Foo); // works
takeValue(123); // error!

直到 TypeScript 2.0 引入了枚举字面量类型,枚举才变得更为特殊。 枚举字面量类型为每个枚举成员提供了其自己的类型,并将枚举本身转换为每个成员类型的联合类型。 它们还允许我们仅引用枚举中的一部分类型,并细化掉那些类型。

// Color is like a union of Red | Orange | Yellow | Green | Blue | Violet
enum Color {
    Red, Orange, Yellow, Green, Blue, /* Indigo */, Violet
}

// Each enum member has its own type that we can refer to!
type PrimaryColor = Color.Red | Color.Green | Color.Blue;

function isPrimaryColor(c: Color): c is PrimaryColor {
    // Narrowing literal types can catch bugs.
    // TypeScript will error here because
    // we'll end up comparing 'Color.Red' to 'Color.Green'.
    // We meant to use ||, but accidentally wrote &&.
    return c === Color.Red && c === Color.Green && c === Color.Blue;
}

为每个枚举成员提供其自己的类型的一个问题是,这些类型在某种程度上与成员的实际值相关联。 在某些情况下,无法计算该值 - 例如,枚举成员可能由函数调用初始化。

enum E {
  Blah = Math.random(),
}

每当 TypeScript 遇到这些问题时,它会悄悄地退而使用旧的枚举策略。 这意味着放弃所有联合类型和字面量类型的优势。

TypeScript 5.0 通过为每个计算成员创建唯一类型,成功将所有枚举转换为联合枚举。 这意味着现在所有枚举都可以被细化,并且每个枚举成员都有其自己的类型。

更多详情请参考 PR

--moduleResolution bundler

TypeScript 4.7 支持将 --module--moduleResolution 选项设置为 node16nodenext。 这些选项的目的是更好地模拟 Node.js 中 ECMAScript 模块的精确查找规则; 然而,这种模式存在许多其他工具实际上并不强制执行的限制。

例如,在 Node.js 的 ECMAScript 模块中,任何相对导入都需要包含文件扩展名。

// entry.mjs
import * as utils from './utils'; //  wrong - we need to include the file extension.

import * as utils from './utils.mjs'; //  works

对于 Node.js 和浏览器来说,这样做有一些原因 - 它可以加快文件查找速度,并且对于简单的文件服务器效果更好。 但是对于许多使用打包工具的开发人员来说,node16 / nodenext 设置很麻烦, 因为打包工具中没有这么多限制。 在某些方面,node 解析模式对于任何使用打包工具的人来说是更好的。

但在某些方面,原始的 node 解析模式已经过时了。 大多数现代打包工具在 Node.js 中使用 ECMAScript 模块和 CommonJS 查找规则的融合。 例如,像在 CommonJS 中一样,无扩展名的导入也可以正常工作,但是在查找包的导出条件时,它们将首选像在 ECMAScript 文件中一样的 import 条件。

为了模拟打包工具的工作方式,TypeScript 现在引入了一种新策略:--moduleResolution bundler

{
  "compilerOptions": {
    "target": "esnext",
    "moduleResolution": "bundler"
  }
}

如果你使用如 Vite, esbuild, swc, Webpack, parcel 等现代打包工具,它们实现了混合的查找策略,新的 bundler 选项是更好的选择。

另一方面,如果您正在编写一个要发布到 npm 的代码库,那么使用 bundler 选项可能会隐藏影响未使用打包工具用户的兼容性问题。 因此,在这些情况下,使用 node16nodenext 解析选项可能是更好的选择。

更多详情请参考 PR

定制化解析的标记

JavaScript 工具现在可以模拟“混合”解析规则,就像我们上面描述的 bundler 模式一样。 由于工具的支持可能有所不同,因此 TypeScript 5.0 提供了启用或禁用一些功能的方法,这些功能可能无法与您的配置一起使用。

allowImportingTsExtensions

--allowImportingTsExtensions 允许 TypeScript 文件导入使用了 TypeScript 特定扩展名的文件,例如 .ts, .mts, .tsx

此标记仅在启用了 --noEmit--emitDeclarationOnly 时允许使用, 因为这些导入路径无法在运行时的 JavaScript 输出文件中被解析。 这里的期望是,您的解析器(例如打包工具、运行时或其他工具)将保证这些在 .ts 文件之间的导入可以工作。

resolvePackageJsonExports

--resolvePackageJsonExports 强制 TypeScript 使用 package.json 里的 exports 字段,如果它尝试读取 node_modules 里的某个包。

--moduleResolutionnode16, nodenextbundler 时,该选项的默认值为 true

resolvePackageJsonImports

--resolvePackageJsonImports 强制 TypeScript 使用 package.json 里的 imports 字段,当它查找以 # 开头的文件时,且该文件的父目录中包含 package.json 文件。

--moduleResolutionnode16, nodenextbundler 时,该选项的默认值为 true

allowArbitraryExtensions

在 TypeScript 5.0 中,当导入路径不是以已知的 JavaScript 或 TypeScript 文件扩展名结尾时,编译器将查找该路径的声明文件,形式为 {文件基础名称}.d.{扩展名}.ts。 例如,如果您在打包项目中使用 CSS 加载器,您可能需要编写(或生成)如下的声明文件:

/* app.css */
.cookie-banner {
  display: none;
}
// app.d.css.ts
declare const css: {
  cookieBanner: string;
};
export default css;
// App.tsx
import styles from './app.css';

styles.cookieBanner; // string

默认情况下,该导入将引发错误,告诉您 TypeScript 不支持此文件类型,您的运行时可能不支持导入它。 但是,如果您已经配置了运行时或打包工具来处理它,您可以使用新的 --allowArbitraryExtensions 编译器选项来抑制错误。

需要注意的是,历史上通常可以通过添加名为 app.css.d.ts 而不是 app.d.css.ts 的声明文件来实现类似的效果 - 但是,这只在 Node.js 中 CommonJS 的 require 解析规则下可以工作。 严格来说,前者被解析为名为 app.css.js 的 JavaScript 文件的声明文件。 由于 Node 中的 ESM 需要使用包含扩展名的相对文件导入,因此在 --moduleResolutionnode16nodenext 时,TypeScript 会在示例的 ESM 文件中报错。

更多详情请参考 PR PR

customConditions

--customConditions 接受额外的条件列表,当 TypeScript 从 package.json 的exportsimports 字段解析时,这些条件应该成功。 这些条件会被添加到解析器默认使用的任何现有条件中。

例如,有如下的配置:

{
  "compilerOptions": {
    "target": "es2022",
    "moduleResolution": "bundler",
    "customConditions": ["my-condition"]
  }
}

每当 package.json 里引用了 exportsimports 字段时,TypeScript 都会考虑名为 my-condition 的条件。

所以当从具有如下 package.json 的包中导入时:

{
  // ...
  "exports": {
    ".": {
      "my-condition": "./foo.mjs",
      "node": "./bar.mjs",
      "import": "./baz.mjs",
      "require": "./biz.mjs"
    }
  }
}

TypeScript 会尝试查找 foo.mjs 文件。

该字段仅在 --moduleResolutionnode16, nodenextbundler 时有效。

--verbatimModuleSyntax

在默认情况下,TypeScript 会执行导入省略。 大体上来讲,如果有如下代码:

import { Car } from './car';

export function drive(car: Car) {
  // ...
}

TypeScript 能够检测到导入语句仅用于导入类型,因此会删除导入语句。 最终生成的 JavaScript 代码如下:

export function drive(car) {
  // ...
}

大多数情况下这是没问题的,因为如果 Car 不是从 ./car 导出的值,我们将会得到一个运行时错误。

但在一些特殊情况下,它增加了一层复杂性。 例如,不存在像 import "./car"; 这样的语句 - 这个导入语句会被完全删除。 这对于有副作用的模块来讲是有区别的。

TypeScript 的 JavaScript 代码生成策略还有其它一些复杂性 - 导入省略不仅只是由导入语句的使用方式决定 - 它还取决于值的声明方式。 因此,如下的代码的处理方式不总是那么明显:

export { Car } from './car';

这段代码是应该保留还是删除? 如果 Car 是使用 class 声明的,那么在生成的 JavaScript 代码中会被保留。 但是如果 Car 是使用类型别名或 interface 声明的,那么在生成的 JavaScript 代码中会被省略。

尽管 TypeScript 可以根据多个文件来综合判断如何生成代码,但不是所有的编译器都能够做到。

导入和导出语句中的 type 修饰符能够起到一点作用。 我们可以使用 type 修饰符明确声明导入和导出是否仅用于类型分析,并且可以在生成的 JavaScript 文件中完全删除。

// This statement can be dropped entirely in JS output
import type * as car from './car';

// The named import/export 'Car' can be dropped in JS output
import { type Car } from './car';
export { type Car } from './car';

type 修饰符本身并不是特别管用 - 默认情况下,导入省略仍会删除导入语句, 并且不强制要求您区分类型导入和普通导入以及导出。 因此,TypeScript 提供了 --importsNotUsedAsValues 来确保您使用类型修饰符, --preserveValueImports 来防止某些模块消除行为, 以及 --isolatedModules 来确保您的 TypeScript 代码在不同编译器中都能正常运行。 不幸的是,理解这三个标志的细节很困难,并且仍然存在一些意外行为的边缘情况。

TypeScript 5.0 提供了一个新的 --verbatimModuleSyntax 来简化这个情况。 规则很简单 - 所有不带 type 修饰符的导入导出语句会被保留。 任何带有 type 修饰符的导入导出语句会被删除。

// Erased away entirely.
import type { A } from 'a';

// Rewritten to 'import { b } from "bcd";'
import { b, type c, type d } from 'bcd';

// Rewritten to 'import {} from "xyz";'
import { type xyz } from 'xyz';

使用这个新的选项,实现了所见即所得。

但是,这在涉及模块互操作性时会有一些影响。 在这个标志下,当您的设置或文件扩展名暗示了不同的模块系统时,ECMAScript 的导入和导出不会被重写为 require 调用。 相反,您会收到一个错误。 如果您需要生成使用 requiremodule.exports 的代码,您需要使用早于 ES2015 的 TypeScript 的模块语法:

import foo = require('foo');

// ==>

const foo = require('foo');
function foo() {}
function bar() {}
function baz() {}

export = {
  foo,
  bar,
  baz,
};

// ==>

function foo() {}
function bar() {}
function baz() {}

module.exports = {
  foo,
  bar,
  baz,
};

虽然这是一种限制,但它确实有助于使一些问题更加明显。 例如,在 --module node16 下很容易忘记在 package.json 中设置 type 字段。 结果是开发人员会开始编写 CommonJS 模块而不是 ES 模块,但却没有意识到这一点,从而导致查找规则和 JavaScript 输出出现意外的结果。 这个新的标志确保您有意识地使用文件类型,因为语法是刻意不同的。

因为 --verbatimModuleSyntax 相比于 --importsNotUsedAsValues--preserveValueImports 提供了更加一致的行为,推荐使用前者,后两个标记将被弃用。

更多详情请参考 PRissue.

支持 export type *

在 TypeScript 3.8 引入类型导入时,该语法不支持在 export * from "module"export * as ns from "module" 重新导出上使用。 TypeScript 5.0 添加了对两者的支持:

// models/vehicles.ts
export class Spaceship {
  // ...
}

// models/index.ts
export type * as vehicles from './vehicles';

// main.ts
import { vehicles } from './models';

function takeASpaceship(s: vehicles.Spaceship) {
  //  ok - `vehicles` only used in a type position
}

function makeASpaceship() {
  return new vehicles.Spaceship();
  //         ^^^^^^^^
  // 'vehicles' cannot be used as a value because it was exported using 'export type'.
}

更多详情请参考 PR

支持 JSDoc 中的 @satisfies

TypeScript 4.9 支持 satisfies 运算符。 它确保了表达式的类型是兼容的,且不影响类型自身。 例如,有如下代码:

interface CompilerOptions {
  strict?: boolean;
  outDir?: string;
  // ...
}

interface ConfigSettings {
  compilerOptions?: CompilerOptions;
  extends?: string | string[];
  // ...
}

let myConfigSettings = {
  compilerOptions: {
    strict: true,
    outDir: '../lib',
    // ...
  },

  extends: ['@tsconfig/strictest/tsconfig.json', '../../../tsconfig.base.json'],
} satisfies ConfigSettings;

这里,TypeScript 知道 myConfigSettings.extends 声明为数组 - 因为 satisfies 会验证对象的类型。 因此,如果我们想在 extends 上进行映射操作,那是可以的。

declare function resolveConfig(configPath: string): CompilerOptions;

let inheritedConfigs = myConfigSettings.extends.map(resolveConfig);

这对 TypeScript 用户来讲是有用处的,但是许多人使用 TypeScript 来对带有 JSDoc 的 JavaScript 代码进行类型检查。 因此,TypeScript 5.0 支持了新的 JSDoc 标签 @satisfies 来做相同的事。

/** @satisfies */ 能够检查出类型不匹配:

// @ts-check

/**
 * @typedef CompilerOptions
 * @prop {boolean} [strict]
 * @prop {string} [outDir]
 */

/**
 * @satisfies {CompilerOptions}
 */
let myCompilerOptions = {
  outdir: '../lib',
  //  ~~~~~~ oops! we meant outDir
};

但它会保留表达式的原始类型,允许我们稍后使用值的更详细的类型。

// @ts-check

/**
 * @typedef CompilerOptions
 * @prop {boolean} [strict]
 * @prop {string} [outDir]
 */

/**
 * @typedef ConfigSettings
 * @prop {CompilerOptions} [compilerOptions]
 * @prop {string | string[]} [extends]
 */

/**
 * @satisfies {ConfigSettings}
 */
let myConfigSettings = {
  compilerOptions: {
    strict: true,
    outDir: '../lib',
  },
  extends: ['@tsconfig/strictest/tsconfig.json', '../../../tsconfig.base.json'],
};

let inheritedConfigs = myConfigSettings.extends.map(resolveConfig);

/** @satisfies */ 也可以在行内的括号表达式上使用。 可以像下面这样定义 myConfigSettings

let myConfigSettings = /** @satisfies {ConfigSettings} */ {
  compilerOptions: {
    strict: true,
    outDir: '../lib',
  },
  extends: ['@tsconfig/strictest/tsconfig.json', '../../../tsconfig.base.json'],
};

为什么?当你更深入地研究其他代码时,比如函数调用,它通常更有意义。

compileCode(
  /** @satisfies {ConfigSettings} */ {
    // ...
  }
);

更多详情请参考 PR。 感谢作者 Oleksandr Tarasiuk

支持 JSDoc 中的 @overload

在 TypeScript 中,你可以为一个函数指定多个重载。 使用重载能够描述一个函数可以使用不同的参数进行调用,也可能会返回不同的结果。 它们可以限制调用方如何调用函数,并细化他们将得到的结果。

// Our overloads:
function printValue(str: string): void;
function printValue(num: number, maxFractionDigits?: number): void;

// Our implementation:
function printValue(value: string | number, maximumFractionDigits?: number) {
  if (typeof value === 'number') {
    const formatter = Intl.NumberFormat('en-US', {
      maximumFractionDigits,
    });
    value = formatter.format(value);
  }

  console.log(value);
}

这里表示 printValue 的第一个参数可以为 stringnumber 类型。 如果接收的是 number 类型,那么它还接收第二个参数决定打印的小数位数。

TypeScript 5.0 支持在 JSDoc 里使用 @overload 来声明重载。 每一个 JSDoc @overload 标记都表示一个不同的函数重载。

// @ts-check

/**
 * @overload
 * @param {string} value
 * @return {void}
 */

/**
 * @overload
 * @param {number} value
 * @param {number} [maximumFractionDigits]
 * @return {void}
 */

/**
 * @param {string | number} value
 * @param {number} [maximumFractionDigits]
 */
function printValue(value, maximumFractionDigits) {
  if (typeof value === 'number') {
    const formatter = Intl.NumberFormat('en-US', {
      maximumFractionDigits,
    });
    value = formatter.format(value);
  }

  console.log(value);
}

现在不论是编写 TypeScript 文件还是 JavaScript 文件,TypeScript 都能够提示函数调用是否正确。

// all allowed
printValue('hello!');
printValue(123.45);
printValue(123.45, 2);

printValue('hello!', 123); // error!

更多详情请参考 PR,感谢 Tomasz Lenarcik

--build 模式下使用有关文件生成的选项

TypeScript 现在允许在 --build 模式下使用如下选项:

  • --declaration
  • --emitDeclarationOnly
  • --declarationMap
  • --sourceMap
  • --inlineSourceMap

这使得在构建过程中定制某些部分变得更加容易,特别是在你可能会有不同的开发和生产构建时。

例如,一个库的开发构建可能不需要生成声明文件,但是生产构建则需要。 一个项目可以将生成声明文件配置为默认关闭,并使用如下方式构建:

tsc --build -p ./my-project-dir

开发完毕后,在“生产环境”构建时使用 --declaration 选项:

tsc --build -p ./my-project-dir --declaration

更多详情请参考 PR

编辑器导入语句排序时不区分大小写

在 Visual Studio 和 VS Code 等编辑器中,TypeScript 可以帮助组织和排序导入和导出语句。 不过,通常情况下,对于何时将列表“排序”,可能会有不同的解释。

例如,下面的导入列表是否已排序?

import { Toggle, freeze, toBoolean } from './utils';

令人惊讶的是,答案可能是“这要看情况”。 如果我们不考虑大小写敏感性,那么这个列表显然是没有排序的。 字母f排在tT之前。

但在大多数编程语言中,排序默认是比较字符串的字节值。 JavaScript 比较字符串的方式意味着 “Toggle” 总是排在 “freeze” 之前,因为根据 ASCII 字符编码,大写字母排在小写字母之前。 所以从这个角度来看,导入列表是已排序的。

以前,TypeScript 认为导入列表已排序,因为它进行了基本的大小写敏感排序。 这可能让开发人员感到沮丧,因为他们更喜欢不区分大小写的排序方式,或者使用像 ESLint 这样的工具默认需要不区分大小写的排序方式。

现在,TypeScript 默认会检测大小写敏感性。 这意味着 TypeScript 和类似 ESLint 的工具通常不会因为如何最好地排序导入而“互相冲突”。

我们的团队还在尝试更多的排序策略,你可以在这里了解更多。 这些选项可能最终可以由编辑器进行配置。 目前,它们仍然不稳定和实验性的,你可以通过在 JSON 选项中使用 typescript.unstable 条目来选择它们。 下面是你可以尝试的所有选项(设置为它们的默认值):

{
  "typescript.unstable": {
    // Should sorting be case-sensitive? Can be:
    // - true
    // - false
    // - "auto" (auto-detect)
    "organizeImportsIgnoreCase": "auto",

    // Should sorting be "ordinal" and use code points or consider Unicode rules? Can be:
    // - "ordinal"
    // - "unicode"
    "organizeImportsCollation": "ordinal",

    // Under `"organizeImportsCollation": "unicode"`,
    // what is the current locale? Can be:
    // - [any other locale code]
    // - "auto" (use the editor's locale)
    "organizeImportsLocale": "en",

    // Under `"organizeImportsCollation": "unicode"`,
    // should upper-case letters or lower-case letters come first? Can be:
    // - false (locale-specific)
    // - "upper"
    // - "lower"
    "organizeImportsCaseFirst": false,

    // Under `"organizeImportsCollation": "unicode"`,
    // do runs of numbers get compared numerically (i.e. "a1" < "a2" < "a100")? Can be:
    // - true
    // - false
    "organizeImportsNumericCollation": true,

    // Under `"organizeImportsCollation": "unicode"`,
    // do letters with accent marks/diacritics get sorted distinctly
    // from their "base" letter (i.e. is é different from e)? Can be
    // - true
    // - false
    "organizeImportsAccentCollation": true
  },
  "javascript.unstable": {
    // same options valid here...
  }
}

更多详情请参考 PRPR

穷举式 switch/case 自动补全

在编写 switch 语句时,TypeScript 现在会检测被检查的值是否具有字面量类型。 如果是,它将提供一个补全选项,可以为每个未覆盖的情况构建骨架代码。

更多详情请参考 PR

速度,内存以及代码包尺寸优化

TypeScript 5.0 在我们的代码结构、数据结构和算法实现方面进行了许多强大的变化。 这些变化的意义在于,整个体验都应该更快 —— 不仅仅是运行 TypeScript,甚至包括安装 TypeScript。

以下是我们相对于 TypeScript 4.9 能够获得的一些有趣的速度和大小优势。

ScenarioTime or Size Relative to TS 4.9
material-ui build time90%
TypeScript Compiler startup time89%
Playwright build time88%
TypeScript Compiler self-build time87%
Outlook Web build time82%
VS Code build time80%
typescript npm Package Size59%

img

img

怎么做到的呢?我们将在未来的博客文章中详细介绍一些值得注意的改进。 但我们不会让你等到那篇博客文章。

首先,我们最近将 TypeScript 从命名空间迁移到了模块,这使我们能够利用现代构建工具来执行像作用域提升这样的优化。 使用这些工具,重新审视我们的打包策略,并删除一些已过时的代码,使 TypeScript 4.9 的 63.8 MB 包大小减少了约 26.4 MB。 这也通过直接函数调用为我们带来了显著的加速。 我们在这里撰写了关于我们迁移到模块的详细介绍

TypeScript 还在编译器内部对象类型上增加了更多的一致性,并且也减少了一些这些对象类型上存储的数据。 这减少了多态操作,同时平衡了由于使我们的对象结构更加一致而带来的内存使用增加。

我们还在将信息序列化为字符串时执行了一些缓存。 类型显示,它可能在错误报告、声明生成、代码补全等情况下使用,是非常昂贵的操作。 TypeScript 现在对一些常用的机制进行缓存,以便在这些操作之间重复使用。

我们进行了一个值得注意的改变,改善了我们的解析器,即在某些情况下,利用 var 来避免在闭包中使用 let 和 const 的成本。 这提高了一些解析性能。

总的来说,我们预计大多数代码库应该会从 TypeScript 5.0 中看到速度的提升,并且一直能够保持 10% 到 20% 之间的优势。 当然,这将取决于硬件和代码库的特性,但我们鼓励你今天就在你的代码库上尝试它!

更多详情:

TypeScript 4.9

satisfies 运算符

TypeScript 开发者有时会感到进退两难:既想要确保表达式能够匹配某种类型,也想要表达式获得最确切的类型用作类型推断。

例如:

// 每个属性可能是 string 或 RGB 元组。
const palette = {
  red: [255, 0, 0],
  green: '#00ff00',
  bleu: [0, 0, 255],
  //  ^^^^ 拼写错误
};

// 我们想要在 'red' 上调用数组的方法
const redComponent = palette.red.at(0);

// 或者在 'green' 上调用字符串的方法
const greenNormalized = palette.green.toUpperCase();

注意,这里写成了 bleu,但我们想写的是 blue。 通过给 palette 添加类型注释就能够捕获 bleu 拼写错误, 但同时我们也失去了属性各自的信息。

type Colors = 'red' | 'green' | 'blue';

type RGB = [red: number, green: number, blue: number];

const palette: Record<Colors, string | RGB> = {
  red: [255, 0, 0],
  green: '#00ff00',
  bleu: [0, 0, 255],
  //  ~~~~ 能够检测到拼写错误
};

// 意想不到的错误 - 'palette.red' 可能为 string
const redComponent = palette.red.at(0);

新的 satisfies 运算符让我们可以验证表达式是否匹配某种类型,同时不改变表达式自身的类型。 例如,可以使用 satisfies 来检验 palette 的所有属性与 string | number[] 是否兼容:

type Colors = 'red' | 'green' | 'blue';

type RGB = [red: number, green: number, blue: number];

const palette = {
  red: [255, 0, 0],
  green: '#00ff00',
  bleu: [0, 0, 255],
  //  ~~~~ 捕获拼写错误
} satisfies Record<Colors, string | RGB>;

// 依然可以访问这些方法
const redComponent = palette.red.at(0);
const greenNormalized = palette.green.toUpperCase();

satisfies 可以用来捕获许多错误。 例如,检查一个对象是否包含了某个类型要求的所有的键,并且没有多余的:

type Colors = 'red' | 'green' | 'blue';

// 确保仅包含 'Colors' 中定义的键
const favoriteColors = {
  red: 'yes',
  green: false,
  blue: 'kinda',
  platypus: false,
  //  ~~~~~~~~~~ 错误 - "platypus" 不在 'Colors' 中
} satisfies Record<Colors, unknown>;

// 'red', 'green', and 'blue' 的类型信息保留下来
const g: boolean = favoriteColors.green;

有可能我们不太在乎属性名,在乎的是属性值的类型。 在这种情况下,我们也能够确保对象属性值的类型是匹配的。

type RGB = [red: number, green: number, blue: number];

const palette = {
  red: [255, 0, 0],
  green: '#00ff00',
  blue: [0, 0],
  //    ~~~~~~ 错误!
} satisfies Record<string, string | RGB>;

// 类型信息保留下来
const redComponent = palette.red.at(0);
const greenNormalized = palette.green.toUpperCase();

更多示例请查看这里这里。 感谢Oleksandr Tarasiuk对该属性的贡献。

使用 in 运算符来细化并未列出其属性的对象类型

开发者经常需要处理在运行时不完全已知的值。 事实上,我们常常不能确定对象的某个属性是否存在,是否从服务端得到了响应或者读取到了某个配置文件。 JavaScript 的 in 运算符能够检查对象上是否存在某个属性。

从前,TypeScript 能够根据没有明确列出的属性来细化类型。

interface RGB {
  red: number;
  green: number;
  blue: number;
}

interface HSV {
  hue: number;
  saturation: number;
  value: number;
}

function setColor(color: RGB | HSV) {
  if ('hue' in color) {
    // 'color' 类型为 HSV
  }
  // ...
}

此处,RGB 类型上没有列出 hue 属性,因此被细化掉了,剩下了 HSV 类型。

那如果每个类型上都没有列出这个属性呢? 在这种情况下,语言无法提供太多的帮助。 看下面的 JavaScript 示例:

function tryGetPackageName(context) {
  const packageJSON = context.packageJSON;
  // Check to see if we have an object.
  if (packageJSON && typeof packageJSON === 'object') {
    // Check to see if it has a string name property.
    if ('name' in packageJSON && typeof packageJSON.name === 'string') {
      return packageJSON.name;
    }
  }

  return undefined;
}

将上面的代码改写为合适的 TypeScript,我们会给 context 定义一个类型; 然而,在旧版本的 TypeScript 中如果声明 packageJSON 属性的类型为安全的 unknown 类型会有问题。

interface Context {
  packageJSON: unknown;
}

function tryGetPackageName(context: Context) {
  const packageJSON = context.packageJSON;
  // Check to see if we have an object.
  if (packageJSON && typeof packageJSON === 'object') {
    // Check to see if it has a string name property.
    if ('name' in packageJSON && typeof packageJSON.name === 'string') {
      //                                              ~~~~
      // error! Property 'name' does not exist on type 'object.
      return packageJSON.name;
      //                     ~~~~
      // error! Property 'name' does not exist on type 'object.
    }
  }

  return undefined;
}

这是因为当 packageJSON 的类型从 unknown 细化为 object 类型后, in 运算符会严格地将类型细化为包含了所检查属性的某个类型。 因此,packageJSON 的类型仍为 object

TypeScript 4.9 增强了 in 运算符的类型细化功能,它能够更好地处理没有列出属性的类型。 现在 TypeScript 不是什么也不做,而是将其类型与 Record<"property-key-being-checked", unknown> 进行类型交叉运算。

因此在上例中,packageJSON 的类型将从 unknown 细化为 object 再细化为 object & Record<"name", unknown>。 这样就允许我们访问并细化类型 packageJSON.name

interface Context {
  packageJSON: unknown;
}

function tryGetPackageName(context: Context): string | undefined {
  const packageJSON = context.packageJSON;
  // Check to see if we have an object.
  if (packageJSON && typeof packageJSON === 'object') {
    // Check to see if it has a string name property.
    if ('name' in packageJSON && typeof packageJSON.name === 'string') {
      // Just works!
      return packageJSON.name;
    }
  }

  return undefined;
}

TypeScript 4.9 还会严格限制 in 运算符的使用,以确保左侧的操作数能够赋值给 string | number | symbol,右侧的操作数能够赋值给 object。 它有助于检查是否使用了合法的属性名,以及避免在原始类型上进行检查。

更多详情请查看 PR.

类中的自动存取器

TypeScript 4.9 支持了 ECMAScript 即将引入的“自动存取器”功能。 自动存取器的声明如同定义一个类的属性,只不过是需要使用 accessor 关键字。

class Person {
  accessor name: string;

  constructor(name: string) {
    this.name = name;
  }
}

在底层实现中,自动存取器会被展开为 getset 存取器,以及一个无法访问的私有成员。

class Person {
  #__name: string;

  get name() {
    return this.#__name;
  }
  set name(value: string) {
    this.#__name = name;
  }

  constructor(name: string) {
    this.name = name;
  }
}

更多详情请参考 PR

NaN 上的相等性检查

在 JavaScript 中,你无法使用内置的相等运算符去检查某个值是否等于 NaN

由于一些原因,NaN 是个特殊的数值,它代表 不是一个数字。 没有值等于 NaN,包括 NaN 自己!

console.log(NaN == 0); // false
console.log(NaN === 0); // false

console.log(NaN == NaN); // false
console.log(NaN === NaN); // false

换句话说,任何值都不等于 NaN

console.log(NaN != 0); // true
console.log(NaN !== 0); // true

console.log(NaN != NaN); // true
console.log(NaN !== NaN); // true

从技术上讲,这不是 JavaScript 独有的问题,任何使用 IEEE-754 浮点数的语言都有一样的问题; 但是 JavaScript 中主要的数值类型为浮点数,并且解析数值时经常会得到 NaN。 因此,检查 NaN 是很常见的操作,正确的方法是使用 Number.isNaN 函数 - 但像上文提到的,很多人可能不小心地使用了 someValue === NaN 来进行检查。

现在,如果 TypeScript 发现直接比较 NaN 会报错,并提示使用 Number.isNaN

function validate(someValue: number) {
  return someValue !== NaN;
  //     ~~~~~~~~~~~~~~~~~
  // error: This condition will always return 'true'.
  //        Did you mean '!Number.isNaN(someValue)'?
}

我们确信这个改动会帮助捕获初级的错误,就如同 TypeScript 也会检查比较对象字面量和数组字面量一样。

感谢 Oleksandr Tarasiuk 提交的 PR

监视文件功能使用文件系统事件

在先前的版本中,TypeScript 主要依靠轮询来监视每个文件。 使用轮询的策略意味着定期检查文件是否有更新。 在 Node.js 中,fs.watchFile 是内置的使用轮询来检查文件变动的方法。 虽说轮询在跨操作系统和文件系统的情况下更稳妥,但是它也意味着 CPU 会定期地被中断,转而去检查是否有文件更新即便在没有任何改动的情况下。 这在只有少数文件的时候问题不大,但如果工程包含了大量文件 - 或 node_modules 里有大量的文件 - 就会变得非常吃资源。

通常来讲,更好的做法是使用文件系统事件。 做为轮询的替换,我们声明对某些文件的变动感兴趣并提供回调函数用于处理有改动的文件。 大多数现代的平台提供了如 CreateIoCompletionPortkqueueepollinotify API。 Node.js 对这些 API 进行了抽象,提供了 fs.watch API。 文件系统事件通常可以很好地工作,但是也存在一些注意事项。 一个 watcher 需要考虑 inode watching的问题、 在一些文件系统上不可用的问题(比如:网络文件系统)、 嵌套的文件监控是否可用、重命名目录是否触发事件以及可用 file watcher 耗尽的问题! 换句话说,这件事不是那么容易做的,特别是我们还需要跨平台。

因此,过去我们的默认选择是普遍好用的方式:轮询。 虽不总是,但大部分时候是这样的。

后来,我们提供了选择文件监视策略的方法。 这让我们收到了很多使用反馈并改善跨平台的问题。 由于 TypeScript 必须要能够处理大规模的代码并且也已经有了改进,因此我们觉得切换到使用文件系统事件是件值得做的事情。

在 TypeScript 4.9 中,文件监视已经默认使用文件系统事件的方式,仅当无法初始化事件监视时才回退到轮询。 对大部分开发者来讲,在使用 --watch 模式或在 Visual Studio、VS Code 里使用 TypeScript 时会极大降低资源的占用。

文件监视方式仍然是可以配置的,可以使用环境变量和 watchOptions - 像 VS Code 这样的编辑器还支持单独配置。 如果你的代码使用的是网络文件系统(如 NFS 和 SMB)就需要回退到旧的行为; 但如果服务器有强大的处理能力,最好是启用 SSH 并且通过远程运行 TypeScript,这样就可以使用本地文件访问。 VS Code 支持了很多远程开发的工具。

编辑器中的“删除未使用导入”和“排序导入”命令

以前,TypeScript 仅支持两个管理导入语句的编辑器命令。 拿下面的代码举例:

import { Zebra, Moose, HoneyBadger } from './zoo';
import { foo, bar } from './helper';

let x: Moose | HoneyBadger = foo();

第一个命令是 “组织导入语句”,它会删除未使用的导入并对剩下的条目排序。 因此会将上面的代码重写为:

import { foo } from './helper';
import { HoneyBadger, Moose } from './zoo';

let x: Moose | HoneyBadger = foo();

在 TypeScript 4.3 中,引入了“排序导入语句”命令,它仅排序导入语句但不进行删除,因此会将上例代码重写为:

import { bar, foo } from './helper';
import { HoneyBadger, Moose, Zebra } from './zoo';

let x: Moose | HoneyBadger = foo();

使用“排序导入语句”的注意事项是,在 VS Code 中该命令只能在保存文件时触发,而非能够手动执行的命令。

TypeScript 4.9 添加了另一半功能,提供了“移除未使用的导入”功能。 TypeScript 会移除未使用的导入命名和语句,但是不能改变当前的排序。

import { Moose, HoneyBadger } from './zoo';
import { foo } from './helper';

let x: Moose | HoneyBadger = foo();

该功能对任何编译器都是可用的; 但要注意的是,VS Code (1.73+) 会内置这个功能并且可以使用 Command Pallette 来执行。 如果用户想要使用更细的“移除未使用的导入”或“排序导入”命令,那么可以将“组织导入”的快捷键绑定到这些命令上。

更多详情请参考这里

return 关键字上使用跳转到定义

在编辑器中,当在 return 关键字上使用跳转到定义功能时,TypeScript 会跳转到函数的顶端。 这会帮助理解 return 语句是属于哪个函数的。

我们期待这个功能扩展到更多的关键字上,例如 awaityield 或者 switchcasedefault。 感谢Oleksandr Tarasiuk实现

性能优化

TypeScript 进行了一些较小的但是能觉察到的性能优化。

首先,重写了 TypeScript 的 forEachChild 函数使用函数查找表代替 switch 语句。 forEachChild 是编译器在遍历语法节点时会反复调用的函数,和部分语言服务一起大量地被使用在编译绑定阶段。 对 forEachChild 函数的重构减少了绑定阶段和语言服务操作的 20% 时间消耗。

当我们看到了 forEachChild 的效果后也在 visitEachChild(在编译器和语言服务中用来变换节点的函数)上进行了类似的优化。 同样的重构减少了 3% 生成工程输出的时间消耗。

对于 forEachChild 的优化最初是受到了 Artemis Everfree 文章的启发。 虽说我们认为速度提升的根本原因是由于函数体积和复杂度的降低而非这篇文章里提到的问题,但我们非常感谢能够从中获得经验并快速地进行重构让 TypeScript 运行得更快。

最后,TypeScript 还优化了在条件类型的 true 分支中保留类型信息。 例如:

interface Zoo<T extends Animal> {
  // ...
}

type MakeZoo<A> = A extends Animal ? Zoo<A> : never;

TypeScript 在检查 Zoo<A>时需要记住 AAnimal。 TypeScript 通过新建 AAnimal 的交叉类型来保留该信息; 然而,TypeScript 之前采用的是即时求值的方式,即便有时是不需要的。 而且类型检查器中的一些问题代码使得这些类型无法被简化。 TypeScript 现在会推迟类型交叉操作直到真的有需要的时候。 对于大量地使用了有条件类型的代码来说,你会觉察到大幅的提速,但从我们的性能测试结果来看却只看到了 3% 的类型检查性能提升。

TypeScript 4.8

改进的交叉类型化简、联合类型兼容性以及类型细化

TypeScript 4.8 为 --strictNullChecks 带来了一系列修正和改进。 这些变化会影响交叉类型和联合类型的工作方式,也作用于 TypeScript 的类型细化。

例如,unknown{} | null | undefined 类型神似, 因为它接受 nullundefined 以及任何其它类型。 TypeScript 现在能够识别出这种情况,允许将 unknown 赋值给 {} | null | undefined

译者注:除 nullundefined 类型外,其它任何类型都可以赋值给 {} 类型。

function f(x: unknown, y: {} | null | undefined) {
  x = y; // 可以工作
  y = x; // 以前会报错,现在可以工作
}

另一个变化是 {} 与任何其它对象类型交叉会得到那个对象类型。 因此,我们可以重写 NonNullable 类型为与 {} 的交叉类型, 因为 {} & null{} & undefined 会被消掉。

- type NonNullable<T> = T extends null | undefined ? never : T;
+ type NonNullable<T> = T & {};

之所以称其为一项改进,是因为交叉类型可以被化简和赋值了, 而有条件类型目前是不支持的。 因此,NonNullable<NonNullable<T>> 至少可以简化为 NonNullable<T>,在以前这是不行的。

function foo<T>(x: NonNullable<T>, y: NonNullable<NonNullable<T>>) {
  x = y; // 一直没问题
  y = x; // 以前会报错,现在没问题
}

这些变化还为我们带来了更合理的控制流分析和类型细化。 比如,unknown 在条件为“真”的分支中被细化为 {} | null | undefined

function narrowUnknownishUnion(x: {} | null | undefined) {
  if (x) {
    x; // {}
  } else {
    x; // {} | null | undefined
  }
}

function narrowUnknown(x: unknown) {
  if (x) {
    x; // 以前是 'unknown',现在是 '{}'
  } else {
    x; // unknown
  }
}

泛型也会进行类似的细化。 当检查一个值不为 nullundefined 时, TypeScript 会将其与 {} 进行交叉 - 等同于使用 NonNullable。 把所有变化放在一起,我们就可以在不使用类型断言的情况下定义下列函数。

function throwIfNullable<T>(value: T): NonNullable<T> {
  if (value === undefined || value === null) {
    throw Error('Nullable value!');
  }

  // 以前会报错,因为 'T' 不能赋值给 'NonNullable<T>'。
  // 现在会细化为 'T & {}' 并且不报错,因为它等同于 'NonNullable<T>'。
  return value;
}

value 细化为了 T & {},此时它与 NonNullable<T> 等同 - 因此在函数体中不再需要使用 TypeScript 的特定语法。

就该改进本身而言可能是一个很小的变化 - 但它却实实在在地修复了在过去几年中报告的大量问题。

更多详情,请参考这里

改进模版字符串类型中 infer 类型的类型推断

近期,TypeScript 支持了在有条件类型中的 infer 类型变量上添加 extends 约束。

// 提取元组类型中的第一个元素,若其能够赋值给 'number',
// 返回 'never' 若无这样的元素。
type TryGetNumberIfFirst<T> = T extends [infer U extends number, ...unknown[]]
  ? U
  : never;

infer 类型出现在模版字符串类型中且被原始类型所约束,则 TypeScript 会尝试将其解析为字面量类型。

// SomeNum 以前是 'number';现在是 '100'。
type SomeNum = '100' extends `${infer U extends number}` ? U : never;

// SomeBigInt 以前是 'bigint';现在是 '100n'。
type SomeBigInt = '100' extends `${infer U extends bigint}` ? U : never;

// SomeBool 以前是 'boolean';现在是 'true'。
type SomeBool = 'true' extends `${infer U extends boolean}` ? U : never;

现在它能更好地表达代码库在运行时的行为,提供更准确的类型。

要注意的一点是当 TypeScript 解析这些字面量类型时会使用贪心策略,尽可能多地提取原始类型; 然后再回头检查解析出的原始类型是否匹配字符串的内容。 也就是说,TypeScript 检查从字符串到原始类型再到字符串是否匹配。 如果发现字符串前后对不上了,那么回退到基本的原始类型。

// JustNumber 为 `number` 因为 TypeScript 解析 出 `"1.0"`,但 `String(Number("1.0"))` 为 `"1"` 不匹配。
type JustNumber = '1.0' extends `${infer T extends number}` ? T : never;

更多详情请参考这里

--build, --watch, 和 --incremental 的性能优化

TypeScript 4.8 优化了使用 --watch--incremental 时的速度,以及使用 --build 构建工程引用时的速度。 例如,现在在 --watch 模式下 TypeScript 不会去更新未改动文件的时间戳, 这使得重新构建更快,避免与其它监视 TypeScript 输出文件的构建工具之间产生干扰。 此外,TypeScript 也能够重用 --build, --watch--incremental 之间的信息。

这些优化有多大效果?在一个相当大的代码库上,对于简单常用的操作有 10%-25% 的改进,对于无改动操作的场景节省了 40% 的时间。 在 TypeScript 代码库中我们也看到了相似的结果。

更多详情请参考这里

比较对象和数组字面量时报错

在许多语言中,== 操作符在对象上比较的是“值”。 例如,在 Python 语言中想检查列表是否为空时可以使用 == 检查该值是否与空列表相等。

if people_at_home == []:
    print("that's where she lies, broken inside. </3")

在 JavaScript 里却不是这样,使用 ===== 比较对象和数组时比较的是引用。 我们确信这会让 JavaScript 程序员搬起石头砸自己脚,且最坏的情况是在生产环境中存在 bug。 因此,TypeScript 现在不允许如下的代码:

let peopleAtHome = [];

if (peopleAtHome === []) {
  //  ~~~~~~~~~~~~~~~~~~~
  // This condition will always return 'false' since JavaScript compares objects by reference, not value.
  console.log("that's where she lies, broken inside. </3");
}

非常感谢Jack Works的贡献。 更多详情请参考这里

改进从绑定模式中进行类型推断

在某些情况下,TypeScript 会从绑定模式中获取类型来帮助类型推断。

declare function chooseRandomly<T>(x: T, y: T): T;

let [a, b, c] = chooseRandomly([42, true, 'hi!'], [0, false, 'bye!']);
//   ^  ^  ^
//   |  |  |
//   |  |  string
//   |  |
//   |  boolean
//   |
//   number

chooseRandomly 需要确定 T 的类型时,它主要检查 [42, true, "hi!"][0, false, "bye!"]; 但 TypeScript 还需要确定这两个类型是 Array<number | boolean | string> 还是 [number, boolean, string]。 为此,它会检查当前类型推断候选列表中是否存在元组类型。 当 TypeScript 看到了绑定模式 [a, b, c],它创建了类型 [any, any, any], 该类型会被加入到 T 的候选列表(作为推断 [42, true, "hi!"][0, false, "bye!"] 的参考)但优先级较低。

这对 chooseRandomly 函数来讲不错,但在有些情况下不合适。例如:

declare function f<T>(x?: T): T;

let [x, y, z] = f();

绑定模式 [x, y, z] 提示 f 应该输出 [any, any, any] 元组; 但是 f 不应该根据绑定模式来改变类型参数的类型。 它不应该像变戏法一样根据被赋的值突然变成一个类数组的值, 因此绑定模式过多地影响到了生成的类型。 由于绑定模式中均为 any 类型,因此我们也就让 xyzany 类型。

在 TypeScript 4.8 里,绑定模式不会成为类型参数的候选类型。 它们仅在参数需要更确切的类型时提供参考,例如 chooseRandomly 的情况。 如果你想回到之前的行为,可以提供明确的类型参数。

更多详情请参考这里

修复文件监视(尤其是在 git checkout 之间)

长久以来 TypeScript 中存在一个 bug,它对在编辑器中使用 --watch 模式监视文件改动处理的不好。 它有时表现为错误提示不准确,需要重启 tsc 或 VS Code 才行。 这在 Unix 系统上常发生,例如用 vim 保存了一个文件或切换了 git 的分支。

这是因为错误地假设了 Node.js 在不同文件系统下处理文件重命名的方式。 Linux 和 macOS 使用 inodesNode.js 监视的是 inodes 的变化而非文件路径。 因此,当 Node.js 返回了 watcher 对象, 根据平台和文件系统的不同,它即可能监视文件路径也可能是 inode。

为了高效,TypeScript 尝试重用 watcher 对象,如果它检测到文件路径仍存在于磁盘上。 这里就产生了问题,因为即使给定路径上的文件仍然存在,但它可能是全新创建的文件,inode 已经发生了变化。 TypeScript 重用了 watcher 对象而非重新创建一个 watcher 对象,因此可能监视了一个完全不相关的文件。 TypeScript 4.8 能够在 inode 系统上处理这些情况,新建 watcher 对象。

非常感谢 Marc Celani 和他的团队的贡献。 更多详情请参考这里

查找所有引用性能优化

在编辑器中执行“查找所有引用”时,TypeScript 现在能够更智能地聚合引用。 在 TypeScript 自己的代码库中去搜索一个广泛使用的标识符时能够减少 20% 时间。

更多详情请参考这里

从自动导入中排除指定文件

TypeScript 4.8 增加了一个编辑器首选项从自动导入中排除指定文件。 在 Visual Studio Code 里,可以将文件名和 globs 添加到 Settings UI 的 “Auto Import File Exclude Patterns” 下,或者 .vscode/settings.json 文件中:

{
  // Note that `javascript.preferences.autoImportFileExcludePatterns` can be specified for JavaScript too.
  "typescript.preferences.autoImportFileExcludePatterns": [
    "**/node_modules/@types/node"
  ]
}

如果你想避免导入某些模块或代码库,它个功能就派上用场了。 有些模块可能有过多的导出以致于影响到了自动导入功能,让我们难以选择一条自动导入。

更多详情请参考这里

TypeScript 4.7

Node.js 对 ECMAScript Module 的支持

在过去的几年中,Node.js 为支持 ECMAScript 模块(ESM)而做了一些工作。 这是一项有难度的工作,因为 Node.js 生态圈是基于 CommonJS(CJS)模块系统构建的,而非 ESM。 支持两者之间的互操作带来了巨大挑战,有大量的特性需要考虑; 然而,在 Node.js 12 及以上版本中,已经提供了对 ESM 的大部分支持。 在 TypeScript 4.5 期间的一个 nightly 版本中支持了在 Node.js 里使用 ESM 以获得用户反馈, 同时让代码库作者们有时间为此提前作准备。

TypeScript 4.7 正式地支持了该功能,它添加了两个新的 module 选项:node16nodenext

{
  "compilerOptions": {
    "module": "node16"
  }
}

这些新模式带来了一些高级特征,下面将一一介绍。

package.json 里的 type 字段和新的文件扩展名

Node.js 在 package.json 中支持了一个新的设置,叫做 type"type" 可以被设置为 "module" 或者 "commonjs"

{
  "name": "my-package",
  "type": "module",

  "//": "...",
  "dependencies": {}
}

这些设置会控制 .js 文件是作为 ESM 进行解析还是作为 CommonJS 模块进行解析, 若没有设置,则默认值为 CommonJS。 当一个文件被当做 ESM 模块进行解析时,会使用如下与 CommonJS 模块不同的规则:

  • 允许使用 import / export 语句
  • 允许使用顶层的 await
  • 相对路径导入必须提供完整的扩展名(需要使用 import "./foo.js" 而非 import "./foo"
  • 解析 node_modules 里的依赖可能不同
  • 不允许直接使用像 requiremodule 这样的全局值
  • 需要使用特殊的规则来导入 CommonJS 模块

我们回头会介绍其中一部分。

为了让 TypeScript 融入该系统,.ts.tsx 文件现在也以同样的方式工作。 当 TypeScript 遇到 .ts.tsx.js.jsx 文件时, 它会向上查找 package.json 来确定该文件是否使用了 ESM,然后再以此决定:

  • 如何查找该文件所导入的其它模块
  • 当需要产生输出的时,如何转换该文件

当一个 .ts 文件被编译为 ESM 时,ECMAScript import / export 语句在生成的 .js 文件中原样输出; 当一个 .ts 文件被编译为 CommonJS 模块时,则会产生与使用了 --module commonjs 选项一致的输出结果。

这也意味着 ESM 和 CJS 模块中的 .ts 文件路径解析是不同的。 例如,现在有如下的代码:

// ./foo.ts
export function helper() {
  // ...
}

// ./bar.ts
import { helper } from './foo'; // only works in CJS

helper();

这段代码在 CommonJS 模块里没问题,但在 ESM 里会出错,因为相对导入需要使用完整的扩展名。 因此,我们不得不重写代码并使用 foo.ts 输出文件的扩展名,bar.ts 必须从 ./foo.js 导入。

// ./bar.ts
import { helper } from './foo.js'; // works in ESM & CJS

helper();

初看可能感觉很繁琐,但 TypeScript 的自动导入工具以及路径补全工具会有所帮助。

此外还需要注意的是该行为同样适用于 .d.ts 文件。 当 TypeScript 在一个 package 里找到了 .d.ts 文件,它会基于这个 package 来解析 .d.ts 文件。

新的文件扩展名

package.json 文件里的 type 字段让我们可以继续使用 .ts.js 文件扩展名; 但你可能偶尔需要编写与 type 设置不符的文件,或者更喜欢明确地表达意图。

为此,Node.js 支持了两个文件扩展名:.mjs.cjs.mjs 文件总是使用 ESM,而 .cjs 则总是使用 CommonJS 模块, 它们分别会生成 .mjs.cjs 文件。

正因此,TypeScript 也支持了两个新的文件扩展名:.mts.cts。 当 TypeScript 生成 JavaScript 文件时,将生成 .mjs.cjs

TypeScript 还支持了两个新的声明文件扩展名:.d.mts.d.cts。 当 TypeScript 为 .mts.cts 生成声明文件时,对应的扩展名为 .d.mts.d.cts

这些扩展名的使用完全是可选的,但通常是有帮助的,不论它们是不是你工作流中的一部分。

CommonJS 互操作性

Node.js 允许 ESM 导入 CommonJS 模块,就如同它们是带有默认导出的 ESM。

// ./foo.cts
export function helper() {
  console.log('hello world!');
}

// ./bar.mts
import foo from './foo.cjs';

// prints "hello world!"
foo.helper();

在某些情况下,Node.js 会综合和合成 CommonJS 模块里的命名导出,这提供了便利。 此时,ESM 既可以使用“命名空间风格”的导入(例如,import * as foo from "..."), 也可以使用命名导入(例如,import { helper } from "...")。

// ./foo.cts
export function helper() {
  console.log('hello world!');
}

// ./bar.mts
import { helper } from './foo.cjs';

// prints "hello world!"
helper();

有时候 TypeScript 不知道命名导入是否会被综合合并,但如果 TypeScript 能够通过确定地 CommonJS 模块导入了解到该信息,那么就会提示错误。

关于互操作性,TypeScript 特有的注意点是如下的语法:

import foo = require('foo');

在 CommonJS 模块中,它可以归结为 require() 调用, 在 ESM 里,它会导入 createRequire 来完成同样的事情。 对于像浏览器这样的平台(不支持 require())这段代码的可移植性较差,但对互操作性是有帮助的。 你可以这样改写:

// ./foo.cts
export function helper() {
  console.log('hello world!');
}

// ./bar.mts
import foo = require('./foo.cjs');

foo.helper();

最后值得注意的是在 CommonJS 模块里导入 ESM 的唯一方法是使用动态 import() 调用。 这也许是一个挑战,但也是目前 Node.js 的行为。

更多详情,请阅读这里

package.json 中的 exports, imports 以及自引用

Node.js 在 package.json 支持了一个新的字段 exports 来定义入口位置。 它比在 package.json 里定义 "main" 更强大,它能控制将包里的哪些部分公开给使用者。

下例的 package.json 支持对 CommonJS 和 ESM 使用不同的入口位置:

// package.json
{
  "name": "my-package",
  "type": "module",
  "exports": {
    ".": {
      // Entry-point for `import "my-package"` in ESM
      "import": "./esm/index.js",

      // Entry-point for `require("my-package") in CJS
      "require": "./commonjs/index.cjs"
    }
  },

  // CJS fall-back for older versions of Node.js
  "main": "./commonjs/index.cjs"
}

关于该特性的更多详情请阅读这里。 下面我们主要关注 TypeScript 是如何支持它的。

在以前 TypeScript 会先查找 "main" 字段,然后再查找其对应的声明文件。 例如,如果 "main" 指向了 ./lib/index.js, TypeScript 会查找名为 ./lib/index.d.ts 的文件。 代码包作者可以使用 "types" 字段来控制该行为(例如,"types": "./types/index.d.ts")。

新实现的工作方式与导入条件相似。 默认地,TypeScript 使用与导入条件相同的规则 - 对于 ESM 里的 import 语句,它会查找 import 字段; 对于 CommonJS 模块里的 import 语句,它会查找 require 字段。 如果找到了文件,则去查找相应的声明文件。 如果你想将声明文件指向其它位置,则可以添加一个 "types" 导入条件。

// package.json
{
  "name": "my-package",
  "type": "module",
  "exports": {
    ".": {
      // Entry-point for `import "my-package"` in ESM
      "import": {
        // Where TypeScript will look.
        "types": "./types/esm/index.d.ts",

        // Where Node.js will look.
        "default": "./esm/index.js"
      },
      // Entry-point for `require("my-package") in CJS
      "require": {
        // Where TypeScript will look.
        "types": "./types/commonjs/index.d.cts",

        // Where Node.js will look.
        "default": "./commonjs/index.cjs"
      }
    }
  },

  // Fall-back for older versions of TypeScript
  "types": "./types/index.d.ts",

  // CJS fall-back for older versions of Node.js
  "main": "./commonjs/index.cjs"
}

注意"types" 条件在 "exports" 中需要被放在开始的位置。

TypeScript 也支持 package.json 里的 "imports" 字段,它与查找声明文件的工作方式类似。 此外,还支持一个包引用它自己。 这些特性通常不特殊设置,但是是支持的。

设置模块检测策略

在 JavaScript 中引入模块带来的一个问题是让“Script”代码和新的模块代码之间的界限变得模糊。 (译者注:对于任意一段 JavaScript 代码,它的类型只能为 “Script” 或 “Module” 两者之一,它们是 ECMAScript 语言规范中定义的术语。) 模块中的 JavaScript 存在些许不同的执行方式和作用域规则,因此工具们需要确定每个文件的执行方式。 例如,Node.js 要求模块入口脚本是一个 .mjs 文件,或者它有一个邻近的 package.json 文件且带有 "type": "module"。 TypeScript 的规则则是如果一个文件里存在 importexport 语句,那么它是模块文件; 反之会把 .ts.js 文件当作是 “Script” 文件,它们存在于全局作用域

这与 Node.js 中对 package.json 的处理行为不同,因为 package.json 可以改变文件的类型;又或者是在 --jsx react-jsx 模式下一个 JSX 文件显式地导入了 JSX 工厂函数。 它也与当下的期望不符,因为大多数的 TypeScript 代码是基于模块来编写的。

以上就是 TypeScript 4.7 引入了 moduleDetection. moduleDetection 选项的原因。 它接受三个值:

  1. "auto",默认值
  2. "legacy",行为与 TypeScript 4.6 和以前的版本相同
  3. "force"

"auto" 模式下,TypeScript 不但会检测 importexport 语句,它还会检测:

  • 若启用了 --module nodenext / --module node16,那么 package.json 里的 "type" 字段是否为 "module",以及
  • 若启用了 --jsx react-jsx,那么当前文件是否为 JSX 文件。

在这些情况下,我们想将每个文件都当作模块文件。

"force" 选项能够保证每个非声明文件都被当成模块文件,不论 modulemoduleResolutonjsx 是如何设置的。

与此同时,使用 "legacy" 选项会回退到以前的行为,仅通过检测 importexport 语句来决定是否为模块文件。

更多详情请阅读PR

[] 语法元素访问的控制流分析

在 TypeScript 4.7 里,当索引键值是字面量类型和 unique symbol 类型时会细化访问元素的类型。 例如,有如下代码:

const key = Symbol();

const numberOrString = Math.random() < 0.5 ? 42 : 'hello';

const obj = {
  [key]: numberOrString,
};

if (typeof obj[key] === 'string') {
  let str = obj[key].toUpperCase();
}

在之前,TypeScript 不会处理涉及 obj[key] 的类型守卫,也就不知道 obj[key] 的类型是 string。 它会将 obj[key] 当作 string | number 类型,因此调用 toUpperCase() 会产生错误。

TypeScript 4.7 能够知道 obj[key] 的类型为 string

这意味着在 --strictPropertyInitialization 模式下,TypeScript 能够正确地检查计算属性是否被初始化。

// 'key' has type 'unique symbol'
const key = Symbol();

class C {
  [key]: string;

  constructor(str: string) {
    // oops, forgot to set 'this[key]'
  }

  screamString() {
    return this[key].toUpperCase();
  }
}

在 TypeScript 4.7 里,--strictPropertyInitialization 会提示错误说 [key] 属性在构造函数里没有被赋值。

感谢 Oleksandr Tarasiuk 提交的代码

改进对象和方法里的函数类型推断

TypeScript 4.7 可以对数组和对象里的函数进行更精细的类型推断。 它们可以像普通参数那样将类型从左向右进行传递。

declare function f<T>(arg: {
  produce: (n: string) => T;
  consume: (x: T) => void;
}): void;

// Works
f({
  produce: () => 'hello',
  consume: x => x.toLowerCase(),
});

// Works
f({
  produce: (n: string) => n,
  consume: x => x.toLowerCase(),
});

// Was an error, now works.
f({
  produce: n => n,
  consume: x => x.toLowerCase(),
});

// Was an error, now works.
f({
  produce: function () {
    return 'hello';
  },
  consume: x => x.toLowerCase(),
});

// Was an error, now works.
f({
  produce() {
    return 'hello';
  },
  consume: x => x.toLowerCase(),
});

之所以有些类型推断之前会失败是因为,若要知道 produce 函数的类型则需要在找到合适的类型 T 之前间接地获得 arg 的类型。 (译者注:这些之前失败的情况均是需要进行按上下文件归类的场景,即需要先知道 arg 的类型,才能确定 produce 的类型;如果不需要执行按上下文归类就能确定 produce 的类型则没有问题。) TypeScript 现在会收集与泛型参数 T 的类型推断相关的函数,然后进行惰性地类型推断。

更多详情请阅读这里

实例化表达式

我们偶尔可能会觉得某个函数过于通用了。 例如有一个 makeBox 函数。

interface Box<T> {
  value: T;
}

function makeBox<T>(value: T) {
  return { value };
}

假如我们想要定义一组更具体的可以收纳扳手锤子Box 函数。 为此,我们将 makeBox 函数包装进另一个函数,或者明确地定义一个 makeBox 的类型别名。

function makeHammerBox(hammer: Hammer) {
  return makeBox(hammer);
}

// 或者

const makeWrenchBox: (wrench: Wrench) => Box<Wrench> = makeBox;

这样可以工作,但有些浪费且笨重。 理想情况下,我们可以在替换泛型参数的时候直接声明 makeBox 的别名。

TypeScript 4.7 支持了该特性! 我们现在可以直接为函数和构造函数传入类型参数。

const makeHammerBox = makeBox<Hammer>;
const makeWrenchBox = makeBox<Wrench>;

这样我们可以让 makeBox 只接受更具体的类型并拒绝其它类型。

const makeStringBox = makeBox<string>;

// TypeScript 会提示错误
makeStringBox(42);

这对构造函数也生效,例如 ArrayMapSet

// 类型为 `new () => Map<string, Error>`
const ErrorMap = Map<string, Error>;

// 类型为 `Map<string, Error>`
const errorMap = new ErrorMap();

当函数或构造函数接收了一个类型参数,它会生成一个新的类型并保持所有签名使用了兼容的类型参数列表, 将形式类型参数替换成给定的实际类型参数。 其它种类的签名会被丢弃,因为 TypeScript 认为它们不会被使用到。

更多详情请阅读这里

infer 类型参数上的 extends 约束

有条件类型有点儿像一个进阶功能。 它允许我们匹配并依据类型结构进行推断,然后作出某种决定。 例如,编写一个有条件类型,它返回元组类型的第一个元素如果它类似 string 类型的话。

type FirstIfString<T> = T extends [infer S, ...unknown[]]
  ? S extends string
    ? S
    : never
  : never;

// string
type A = FirstIfString<[string, number, number]>;

// "hello"
type B = FirstIfString<['hello', number, number]>;

// "hello" | "world"
type C = FirstIfString<['hello' | 'world', boolean]>;

// never
type D = FirstIfString<[boolean, number, string]>;

FirstIfString 匹配至少有一个元素的元组类型,将元组第一个元素的类型提取到 S。 然后检查 Sstring 是否兼容,如果是就返回它。

可以注意到我们必须使用两个有条件类型来实现它。 我们也可以这样定义 FirstIfString

type FirstIfString<T> = T extends [string, ...unknown[]]
  ? // Grab the first type out of `T`
    T[0]
  : never;

它可以工作但要更多的“手动”操作且不够形象。 我们不是进行类型模式匹配并给首个元素命名,而是使用 T[0] 来提取 T 的第 0 个元素。 如果我们处理的是比元组类型复杂得多的类型就会变得棘手,因此 infer 可以让事情变得简单。

使用嵌套的条件来推断类型再去匹配推断出的类型是很常见的。 为了省去那一层嵌套,TypeScript 4.7 允许在 infer 上应用约束。

type FirstIfString<T> = T extends [infer S extends string, ...unknown[]]
  ? S
  : never;

通过这种方式,在 TypeScript 去匹配 S 时,它也会保证 Sstring 类型。 如果 S 不是 string 就是进入到 false 分支,此例中为 never

更多详情请阅读这里

可选的类型参数变型注释

先看一下如下的类型。

interface Animal {
  animalStuff: any;
}

interface Dog extends Animal {
  dogStuff: any;
}

// ...

type Getter<T> = () => T;

type Setter<T> = (value: T) => void;

假设有两个不同的 Getter 实例。 要想知道这两个 Getter 实例是否可以相互替换完全依赖于类型 T。 例如要知道 Getter<Dog> → Getter<Animal> 是否允许,则需要检查 Dog → Animal 是否允许。 因为对 TGetter<T> 的判断是相同“方向”的,我们称 Getter协变的。 相反的,判断 Setter<Dog> → Setter<Animal> 是否允许,需要检查 Animal → Dog 是否允许。 这种在方向上的“翻转”有点像数学里判断 $−x < −y$ 等同于判断 $y < x$。 当我们需要像这样翻转方向来比较 T 时,我们称 Setter 对于 T逆变的。

在 TypeScript 4.7 里,我们可以明确地声明类型参数上的变型关系。

因此,现在如果想在 Getter 上明确地声明对于 T 的协变关系则可以使用 out 修饰符。

type Getter<out T> = () => T;

相似的,如果想要明确地声明 Setter 对于 T 是逆变关系则可以指定 in 修饰符。

type Setter<in T> = (value: T) => void;

使用 outin 的原因是类型参数的变型关系依赖于它们被用在输出的位置还是输入的位置。 若不思考变型关系,你也可以只关注 T 是被用在输出还是输入位置上。

当然也有同时使用 outin 的时候。

interface State<in out T> {
  get: () => T;
  set: (value: T) => void;
}

T 被同时用在输入和输出的位置上时就成为了不变关系。 两个不同的 State<T> 不允许互换使用,除非两者的 T 是相同的。 换句话说,State<Dog>State<Animal> 不能互换使用。

从技术上讲,在纯粹的结构化类型系统里,类型参数和它们的变型关系不太重要 - 我们只需要将类型参数替换为实际类型,然后再比较相匹配的类型成员之间是否兼容。 那么如果 TypeScript 使用结构化类型系统为什么我们要在意类型参数的变型呢? 还有为什么我们会想要为它们添加类型注释呢?

其中一个原因是可以让读者能够明确地知道类型参数是如何被使用的。 对于十分复杂的类型来讲,可能很难确定一个类型参数是用于输入或者输出再或者两者兼有。 如果我们忘了说明类型参数是如何被使用的,TypeScript 也会提示我们。 举个例子,如果忘了在 State 上添加 inout 就会产生错误。

interface State<out T> {
  //          ~~~~~
  // error!
  // Type 'State<sub-T>' is not assignable to type 'State<super-T>' as implied by variance annotation.
  //   Types of property 'set' are incompatible.
  //     Type '(value: sub-T) => void' is not assignable to type '(value: super-T) => void'.
  //       Types of parameters 'value' and 'value' are incompatible.
  //         Type 'super-T' is not assignable to type 'sub-T'.
  get: () => T;
  set: (value: T) => void;
}

另一个原因则有关精度和速度。 TypeScript 已经在尝试推断类型参数的变型并做为一项优化。 这样做可以快速对大型的结构化类型进行类型检查。 提前计算变型省去了深入结构内部进行兼容性检查的步骤, 仅比较类型参数相比于一次又一次地比较完整的类型结构会快得多。 但经常也会出现这个计算十分耗时,并且在计算时产生了环,从而无法得到准确的变型关系。

type Foo<T> = {
  x: T;
  f: Bar<T>;
};

type Bar<U> = (x: Baz<U[]>) => void;

type Baz<V> = {
  value: Foo<V[]>;
};

declare let foo1: Foo<unknown>;
declare let foo2: Foo<string>;

foo1 = foo2; // Should be an error but isn't ❌
foo2 = foo1; // Error - correct ✅

提供明确的类型注解能够加快对环状类型的解析速度,有利于提高准确度。 例如,将上例的 T 设置为逆变可以帮助阻止有问题的赋值运算。

- type Foo<T> = {
+ type Foo<in out T> = {
      x: T;
      f: Bar<T>;
  }

我们并不推荐为所有的类型参数都添加变型注解; 例如,我们是能够(但不推荐)将变型设置为更严格的关系(即便实际上不需要), 因此 TypeScript 不会阻止你将类型参数设置为不变,就算它们实际上是协变的、逆变的或者是分离的。 因此,如果你选择添加明确的变型标记,我们推荐要经过深思熟虑后准确地使用它们。

但如果你操作的是深层次的递归类型,尤其是作为代码库作者,那么你可能会对使用这些注解来让用户获利感兴趣。 这些注解能够帮助提高准确性和类型检查速度,甚至可以增强代码编辑的体验。 可以通过实验来确定变型计算是否为类型检查时间的瓶颈,例如使用像 analyze-trace 这样的工具。

更多详情请阅读这里

使用 moduleSuffixes 自定义解析策略

TypeScript 4.7 支持了 moduleSuffixes 选项来自定义模块说明符的查找方式。

{
    "compilerOptions": {
        "moduleSuffixes": [".ios", ".native", ""]
    }
}

对于上述配置,如果有如下的导入语句:

import * as foo from './foo';

它会尝试查找文件 ./foo.ios.ts./foo.native.ts 最后是 ./foo.ts

注意 moduleSuffixes 末尾的空字符串 "" 是必须的,只有这样 TypeScript 才会去查找 ./foo.ts。 也就是说,moduleSuffixes 的默认值是 [""]

这个功能对于 React Native 工程是很有用的,因为对于不同的目标平台会有不同的 tsconfig.jsonmoduleSuffixes

这个功能是由 Adam Foxman 贡献的!

resolution-mode

Node.js 的 ECMAScript 解析规则是根据当前文件所属的模式以及使用的语法来决定如何解析导入; 然而,在 ECMAScript 模块里引用 CommonJS 模块也是很常用的,或者反过来。

TypeScript 允许使用 /// <reference types="..." /> 指令。

/// <reference types="pkg" resolution-mode="require" />

// or

/// <reference types="pkg" resolution-mode="import" />

此外,在 Nightly 版本的 TypeScript 里,import type 可以指定导入断言来达到同样的目的。

// Resolve `pkg` as if we were importing with a `require()`
import type { TypeFromRequire } from 'pkg' assert { 'resolution-mode': 'require' };

// Resolve `pkg` as if we were importing with an `import`
import type { TypeFromImport } from 'pkg' assert { 'resolution-mode': 'import' };

export interface MergedType extends TypeFromRequire, TypeFromImport {}

这些断言也可以用在 import() 类型上。

export type TypeFromRequire = import('pkg').TypeFromRequire;

export type TypeFromImport = import('pkg').TypeFromImport;

export interface MergedType extends TypeFromRequire, TypeFromImport {}

import typeimport() 语法仅在 Nightly 版本里支持 resolution-mode。 你可能会看到如下的错误:

Resolution mode assertions are unstable.
Use nightly TypeScript to silence this error.
Try updating with 'npm install -D typescript@next'.

如果你在 TypeScript 的 Nightly 版本中使用了该功能,别忘了可以提供反馈

更多详情请查看 PR: 引用指令PR: 类型导入断言

跳转到在源码中的定义

TypeScript 4.7 支持了一个实验性的编辑器功能叫作 Go To Source Definition (跳转到在源码中的定义)。 它和 Go To Definition (跳转到定义)相似,但不是跳转到声明文件中。 而是查找相应的实现文件(比如 .js.ts 文件),并且在那里查找定义 - 即便这些文件总是会被声明文件 .d.ts 所遮蔽。

当你想查看导入的三方库的函数实现而不是 .d.ts 声明文件时是很便利的。

你可以在最新版本的 Visual Studio Code 里试用该功能。 但该功能还是预览版,存在一些已知的限制。 在某些情况下 TypeScript 使用启发式的方法来猜测函数定义的代码在哪个 .js 文件中, 因此结果可能不太精确。 Visual Studio Code 也不会提示哪些结果是通过猜测得到的,但我们正在实现它。

更多详情请参考 PR

分组整理导入语句

TypeScript 为 JavaScript 和 TypeScript 提供了叫做 “Organize Imports” (整理导入语句)编辑器功能。 可是,它的行为有点简单粗暴,它直接排序所有的导入语句。

例如,在如下的代码上使用 “Organize Imports”:

// local code
import * as bbb from './bbb';
import * as ccc from './ccc';
import * as aaa from './aaa';

// built-ins
import * as path from 'path';
import * as child_process from 'child_process';
import * as fs from 'fs';

// some code...

你会得到:

// local code
import * as child_process from 'child_process';
import * as fs from 'fs';
// built-ins
import * as path from 'path';
import * as aaa from './aaa';
import * as bbb from './bbb';
import * as ccc from './ccc';

// some code...

这不是我们想要的。 尽管导入语句已经按它们的路径排序了,并且注释和折行被保留了, 但仍不是我们期望的。

TypeScript 4.7 在 “Organize Imports” 时会考虑分组。 再次在上例代码上执行 “Organize Imports” 会得到期望的结果:

// local code
import * as aaa from './aaa';
import * as bbb from './bbb';
import * as ccc from './ccc';

// built-ins
import * as child_process from 'child_process';
import * as fs from 'fs';
import * as path from 'path';

// some code...

感谢 Minh QuyPR

TypeScript 4.6

允许在构造函数中的 super() 调用之前插入代码

在 JavaScript 的类中,在引用 this 之前必须先调用 super()。 在 TypeScript 中同样有这个限制,只不过在检查时过于严格。 在之前版本的 TypeScript 中,如果类中存在属性初始化器, 那么在构造函数里,在 super() 调用之前不允许出现任何其它代码。

class Base {
  // ...
}

class Derived extends Base {
  someProperty = true;

  constructor() {
    // 错误!
    // 必须先调用 'super()' 因为需要初始化 'someProperty'。
    doSomeStuff();
    super();
  }
}

这样做是因为程序实现起来容易,但这样做也会拒绝很多合法的代码。 TypeScript 4.6 放宽了限制,它允许在 super() 之前出现其它代码, 与此同时仍然会检查在引用 this 之前顶层的super() 已经被调用。

感谢 Joshua GoldbergPR

基于控制流来分析解构的可辨识联合类型

TypeScript 可以根据判别式属性来细化类型。 例如,在下面的代码中,TypeScript 能够在检查 kind 的类型后细化 action 的类型。

type Action =
  | { kind: 'NumberContents'; payload: number }
  | { kind: 'StringContents'; payload: string };

function processAction(action: Action) {
  if (action.kind === 'NumberContents') {
    // `action.payload` is a number here.
    let num = action.payload * 2;
    // ...
  } else if (action.kind === 'StringContents') {
    // `action.payload` is a string here.
    const str = action.payload.trim();
    // ...
  }
}

这样就可以使用持有不同数据的对象,但通过共同的字段来区分它们。

这在 TypeScript 是很常见的;然而,根据个人的喜好,你可能想对上例中的 kindpayload 进行解构。 就像下面这样:

type Action =
  | { kind: 'NumberContents'; payload: number }
  | { kind: 'StringContents'; payload: string };

function processAction(action: Action) {
  const { kind, payload } = action;
  if (kind === 'NumberContents') {
    let num = payload * 2;
    // ...
  } else if (kind === 'StringContents') {
    const str = payload.trim();
    // ...
  }
}

此前,TypeScript 会报错 - 当 kindpayload 是由同一个对象解构为变量时,它们会被独立对待。

在 TypeScript 4.6 中可以正常工作!

当解构独立的属性为 const 声明,或当解构参数到变量且没有重新赋值时,TypeScript 会检查被解构的类型是否为可辨识联合。 如果是的话,TypeScript 就能够根据类型检查来细化变量的类型。 因此上例中,通过检查 kind 的类型可以细化 payload 的类型。

更多详情请查看 PR

改进的递归深度检查

TypeScript 要面对一些有趣的挑战,因为它是构建在结构化类型系统之上,同时又支持了泛型。

在结构化类型系统中,对象类型的兼容性是由对象包含的成员决定的。

interface Source {
  prop: string;
}

interface Target {
  prop: number;
}

function check(source: Source, target: Target) {
  target = source;
  // error!
  // Type 'Source' is not assignable to type 'Target'.
  //   Types of property 'prop' are incompatible.
  //     Type 'string' is not assignable to type 'number'.
}

SourceTarget 的兼容性取决于它们的属性是否可以执行赋值操作。 此例中是指 prop 属性。

当引入了泛型后,有一些难题需要解决。 例如,下例中的 Source<string> 是否可以赋值给 Target<number>

interface Source<T> {
  prop: Source<Source<T>>;
}

interface Target<T> {
  prop: Target<Target<T>>;
}

function check(source: Source<string>, target: Target<number>) {
  target = source;
}

要想回答这个问题,TypeScript 需要检查 prop 的类型是否兼容。 这又要回答另一个问题:Source<Source<string>> 是否能够赋值给 Target<Target<number>>? 要想回答这个问题,TypeScript 需要检查 prop 的类型是否与那些类型兼容, 结果就是还要检查 Source<Source<Source<string>>> 是否能够赋值给 Target<Target<Target<number>>>? 继续发展下去,就会注意到类型会进行无限展开。

TypeScript 使用了启发式的算法 - 当一个类型达到特定的检查深度时,它表现出了将会进行无限展开, 那么就认为它可能是兼容的。 通常情况下这是没问题的,但是也可能出现漏报的情况。

interface Foo<T> {
  prop: T;
}

declare let x: Foo<Foo<Foo<Foo<Foo<Foo<string>>>>>>;
declare let y: Foo<Foo<Foo<Foo<Foo<string>>>>>;

x = y;

通过人眼观察我们知道上例中的 xy 是不兼容的。 虽然类型的嵌套层次很深,但人家就是这样声明的。 启发式算法要处理的是在探测类型过程中生成的深层次嵌套类型,而非程序员明确手写出的类型。

TypeScript 4.6 现在能够区分出这类情况,并且对上例进行正确的错误提示。 此外,由于不再担心会对明确书写的类型进行误报, TypeScript 能够更容易地判断类型的无限展开, 并且降低了类型兼容性检查的成本。 因此,像 DefinitelyTyped 上的 redux-immutablereact-lazylogyup 代码库,对它们的类型检查时间降低了 50%。

你可能已经体验过这个改动了,因为它被挑选合并到了 TypeScript 4.5.3 中, 但它仍然是 TypeScript 4.6 中值得关注的一个特性。 更多详情请阅读 PR

索引访问类型推断改进

TypeScript 现在能够正确地推断通过索引访问到另一个映射对象类型的类型。

interface TypeMap {
  number: number;
  string: string;
  boolean: boolean;
}

type UnionRecord<P extends keyof TypeMap> = {
  [K in P]: {
    kind: K;
    v: TypeMap[K];
    f: (p: TypeMap[K]) => void;
  };
}[P];

function processRecord<K extends keyof TypeMap>(record: UnionRecord<K>) {
  record.f(record.v);
}

// 这个调用之前是有问题的,但现在没有问题
processRecord({
  kind: 'string',
  v: 'hello!',

  // 'val' 之前会隐式地获得类型 'string | number | boolean',
  // 但现在会正确地推断为类型 'string'。
  f: val => {
    console.log(val.toUpperCase());
  },
});

该模式已经被支持了并允许 TypeScript 判断 record.f(record.v) 调用是合理的, 但是在以前,processRecord 调用中对 val 的类型推断并不好。

TypeScript 4.6 改进了这个情况,因此在启用 processRecord 时不再需要使用类型断言。

更多详情请阅读 PR

对因变参数的控制流分析

函数签名可以声明为剩余参数且其类型可以为可辨识联合元组类型。

function func(...args: ['str', string] | ['num', number]) {
  // ...
}

这意味着 func 的实际参数完全依赖于第一个实际参数。 若第一个参数为字符串 "str" 时,则第二个参数为 string 类型。 若第一个参数为字符串 "num" 时,则第二个参数为 number 类型。

像这样 TypeScript 是由签名来推断函数类型时,TypeScript 能够根据依赖的参数来细化类型。

type Func = (...args: ['a', number] | ['b', string]) => void;

const f1: Func = (kind, payload) => {
  if (kind === 'a') {
    payload.toFixed(); // 'payload' narrowed to 'number'
  }
  if (kind === 'b') {
    payload.toUpperCase(); // 'payload' narrowed to 'string'
  }
};

f1('a', 42);
f1('b', 'hello');

更多详情请阅读 PR

--target es2022

TypeScript 的 --target 编译选项现在支持使用 es2022。 这意味着像类字段这样的特性能够稳定地在输出结果中保留。 这也意味着像 Arrays 的上 at() 和 Object.hasOwn 方法 或者 new Error 时的 cause 选项 可以通过设置新的 --target 或者 --lib es2022 来使用。

感谢 Kagami Sascha Rosylight (saschanaz)实现

删除 react-jsx 中不必要的参数

在以前,当使用 --jsx react-jsx 来编译如下的代码时

export const el = <div>foo</div>;

TypeScript 会生成如下的 JavaScript 代码:

import { jsx as _jsx } from 'react/jsx-runtime';
export const el = _jsx('div', { children: 'foo' }, void 0);

末尾的 void 0 参数是没用的,删掉它会减小打包的体积。

感谢 https://github.com/a-tarasyukPR,TypeScript 4.6 会删除 void 0 参数。

JSDoc 命名建议

在 JSDoc 里,你可以用 @param 标签来文档化参数。

/**
 * @param x The first operand
 * @param y The second operand
 */
function add(x, y) {
  return x + y;
}

但是,如果这些注释已经过时了会发生什么?就比如,我们将 xy 重命名为 ab

/**
 * @param x {number} The first operand
 * @param y {number} The second operand
 */
function add(a, b) {
  return a + b;
}

在之前 TypeScript 仅会在对 JavaScript 文件执行类型检查时报告这个问题 - 通过 使用 checkJs 选项,或者在文件顶端添加 // @ts-check 注释。

现在,你能够在编译器中的 TypeScript 文件上看到类似的提示! TypeScript 现在会给出建议,如果函数签名中的参数名与 JSDoc 中的参数名不一致。

example

改动是由 Alexander Tarasyuk 提供的!

JavaScript 中更多的语法和绑定错误提示

TypeScript 将更多的语法和绑定错误检查应用到了 JavaScript 文件上。 如果你在 Visual Studio 或 Visual Studio Code 这样的编辑器中打开 JavaScript 文件时就会看到这些新的错误提示, 或者当你使用 TypeScript 编译器来处理 JavaScript 文件时 - 即便你没有打开 checkJs 或者添加 // @ts-check 注释。

做为例子,如果在 JavaScript 文件中的同一个作用域中有两个同名的 const 声明, 那么 TypeScript 会报告一个错误。

const foo = 1234;
//    ~~~
// error: Cannot redeclare block-scoped variable 'foo'.

// ...

const foo = 5678;
//    ~~~
// error: Cannot redeclare block-scoped variable 'foo'.

另外一个例子,TypeScript 会报告修饰符是否被正确地使用了。

function container() {
  export function foo() {
    //  ~~~~~~
    // error: Modifiers cannot appear here.
  }
}

这些检查可以通过在文件顶端添加 // @ts-nocheck 注释来禁用, 但是我们很想听听在大家的 JavaScript 工作流中使用该特性的反馈。 你可以在 Visual Studio Code 安装 TypeScript 和 JavaScript Nightly 扩展 来提前体验, 并阅读 PR1PR1

TypeScript Trace 分析器

有人偶尔会遇到创建和比较类型时很耗时的情况。 TypeScript 提供了一个 --generateTrace 选项来帮助识别耗时的类型, 或者帮助诊断 TypeScript 编译器中的问题。 虽说由 --generateTrace 生成的信息是非常有帮助的(尤其是在 TypeScript 4.6 的改进后), 但是阅读这些 trace 信息是比较难的。

近期,我们发布了 @typescript/analyze-trace 工具来帮助阅读这些信息。 虽说我们不认为每个人都需要使用 analyze-trace,但是我们认为它会为遇到了 TypeScript 构建性能问题的团队提供帮助。

更多详情请查看 repo

TypeScript 4.5

支持从 node_modules 里读取 lib

为确保对 TypeScript 和 JavaScript 的支持可以开箱即用,TypeScript 内置了一些声明文件(.d.ts)。 这些声明文件描述了 JavaScript 语言中可用的 API,以及标准的浏览器 DOM API。 虽说 TypeScript 会根据工程中 target 的设置来提供默认值,但你仍然可以通过在 tsconfig.json 文件中设置 lib 来指定包含哪些声明文件。

TypeScript 包含的声明文件偶尔也会成为缺点:

  • 在升级 TypeScript 时,你必须要处理 TypeScript 内置声明文件的升级带来的改变,这可能成为一项挑战,因为 DOM API 的变动十分频繁。
  • 难以根据你的需求以及工程依赖的需求去定制声明文件(例如,工程依赖声明了需要使用 DOM API,那么你可能也必须要使用 DOM API)。

TypeScript 4.5 引入了覆盖特定内置 lib 的方式,它与 @types/ 的工作方式类似。 在决定应包含哪些 lib 文件时,TypeScript 会先去检查 node_modules 下面的 @typescript/lib-* 包。 例如,若将 dom 作为 lib 中的一项,那么 TypeScript 会尝试使用 node_modules/@typescript/lib-dom

然后,你就可以使用包管理器去安装特定的包作为 lib 中的某一项。 例如,现在 TypeScript 会将 DOM API 发布到 @types/web。 如果你想要给工程指定一个固定版本的 DOM API,你可以在 package.json 文件中添加如下代码:

{
  "dependencies": {
    "@typescript/lib-dom": "npm:@types/web"
  }
}

从 4.5 版本开始,你可以更新 TypeScript 和依赖管理工具生成的锁文件来确保使用固定版本的 DOM API。 你可以根据自己的情况来逐步更新类型声明。

十分感谢 saschanaz 提供的帮助。

更多详情,请参考 PR

改进 Awaited 类型和 Promise

TypeScript 4.5 引入了一个新的 Awaited 类型。 该类型用于描述 async 函数中的 await 操作,或者 Promise 上的 .then() 方法 - 尤其是递归地解开 Promise 的行为。

// A = string
type A = Awaited<Promise<string>>;

// B = number
type B = Awaited<Promise<Promise<number>>>;

// C = boolean | number
type C = Awaited<boolean | Promise<number>>;

Awaited 有助于描述现有 API,比如 JavaScript 内置的 Promise.allPromise.race 等等。 实际上,正是涉及 Promise.all 的类型推断问题促进了 Awaited 类型的产生。 例如,下例中的代码在 TypeScript 4.4 及之前的版本中会失败。

declare function MaybePromise<T>(value: T): T | Promise<T> | PromiseLike<T>;

async function doSomething(): Promise<[number, number]> {
  const result = await Promise.all([MaybePromise(100), MaybePromise(200)]);

  // 错误!
  //
  //    [number | Promise<100>, number | Promise<200>]
  //
  // 不能赋值给类型
  //
  //    [number, number]
  return result;
}

现在,Promise.all 结合并利用 Awaited 来提供更好的类型推断结果,同时上例中的代码也不再有错误。

更多详情,请参考 PR

模版字符串类型作为判别式属性

TypeScript 4.5 可以对模版字符串类型的值进行细化,同时可以识别模版字符串类型的判别式属性。

例如,下面的代码在以前会出错,但在 TypeScript 4.5 里没有错误。

export interface Success {
  type: `${string}Success`;
  body: string;
}

export interface Error {
  type: `${string}Error`;
  message: string;
}

export function handler(r: Success | Error) {
  if (r.type === 'HttpSuccess') {
    // 'r' 的类型为 'Success'
    let token = r.body;
  }
}

更多详情,请参考 PR

module es2022

感谢 Kagami S. Rosylight,TypeScript 现在支持了一个新的 module 设置:es2022module es2022 的主要功能是支持顶层的 await,即可以在 async 函数外部使用 await。 该功能在 --module esnext 里已经被支持了(现在又增加了 --module nodenext),但 es2022 是支持该功能的首个稳定版本。

更多详情,请参考 PR

在条件类型上消除尾递归

当 TypeScript 检测到了以下情况时通常需要优雅地失败,比如无限递归、极其耗时以至影响编辑器使用体验的类型展开操作。 因此,TypeScript 会使用试探式的方法来确保它在试图拆分一个无限层级的类型时或操作将生成大量中间结果的类型时不会偏离轨道。

type InfiniteBox<T> = { item: InfiniteBox<T> };

type Unpack<T> = T extends { item: infer U } ? Unpack<U> : T;

// error: Type instantiation is excessively deep and possibly infinite.
type Test = Unpack<InfiniteBox<number>>;

上例是有意写成简单且没用的类型,但是存在大量有用的类型恰巧会触发试探。 作为示例,下面的 TrimLeft 类型会从字符串类型的开头删除空白。 若给定一个在开头位置有一个空格的字符串类型,它会直接将空格后面的字符串再传入 TrimLeft

type TrimLeft<T extends string> = T extends ` ${infer Rest}`
  ? TrimLeft<Rest>
  : T;

// Test = "hello" | "world"
type Test = TrimLeft<'   hello' | ' world'>;

这个类型也许有用,但如果字符串起始位置有 50 个空格,就会产生错误。

type TrimLeft<T extends string> = T extends ` ${infer Rest}`
  ? TrimLeft<Rest>
  : T;

// error: Type instantiation is excessively deep and possibly infinite.
type Test = TrimLeft<'                                                oops'>;

这很讨厌,因为这种类型在表示字符串操作时很有用 - 例如,URL 路由解析器。 更差的是,越有用的类型越会创建更多的实例化类型,结果就是对输入参数会有限制。

但也有一个可取之处:TrimLeft 在一个分支中使用了尾递归的方式编写。 当它再次调用自己时,是直接返回了结果并且不存在后续操作。 由于这些类型不需要创建中间结果,因此可以被更快地实现并且可以避免触发 TypeScript 内置的类型递归试探。

这就是 TypeScript 4.5 在条件类型上删除尾递归的原因。 只要是条件类型的某个分支为另一个条件类型,TypeScript 就不会去生成中间类型。 虽说仍然会进行一些试探来确保类型没有偏离方向,但已无伤大雅。

注意,下面的类型不会被优化,因为它使用了包含条件类型的联合类型。

type GetChars<S> = S extends `${infer Char}${infer Rest}`
  ? Char | GetChars<Rest>
  : never;

如果你想将它改成尾递归,可以引入帮助类型来接收一个累加类型的参数,就如同尾递归函数一样。

type GetChars<S> = GetCharsHelper<S, never>;
type GetCharsHelper<S, Acc> = S extends `${infer Char}${infer Rest}`
  ? GetCharsHelper<Rest, Char | Acc>
  : Acc;

更多详情,请参考 PR

禁用导入省略

在某些情况下,TypeScript 无法检测导入是否被使用。 例如,考虑下面的代码:

import { Animal } from './animal.js';

eval('console.log(new Animal().isDangerous())');

默认情况下,TypeScript 会删除上面的导入语句,因为它看上去没有被使用。 在 TypeScript 4.5 里,你可以启用新的标记 preserveValueImports 来阻止 TypeScript 从生成的 JavaScript 代码里删除导入的值。 虽说应该使用 eval 的理由不多,但在 Svelte 框架里有相似的情况:

<!-- A .svelte File -->
<script>
  import { someFunc } from './some-module.js';
</script>

<button on:click="{someFunc}">Click me!</button>

同样在 Vue.js 中,使用 <script setup> 功能:

<!-- A .vue File -->
<script setup>
  import { someFunc } from './some-module.js';
</script>

<button @click="someFunc">Click me!</button>

这些框架会根据 <script> 标签外的标记来生成代码,但 TypeScript 仅仅会考虑 <script> 标签内的代码。 也就是说 TypeScript 会自动删除对 someFunc 的导入,因此上面的代码无法运行! 使用 TypeScript 4.5,你可以通过 preserveValueImports 来避免发生这种情况。

当该标记和 --isolatedModules` 一起使用时有个额外要求:导入的类型必须被标记为 type-only,因为编译器一次处理一个文件,无法知道是否导入了未被使用的值,或是导入了必须要被删除的类型以防运行时崩溃。

// Which of these is a value that should be preserved? tsc knows, but `ts.transpileModule`,
// ts-loader, esbuild, etc. don't, so `isolatedModules` gives an error.
import { someFunc, BaseType } from './some-module.js';
//                 ^^^^^^^^
// Error: 'BaseType' is a type and must be imported using a type-only import
// when 'preserveValueImports' and 'isolatedModules' are both enabled.

这催生了另一个 TypeScript 4.5 的功能,导入语句中的 type 修饰符,它尤其重要。

更多详情,请参考 PR

在导入名称前使用 type 修饰符

上面提到,preserveValueImportsisolatedModules 结合使用时有额外的要求,这是为了让构建工具能够明确知道是否可以省略导入语句。

// Which of these is a value that should be preserved? tsc knows, but `ts.transpileModule`,
// ts-loader, esbuild, etc. don't, so `isolatedModules` issues an error.
import { someFunc, BaseType } from './some-module.js';
//                 ^^^^^^^^
// Error: 'BaseType' is a type and must be imported using a type-only import
// when 'preserveValueImports' and 'isolatedModules' are both enabled.

当同时使用了这些选项时,需要有一种方式来表示导入语句是否可以被合法地丢弃。 TypeScript 已经有类似的功能,即 import type

import type { BaseType } from './some-module.js';
import { someFunc } from './some-module.js';

export class Thing implements BaseType {
  // ...
}

这是有效的,但还可以提供更好的方式来避免使用两条导入语句从相同的模块中导入。 因此,TypeScript 4.5 允许在每个命名导入前使用 type 修饰符,你可以按需混合使用它们。

import { someFunc, type BaseType } from './some-module.js';

export class Thing implements BaseType {
  someMethod() {
    someFunc();
  }
}

上例中,在 preserveValueImports 模式下,能够确定 BaseType 可以被删除,同时 someFunc 应该被保留,于是就会生成如下代码:

import { someFunc } from './some-module.js';

export class Thing {
  someMethod() {
    someFunc();
  }
}

更多详情,请参考 PR

私有字段存在性检查

TypeScript 4.5 支持了检查对象上是否存在某私有字段的 ECMAScript Proposal。 现在,你可以编写带有 #private 字段成员的类,然后使用 in 运算符检查另一个对象是否包含相同的字段。

class Person {
  #name: string;
  constructor(name: string) {
    this.#name = name;
  }

  equals(other: unknown) {
    return (
      other &&
      typeof other === 'object' &&
      #name in other && // <- this is new!
      this.#name === other.#name
    );
  }
}

该功能一个有趣的地方是,#name in other 隐含了 other 必须是使用 Person 构造的,因为只有在这种情况下才可能存在该字段。 这是该提议中关键的功能之一,同时也是为什么这项提议叫作 “ergonomic brand checks” 的原因 - 因为私有字段通常作为一种“商标”来区分不同类的实例。 因此,TypeScript 能够在每次检查中细化 other类型,直到细化为 Person 类型。

感谢来自 Bloomberg 的朋友提交的 PRAshley ClaymoreTitian Cernicova-DragomirKubilay Kahveci,和 Rob Palmer

导入断言

TypeScript 4.5 支持了 ECMAScript Proposal 中的 导入断言。 该语法会被运行时所使用来检查导入是否为期望的格式。

import obj from './something.json' assert { type: 'json' };

TypeScript 不会检查这些断言,因为它们依赖于宿主环境。 TypeScript 会保留原样,稍后让浏览器或者运行时来处理它们(也可能会出错)。

// TypeScript 允许
// 但浏览器可能不允许
import obj from './something.json' assert { type: 'fluffy bunny' };

动态的 import() 调用可以通过第二个参数来使用导入断言。

const obj = await import('./something.json', {
  assert: { type: 'json' },
});

第二个参数的类型为 ImportCallOptions,并且目前它只接受一个 assert 属性。

感谢 Wenlu Wang 实现了 这个功能

使用 realPathSync.native 获得更快的加载速度

TypeScript 在所有操作系统上使用了 Node.js realPathSync 函数的系统原生实现。

以前,这个函数只在 Linux 上使用了,但在 TypeScript 4.5 中,在大小写不敏感的操作系统上,如 Windows 和 MacOS,也被采用了。 对于一些代码库来讲这个改动会提升 5 ~ 13% 的加载速度(和操作系统有关)。

更多详情请参考 PR

JSX Attributes 的代码片段自动补全

TypeScript 4.5 为 JSX 属性提供了代码片段自动补全功能。 当在 JSX 标签上输入属性时,TypeScript 已经能够提供提供建议; 但对于代码片段自动补全来讲,它们会删除部分已经输入的字符来添加一个初始化器并将光标放到正确的位置。

Snippet completions for JSX attributes. For a string property, quotes are automatically added. For a numeric properties, braces are added.

TypeScript 通常会使用属性的类型来判断插入哪种初始化器,但你可以在 Visual Studio Code 中自定义该行为。

Settings in VS Code for JSX attribute completions

注意,该功能只在新版本的 Visual Studio Code 中支持,因此你可能需要使用 Insiders 版本。 更多详情,请参考 PR

为未解决类型提供更好的编辑器支持

在某些情况下,编辑器会使用一个轻量级的“部分”语义模式 - 比如编辑器正在等待加载完整的工程,又或者是 GitHub 的基于 web 的编辑器

在旧版本 TypeScript 中,如果语言服务无法找到一个类型,它会输出 any

Hovering over a signature where Buffer isn't found, TypeScript replaces it with any.

上例中,没有找到 Buffer,因此 TypeScript 在 quick info 里显示了 any。 在 TypeScript 4.5 中,TypeScript 会尽可能保留你编写的代码。

Hovering over a signature where Buffer isn't found, it continues to use the name Buffer.

然而,当你将鼠标停在 Buffer 上时,你会看到 TypeScript 无法找到 Buffer 的提示。

TypeScript displays type Buffer = /* unresolved */ any;

总之,在 TypeScript 还没有读取整个工程的时候,它提供了更加平滑的体验。 注意,在其它正常情况下,当无法找到某个类型时总会产生错误。

更多详情,请参考 PR

TypeScript 4.4

针对条件表达式和判别式的别名引用进行控制流分析

在 JavaScript 中,总会用多种方式对某个值进行检查,然后根据不同类型的值执行不同的操作。 TypeScript 能够理解这些检查,并将它们称作为类型守卫。 我们不需要在变量的每一个使用位置上都指明类型,TypeScript 的类型检查器能够利用基于控制流的分析技术来检查是否在前面使用了类型守卫。

例如,可以这样写

function foo(arg: unknown) {
  if (typeof arg === 'string') {
    console.log(arg.toUpperCase());
    //           ^?
  }
}

这个例子中,我们检查 arg 是否为 string 类型。 TypeScript 识别出了 typeof arg === "string" 检查,它被当作是一个类型守卫,并且知道在 if 分支内 arg 的类型为 string。 这样就可以正常地访问 string 类型上的方法,例如 toUpperCase()

但如果我们将条件表达式提取到一个名为 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) {
    // 知道此处为 circle
    return Math.PI * shape.radius ** 2;
  } else {
    // 知道此处为 square
    return shape.sideLength ** 2;
  }
}

在 TypeScript 4.4 版本中对判别式的分析又进了一层 - 现在可以提取出判别式然后细化原来的对象类型。

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; sideLength: number };

function area(shape: Shape): number {
  // Extract out the 'kind' field first.
  const { kind } = shape;

  if (kind === 'circle') {
    // We know we have a circle here!
    return Math.PI * shape.radius ** 2;
  } else {
    // We know we're left with a square here!
    return shape.sideLength ** 2;
  }
}

另一个例子,该函数会检查它的两个参数是否有内容。

function doSomeChecks(
  inputA: string | undefined,
  inputB: string | undefined,
  shouldDoExtraWork: boolean
) {
  const mustDoWork = inputA && inputB && shouldDoExtraWork;
  if (mustDoWork) {
    // We can access 'string' properties on both 'inputA' and 'inputB'!
    const upperA = inputA.toUpperCase();
    const upperB = inputB.toUpperCase();
    // ...
  }
}

TypeScript 知道如果 mustDoWorktrue 那么 inputAinputB 都存在。 也就是说不需要编写像 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;
    //  ^?
  }
}

注意这里会有一个截点 - TypeScript 并不是毫无限制地去追溯检查这些条件表达式,但对于大多数使用场景而言已经足够了。

这个功能能让很多直观的 JavaScript 代码在 TypeScript 里也好用,而不会妨碍我们。 更多详情请参考 PR

Symbol 以及模版字符串索引签名

TypeScript 支持使用索引签名来为对象的每个属性定义类型。 这样我们就可以将对象当作字典类型来使用,把字符串放在方括号里来进行索引。

例如,可以编写由 string 类型的键映射到 boolean 值的类型。 如果我们给它赋予 boolean 类型以外的值会报错。

interface BooleanDictionary {
  [key: string]: boolean;
}

declare let myDict: BooleanDictionary;

// 允许赋予 boolean 类型的值
myDict['foo'] = true;
myDict['bar'] = false;

// 错误
myDict['baz'] = 'oops';

虽说在这里 Map 可能是更适合的数据结构(具体的说是 Map<string, boolean>),但 JavaScript 对象通常更方便或者正是我们要操作的目标。

相似地,Array<T> 已经定义了 number 索引签名,我们可以插入和获取 T 类型的值。

// 这是 TypeScript 内置的部分 Array 类型
interface Array<T> {
  [index: number]: T;

  // ...
}

let arr = new Array<string>();

// 没问题
arr[0] = 'hello!';

// 错误,期待一个 'string' 值
arr[1] = 123;

索引签名是一种非常有用的表达方式。 然而,直到现在它们只能使用 stringnumber 类型的键(string 索引签名存在一个有意为之的怪异行为,它们可以接受 number 类型的键,因为 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 = {};

// 没问题
colors[red] = 255;
let redVal = colors[red];
//  ^ number

colors[blue] = 'da ba dee';
// 错误:'string' 不能赋值给 'number'

相似地,可以定义带有模版字符串的索引签名。 一个场景是用来免除对以 data- 开头的属性名执行的 TypeScript 额外属性检查。 当传递一个对象字面量给目标类型时,TypeScript 会检查是否存在相比于目标类型的额外属性。

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,
};

最后,索引签名现在支持联合类型,只要它们是无限域原始类型的联合 - 尤其是:

  • string
  • number
  • symbol
  • 模版字符串(例如 `hello-${string}`

带有以上类型的联合的索引签名会展开为不同的索引签名。

interface Data {
  [optName: string | symbol]: any;
}

// 等同于

interface Data {
  [optName: string]: any;
  [optName: symbol]: any;
}

更多详情请参考 PR

Defaulting to the unknown Type in Catch Variables (--useUnknownInCatchVariables)

异常捕获变量的类型默认为 unknown--useUnknownInCatchVariables

在 JavaScript 中,允许使用 throw 语句抛出任意类型的值,并在 catch 语句中捕获它。 因此,TypeScript 从前会将异常捕获变量的类型设置为 any 类型,并且不允许指定其它的类型注解:

try {
  // 谁知道它会抛出什么东西
  executeSomeThirdPartyCode();
} catch (err) {
  // err: any
  console.error(err.message); // 可以,因为类型为 'any'
  err.thisWillProbablyFail(); // 可以,因为类型为 'any' :(
}

当 TypeScript 引入了 unknown 类型后,对于追求高度准确性和类型安全的用户来讲在 catch 语句的捕获变量处使用 unknown 成为了比 any 类型更好的选择,因为它强制我们去检测要使用的值。 后来,TypeScript 4.0 允许用户在 catch 语句中明确地指定 unknown(或 any)类型,这样就可以根据情况有选择一使用更严格的类型检查; 然而,在每一处 catch 语句里手动指定 : unknown 是一件繁琐的事情。

因此,TypeScript 4.4 引入了一个新的标记 --useUnknownInCatchVariables。 它将 catch 语句捕获变量的默认类型由 any 改为 unknown

declare function executeSomeThirdPartyCode(): void;

try {
  executeSomeThirdPartyCode();
} catch (err) {
  // err: unknown

  // Error! Property 'message' does not exist on type 'unknown'.
  console.error(err.message);

  // Works! We can narrow 'err' from 'unknown' to 'Error'.
  if (err instanceof Error) {
    console.error(err.message);
  }
}

这个标记属性于 --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 类型注解,这样就会关闭严格类型检查。

declare function executeSomeThirdPartyCode(): void;

try {
  executeSomeThirdPartyCode();
} catch (err: any) {
  console.error(err.message); // Works again!
}

更多详情请参考 PR

确切的可选属性类型 (--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, // This is okay by default.
};

因此默认情况下,TypeScript 不区分带有 undefined 类型的属性和不存在的属性。 虽说这在大部分情况下是没问题的,但并非所有的 JavaScript 代码都如此。 像是 Object.assignObject.keys,对象展开({ ...obj })和 for-in 循环这样的函数和运算符会区别对待属性是否存在于对象之上。 在 Person 例子中,如果 age 属性的存在与否是至关重要的,那么就可能会导致运行时错误。

在 TypeScript 4.4 中,新的 --exactOptionalPropertyTypes 标记指明了可选属性的确切表示方式,即不自动添加 | undefined 类型:

interface Person {
  name: string;
  age?: number;
}

// 启用 'exactOptionalPropertyTypes'
const p: Person = {
  name: 'Daniel',
  age: undefined, // 错误!undefined 不是一个成员
};

该标记不是 --strict 标记家族的一员,需要显式地开启。 该标记要求同时启用 --strictNullChecks 标记。 我们已经更新了 DefinitelyTyped 以及其它的声明定义来帮助进行平稳地过渡,但你仍可能遇到一些问题,这取决于代码的结构。

更多详情请参考 PR

类中的 static 语句块

TypeScript 4.4 支持了 类中的 static 语句块,一个即将到来的 ECMAScript 特性,它能够帮助编写复杂的静态成员初始化代码。

declare function someCondition(): boolean;

class Foo {
  static count = 0;

  // 静态语句块:
  static {
    if (someCondition()) {
      Foo.count++;
    }
  }
}

在静态语句块中允许编写一系列语句,它们可以访问类中的私有字段。 也就是说在初始化代码中能够编写语句,不会暴露变量,并且可以完全访问类的内部信息。

declare function loadLastInstances(): any[];

class Foo {
  static #count = 0;

  get count() {
    return Foo.#count;
  }

  static {
    try {
      const lastInstances = loadLastInstances();
      Foo.#count += lastInstances.length;
    } catch {}
  }
}

若不使用 static 语句块也能够编写上述代码,只不过需要使用一些折中的 hack 手段。

一个类可以有多个 static 语句块,它们的运行顺序与编写顺序一致。

// Prints:
//    1
//    2
//    3
class Foo {
  static prop = 1;
  static {
    console.log(Foo.prop++);
  }
  static {
    console.log(Foo.prop++);
  }
  static {
    console.log(Foo.prop++);
  }
}

感谢 Wenlu Wang 为 TypeScript 添加了该支持。 更多详情请参考 PR

tsc --help 更新与优化

TypeScript 的 --help 选项完全更新了! 感谢 Song Gao,我们更新了编译选项的描述--help 菜单的配色样式

The new TypeScript --help menu where the output is bucketed into several different areas

更多详情请参考 Issue

性能优化

更快地生成声明文件

TypeScript 现在会缓存下内部符号是否可以在不同上下文中被访问,以及如何显示指定的类型。 这些改变能够改进 TypeScript 处理复杂类型时的性能,尤其是在使用了 --declaration 标记来生成 .d.ts 文件的时候。

更多详情请参考 PR

更快地标准化路径

TypeScript 经常需要对文件路径进行“标准化”操作来得到统一的格式,以便编译器能够随处使用它。 它包括将反斜线替换成正斜线,或者删除路径中间的 /.//../ 片段。 当 TypeScript 需要处理成千上万的路径时,这个操作就会很慢。 在 TypeScript 4.4 里会先对路径进行快速检查,判断它们是否需要进行标准化。 这些改进能够减少 5-10% 的工程加载时间,对于大型工程来讲效果会更加明显。

更多详情请参考 PR 以及 PR

更快地路径映射

TypeScript 现在会缓存构造的路径映射(通过 tsconfig.json 里的 paths)。 对于拥有数百个路径映射的工程来讲效果十分明显。 更多详情请参考 PR

更快地增量构建与 --strict

这曾是一个缺陷,在 --incremental 模式下,如果启用了 --strict 则 TypeScript 会重新进行类型检查。 这导致了不管是否开启了 --incremental 构建速度都挺慢。 TypeScript 4.4 修复了这个问题,该修复也应用到了 TypeScript 4.3 里。

更多详情请参考 PR

针对大型输出更快地生成 Source Map

TypeScript 4.4 优化了为超大输出文件生成 source map 的速度。 在构建旧版本的 TypeScript 编译器时,结果显示节省了 8% 的生成时间。

感谢 David Michon 提供了这项简洁的优化

更快的 --force 构建

当在工程引用上使用了 --build 模式时,TypeScript 必须执行“是否更新检查”来确定是否需要重新构建。 在进行 --force 构建时,该检查是无关的,因为每个工程依赖都要被重新构建。 在 TypeScript 4.4 里,--force 会避免执行无用的步骤并进行完整的构建。 更多详情请参考 PR

JavaScript 中的拼写建议

TypeScript 为在 Visual Studio 和 Visual Studio Code 等编辑器中的 JavaScript 编写体验赋能。 大多数情况下,在处理 JavaScript 文件时,TypeScript 会置身事外; 然而,TypeScript 经常能够提供有理有据的建议且不过分地侵入其中。

这就是为什么 TypeScript 会为 JavaScript 文件提供拼写建议 - 不带有 // @ts-check 的 文件或者关闭了 checkJs 选项的工程。 即,TypeScript 文件中已有的 "Did you mean...?" 建议,现在它们也作用于 JavaScript 文件。

这些拼写建议也暗示了代码中可能存在错误。 我们在测试该特性时已经发现了已有代码中的一些错误!

更多详情请参考 PR

内嵌提示(Inlay Hints)

TypeScript 4.4 支持了内嵌提示特性,它能帮助显示参数名和返回值类型等信息。 可将其视为一种友好的“ghost text”。

A preview of inlay hints in Visual Studio Code

该特性由 Wenlu WangPR 所实现。

他也在 Visual Studio Code 里进行了集成 并在 July 2021 (1.59) 发布。 若你想尝试该特性,需确保安装了稳定版insiders 版本的编辑器。 你也可以在 Visual Studio Code 的设置里修改何时何地显示内嵌提示。

自动导入的补全列表里显示真正的路径

当 Visual Studio Code 显示补全列表时,包含自动导入在内的补全列表里会显示指向模块的路径; 然而,该路径通常不是 TypeScript 最终替换进来的模块描述符。 该路径通常是相对于 workspace 的,如果你导入了 moment 包,你大概会看到 node_modules/moment 这样的路径 。

A completion list containing unwieldy paths containing 'node_modules'. For example, the label for 'calendarFormat' is 'node_modules/moment/moment' instead of 'moment'.

这些路径很难处理且容易产生误导,尤其是插入的路径同时需要考虑 Node.js 的 node_modules 解析,路径映射,符号链接以及重新导出等。

这就是为什么 TypeScript 4.4 中的补全列表会显示真正的导入模块路径。

A completion list containing clean paths with no intermediate 'node_modules'. For example, the label for 'calendarFormat' is 'moment' instead of 'node_modules/moment/moment'.

由于该计算可能很昂贵,当补全列表包含许多条目时最终的模块描述符会在你输入更多的字符时显示出来。 你仍可能看到基于 workspace 的相对路径;然而,当编辑器“预热”后,再多输入几个字符它们会被替换为真正的路径。

TypeScript 4.3

拆分属性的写入类型

在 JavaScript 中,API 经常需要对传入的值进行转换,然后再保存。 这种情况在 getter 和 setter 中也常出现。 例如,在某个类中的一个 setter 总是需要将传入的值转换成 number,然后再保存到私有字段中。

class Thing {
  #size = 0;

  get size() {
    return this.#size;
  }
  set size(value) {
    let num = Number(value);

    // Don't allow NaN and stuff.
    if (!Number.isFinite(num)) {
      this.#size = 0;
      return;
    }

    this.#size = num;
  }
}

我们该如何将这段 JavaScript 代码改写为 TypeScript 呢? 从技术上讲,我们不必进行任何特殊处理 - TypeScript 能够识别出 size 是一个数字。

但问题在于 size 不仅仅是允许将 number 赋值给它。 我们可以通过将 size 声明为 unknownany 来解决这个问题:

class Thing {
  // ...
  get size(): unknown {
    return this.#size;
  }
}

但这不太友好 - unknown 类型会强制在读取 size 值时进行类型断言,同时 any 类型也不会去捕获错误。 如果我们真想要为转换值的 API 进行建模,那么之前版本的 TypeScript 会强制我们在准确性(读取容易,写入难)和自由度(写入方便,读取难)两者之间进行选择。

这就是 TypeScript 4.3 允许分别为读取和写入属性值添加类型的原因。

class Thing {
  #size = 0;

  get size(): number {
    return this.#size;
  }

  set size(value: string | number | boolean) {
    let num = Number(value);

    // Don't allow NaN and stuff.
    if (!Number.isFinite(num)) {
      this.#size = 0;
      return;
    }

    this.#size = num;
  }
}

上例中,set 存取器使用了更广泛的类型种类(stringbooleannumber),但 get 存取器保证它的值为number。 现在,我们再给这类属性赋予其它类型的值就不会报错了!

class Thing {
  #size = 0;

  get size(): number {
    return this.#size;
  }

  set size(value: string | number | boolean) {
    let num = Number(value);

    // Don't allow NaN and stuff.
    if (!Number.isFinite(num)) {
      this.#size = 0;
      return;
    }

    this.#size = num;
  }
}
// ---cut---
let thing = new Thing();

// 可以给 `thing.size` 赋予其它类型的值!
thing.size = 'hello';
thing.size = true;
thing.size = 42;

// 读取 `thing.size` 总是返回数字!
let mySize: number = thing.size;

当需要判定两个同名属性间的关系时,TypeScript 将只考虑“读取的”类型(比如,get 存取器上的类型)。 而“写入”类型只在直接写入属性值时才会考虑。

注意,这个模式不仅作用于类。 你也可以在对象字面量中为 getter 和 setter 指定不同的类型。

function makeThing(): Thing {
  let size = 0;
  return {
    get size(): number {
      return size;
    },
    set size(value: string | number | boolean) {
      let num = Number(value);

      // Don't allow NaN and stuff.
      if (!Number.isFinite(num)) {
        size = 0;
        return;
      }

      size = num;
    },
  };
}

事实上,我们在接口/对象类型上支持了为属性的读和写指定不同的类型。

// Now valid!
interface Thing {
  get size(): number;
  set size(value: number | string | boolean);
}

此处的一个限制是属性的读取类型必须能够赋值给属性的写入类型。 换句话说,getter 的类型必须能够赋值给 setter。 这在一定程度上确保了一致性,一个属性应该总是能够赋值给它自身。

更多详情,请参考PR

override--noImplicitOverride 标记

当在 JavaScript 中去继承一个类时,覆写方法十分容易 - 但不幸的是可能会犯一些错误。

其中一个就是会导致丢失重命名。 例如:

class SomeComponent {
  show() {
    // ...
  }
  hide() {
    // ...
  }
}

class SpecializedComponent extends SomeComponent {
  show() {
    // ...
  }
  hide() {
    // ...
  }
}

SpecializedComponentSomeComponent 的子类,并且覆写了 showhide 方法。 猜一猜,如果有人想要将 showhide 方法删除并用单个方法代替会发生什么?

 class SomeComponent {
-    show() {
-        // ...
-    }
-    hide() {
-        // ...
-    }
+    setVisible(value: boolean) {
+        // ...
+    }
 }
 class SpecializedComponent extends SomeComponent {
     show() {
         // ...
     }
     hide() {
         // ...
     }
 }

哦,不! SpecializedComponent 中的方法没有被更新。 而是变为添加了两个没用的 showhide 方法,它们可能都没有被调用。

此处的部分问题在于我们不清楚这里是想添加新的方法,还是想覆写已有的方法。 因此,TypeScript 4.3 增加了 override 关键字。

class SpecializedComponent extends SomeComponent {
  override show() {
    // ...
  }
  override hide() {
    // ...
  }
}

当一个方法被标记为 override,TypeScript 会确保在基类中存在同名的方法。

class SomeComponent {
  setVisible(value: boolean) {
    // ...
  }
}
class SpecializedComponent extends SomeComponent {
  override show() {
    //   ~~~~
    //   错误
  }
}

这是一项重大改进,但如果忘记在方法前添加 override 则不会起作用 - 这也是人们常犯的错误。

例如,可能会不小心覆写了基类中的方法,并且还没有意识到。

class Base {
  someHelperMethod() {
    // ...
  }
}

class Derived extends Base {
  // 不是真正想覆写基类中的方法,
  // 只是想编写一个本地的帮助方法
  someHelperMethod() {
    // ...
  }
}

因此,TypeScript 4.3 中还增加了一个 --noImplicitOverride 选项。 当启用了该选项,如果覆写了父类中的方法但没有添加 override 关键字,则会产生错误。 在上例中,如果启用了 --noImplicitOverride,则 TypeScript 会报错,并提示我们需要重命名 Derived 中的方法。

感谢开发者社区的贡献。 该功能是在这个 PR中由Wenlu Wang实现,一个更早的 override 实现是由Paul Cody Johnston完成。

模版字符串类型改进

在近期的版本中,TypeScript 引入了一种新类型,即:模版字符串类型。 它可以通过连接操作来构造类字符串类型:

type Color = 'red' | 'blue';
type Quantity = 'one' | 'two';

type SeussFish = `${Quantity | Color} fish`;
// 等同于
//   type SeussFish = "one fish" | "two fish"
//                  | "red fish" | "blue fish";

或者与其它类字符串类型进行模式匹配。

declare let s1: `${number}-${number}-${number}`;
declare let s2: `1-2-3`;

// 正确
s1 = s2;

我们做的首个改动是 TypeScript 应该在何时去推断模版字符串类型。 当一个模版字符串的类型是由类字符串字面量类型进行的按上下文归类(比如,TypeScript 识别出将模版字符串传递给字面量类型时),它会得到模版字符串类型。

function bar(s: string): `hello ${string}` {
  // 之前会产生错误,但现在没有问题
  return `hello ${s}`;
}

在类型推断和 extends string 的类型参数上也会起作用。

declare let s: string;
declare function f<T extends string>(x: T): T;

// 以前:string
// 现在:`hello-${string}`
let x2 = f(`hello ${s}`);

另一个主要的改动是 TypeScript 会更好地进行类型关联,并在不同的模版字符串之间进行推断。

示例如下:

declare let s1: `${number}-${number}-${number}`;
declare let s2: `1-2-3`;
declare let s3: `${number}-2-3`;

s1 = s2;
s1 = s3;

在检查字符串字面量类型时,例如 s2,TypeScript 可以匹配字符串的内容并计算出在第一个赋值语句中 s2s1 兼容。 然而,当再次遇到模版字符串类型时,则会直接放弃进行匹配。 结果就是,像 s3s1 的赋值语句会出错。

现在,TypeScript 会去判断是否模版字符串的每一部分都能够成功匹配。 你现在可以混合并使用不同的替换字符串来匹配模版字符串,TypeScript 能够更好地计算出它们是否兼容。

declare let s1: `${number}-${number}-${number}`;
declare let s2: `1-2-3`;
declare let s3: `${number}-2-3`;
declare let s4: `1-${number}-3`;
declare let s5: `1-2-${number}`;
declare let s6: `${number}-2-${number}`;

// 下列均无问题
s1 = s2;
s1 = s3;
s1 = s4;
s1 = s5;
s1 = s6;

在这项改进之后,TypeScript 提供了更好的推断能力。 示例如下:

declare function foo<V extends string>(arg: `*${V}*`): V;

function test<T extends string>(s: string, n: number, b: boolean, t: T) {
  let x1 = foo('*hello*'); // "hello"
  let x2 = foo('**hello**'); // "*hello*"
  let x3 = foo(`*${s}*` as const); // string
  let x4 = foo(`*${n}*` as const); // `${number}`
  let x5 = foo(`*${b}*` as const); // "true" | "false"
  let x6 = foo(`*${t}*` as const); // `${T}`
  let x7 = foo(`**${s}**` as const); // `*${string}*`
}

更多详情,请参考PR:利用按上下文归类,以及PR:改进模版字符串类型的类型推断和检查

ECMAScript #private 的类成员

TypeScript 4.3 扩大了在类中可被声明为 #private #names 的成员的范围,使得它们在运行时成为真正的私有的。 除属性外,方法和存取器也可进行私有命名。

class Foo {
  #someMethod() {
    //...
  }

  get #someValue() {
    return 100;
  }

  publicMethod() {
    // 可以使用
    // 可以在类内部访问私有命名成员。
    this.#someMethod();
    return this.#someValue;
  }
}

new Foo().#someMethod();
//        ~~~~~~~~~~~
// 错误!
// 属性 '#someMethod' 无法在类 'Foo' 外访问,因为它是私有的。

new Foo().#someValue;
//        ~~~~~~~~~~
// 错误!
// 属性 '#someValue' 无法在类 'Foo' 外访问,因为它是私有的。

更为广泛地,静态成员也可以有私有命名。

class Foo {
  static #someMethod() {
    // ...
  }
}

Foo.#someMethod();
//  ~~~~~~~~~~~
// 错误!
// 属性 '#someMethod' 无法在类 'Foo' 外访问,因为它是私有的。

该功能是由 Bloomberg 的朋友开发的:PR - 由 Titian Cernicova-DragomirKubilay Kahveci 开发,并得到了 Joey WattsRob PalmerTim McClure 的帮助支持。 感谢他们!

ConstructorParameters 可用于抽象类

在 TypeScript 4.3 中,ConstructorParameters工具类型可以用在 abstract 类上。

abstract class C {
  constructor(a: string, b: number) {
    // ...
  }
}

// 类型为 '[a: string, b: number]'
type CParams = ConstructorParameters<typeof C>;

这多亏了 TypeScript 4.2 支持了声明抽象的构造签名:

type MyConstructorOf<T> = {
  new (...args: any[]): T;
};

// 或使用简写形式:

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

更多详情,请参考 PR

按上下文细化泛型类型

TypeScript 4.3 能够更智能地对泛型进行类型细化。 这让 TypeScript 能够支持更多模式,甚至有时还能够发现错误。

设想有这样的场景,我们想要编写一个 makeUnique 函数。 它接受一个 SetArray,如果接收的是 Array,则对数组进行排序并去除重复的元素。 最后返回初始的集合。

function makeUnique<T>(
  collection: Set<T> | T[],
  comparer: (x: T, y: T) => number
): Set<T> | T[] {
  // 假设元素已经是唯一的
  if (collection instanceof Set) {
    return collection;
  }

  // 排序,然后去重
  collection.sort(comparer);
  for (let i = 0; i < collection.length; i++) {
    let j = i;
    while (
      j < collection.length &&
      comparer(collection[i], collection[j + 1]) === 0
    ) {
      j++;
    }
    collection.splice(i + 1, j - i);
  }
  return collection;
}

暂且不谈该函数的具体实现,假设它就是某应用中的一个需求。 我们可能会注意到,函数签名没能捕获到 collection 的初始类型。 我们可以定义一个类型参数 C,并用它代替 Set<T> | T[]

- function makeUnique<T>(collection: Set<T> | T[], comparer: (x: T, y: T) => number): Set<T> | T[]
+ function makeUnique<T, C extends Set<T> | T[]>(collection: C, comparer: (x: T, y: T) => number): C

在 TypeScript 4.2 以及之前的版本中,如果这样做的话会产生很多错误。

function makeUnique<T, C extends Set<T> | T[]>(
  collection: C,
  comparer: (x: T, y: T) => number
): C {
  // 假设元素已经是唯一的
  if (collection instanceof Set) {
    return collection;
  }

  // 排序,然后去重
  collection.sort(comparer);
  //         ~~~~
  // 错误:属性 'sort' 不存在于类型 'C' 上。
  for (let i = 0; i < collection.length; i++) {
    //                           ~~~~~~
    // 错误: 属性 'length' 不存在于类型 'C' 上。
    let j = i;
    while (
      j < collection.length &&
      comparer(collection[i], collection[j + 1]) === 0
    ) {
      //             ~~~~~~
      // 错误: 属性 'length' 不存在于类型 'C' 上。
      //       ~~~~~~~~~~~~~  ~~~~~~~~~~~~~~~~~
      // 错误: 元素具有隐式的 'any' 类型,因为 'number' 类型的表达式不能用来索引 'Set<T> | T[]' 类型。
      j++;
    }
    collection.splice(i + 1, j - i);
    //         ~~~~~~
    // 错误: 属性 'splice' 不存在于类型 'C' 上。
  }
  return collection;
}

全是错误! 为何 TypeScript 要对我们如此刻薄?

问题在于进行 collection instanceof Set 检查时,我们期望它能够成为类型守卫,并根据条件将 Set<T> | T[] 类型细化为 Set<T>T[] 类型; 然而,实际上 TypeScript 没有对 Set<T> | T[] 进行处理,而是去细化泛型值 collection,其类型为 C

虽是细微的差别,但结果却不同。 TypeScript 不会去读取 C 的泛型约束(即 Set<T> | T[])并细化它。 如果要让 TypeScript 由 Set<T> | T[] 进行类型细化,它就会忘记在每个分支中 collection 的类型为 C,因为没有比较好的办法去保留这些信息。 假设 TypeScript 真这样做了,那么上例也会有其它的错误。 在函数返回的位置期望得到一个 C 类型的值,但从每个分支中得到的却是Set<T>T[],因此 TypeScript 会拒绝编译。

function makeUnique<T>(
  collection: Set<T> | T[],
  comparer: (x: T, y: T) => number
): Set<T> | T[] {
  // 假设元素已经是唯一的
  if (collection instanceof Set) {
    return collection;
    //     ~~~~~~~~~~
    // 错误:类型 'Set<T>' 不能赋值给类型 'C'。
    //          'Set<T>' 可以赋值给 'C' 的类型约束,但是
    //          'C' 可能使用 'Set<T> | T[]' 的不同子类型进行实例化。
  }

  // ...

  return collection;
  //     ~~~~~~~~~~
  // 错误:类型 'T[]' 不能赋值给类型 'C'。
  //          'T[]' 可以赋值给 'C' 的类型约束,但是
  //          'C' 可能使用 'Set<T> | T[]' 的不同子类型进行实例化。
}

TypeScript 4.3 是怎么做的? 在一些关键的位置,类型系统会去查看类型的约束。 例如,在遇到 collection.length 时,TypeScript 不去关心 collection 的类型为 C,而是会去查看可访问的属性,而这些是由 T[] | Set<T> 泛型约束决定的。

在类似的地方,TypeScript 会获取由泛型约束细化出的类型,因为它包含了用户关心的信息; 而在其它的一些地方,TypeScript 会去细化初始的泛型类型(但结果通常也是该泛型类型)。

换句话说,根据泛型值的使用方式,TypeScript 的处理方式会稍有不同。 最终结果就是,上例中的代码不会产生编译错误。

更多详情,请参考PR

检查总是为真的 Promise

strictNullChecks 模式下,在条件语句中检查 Promise 是否真时会产生错误。

async function foo(): Promise<boolean> {
  return false;
}

async function bar(): Promise<string> {
  if (foo()) {
    //  ~~~~~
    // Error!
    // This condition will always return true since
    // this 'Promise<boolean>' appears to always be defined.
    // Did you forget to use 'await'?
    return 'true';
  }
  return 'false';
}

这项改动是由Jack Works实现。

static 索引签名

与明确的类型声明相比,索引签名允许我们在一个值上设置更多的属性。

class Foo {
  hello = 'hello';
  world = 1234;

  // 索引签名:
  [propName: string]: string | number | undefined;
}

let instance = new Foo();

// 没问题
instance['whatever'] = 42;

// 类型为 'string | number | undefined'
let x = instance['something'];

目前为止,索引签名只允许在类的实例类型上进行设置。 感谢 Wenlu WangPR,现在索引签名也可以声明为 static

class Foo {
  static hello = 'hello';
  static world = 1234;

  static [propName: string]: string | number | undefined;
}

// 没问题
Foo['whatever'] = 42;

// 类型为 'string | number | undefined'
let x = Foo['something'];

类静态类型上的索引签名检查规则与类实例类型上的索引签名的检查规则是相同的,即每个静态属性必须与静态索引签名类型兼容。

class Foo {
  static prop = true;
  //     ~~~~
  // 错误!'boolean' 类型的属性 'prop' 不能赋值给字符串索引类型
  // 'string | number | undefined'.

  static [propName: string]: string | number | undefined;
}

.tsbuildinfo 文件大小改善

TypeScript 4.3 中,作为 --incremental 构建组分部分的 .tsbuildinfo 文件会变得非常小。 这得益于一些内部格式的优化,使用以数值标识的查找表来替代重复多次的完整路径以及类似的信息。 这项工作的灵感源自于 Tobias KoppersPR,而后在 PR 中实现,并在 PR 中进行优化。

我们观察到了 .tsbuildinfo 文件有如下的变化:

  • 1MB 到 411 KB
  • 14.9MB 到 1MB
  • 1345MB 到 467MB

不用说,缩小文件的尺寸会稍微加快构建速度。

--incremental--watch 中进行惰性计算

--incremental--watch 模式的一个问题是虽然它会加快后续的编译速度,但是首次编译很慢 - 有时会非常地慢。 这是因为在该模式下需要保存和计算当前工程的一些信息,有时还需要将这些信息写入 .tsbuildinfo 文件,以备后续之用。

因此, TypeScript 4.3 也对 --incremental--watch 进行了首次构建时的优化,让它可以和普通构建一样快。 为了达到目的,大部分信息会进行按需计算,而不是和往常一样全部一次性计算。 虽然这会加重后续构建的负担,但是 TypeScript 的 --incremental--watch 功能会智能地处理一小部分文件,并保存住会对后续构建有用的信息。 这就好比,--incremental--watch 构建会进行“预热”,并能够在多次修改文件后加速构建。

在一个包含了 3000 个文件的仓库中, 这能节约大概三分之一的构建时间

这项改进 是由 Tobias Koppers 开启,并在 PR 里完成。 感谢他们!

导入语句的补全

在 JavaScript 中,关于导入导出语句的一大痛点是其排序问题 - 尤其是导入语句的写法如下:

import { func } from './module.js';

而非

from "./module.js" import { func };

这导致了在书写完整的导入语句时很难受,因为自动补全无法工作。 例如,你输入了 import { ,TypeScript 不知道你要从哪个模块里导入,因此它不能提供补全信息。

为缓解该问题,我们可以利用自动导入功能! 自动导入能够提供每个可能导出并在文件顶端插入一条导入语句。

因此当你输入 import 语句并没提供一个路径时,TypeScript 会提供一个可能的导入列表。 当你确认了一个补全,TypeScript 会补全完整的导入语句,它包含了你要输入的路径。

Import statement completions

该功能需要编辑器的支持。 你可以在 Insiders 版本的 Visual Studio Code 中进行尝试。

更多详情,请参考 PR

TypeScript 现在能够理解 @link 标签,并会解析它指向的声明。 也就是说,你将鼠标悬停在 @link 标签上会得到一个快速提示,或者使用“跳转到定义”或“查找全部引用”命令。

例如,在支持 TypeScript 的编辑器中你可以在 @link bar中的 bar 上使用跳转到定义,它会跳转到 bar 的函数声明。

/**
 * To be called 70 to 80 days after {@link plantCarrot}.
 */
function harvestCarrot(carrot: Carrot) {}

/**
 * Call early in spring for best results. Added in v2.1.0.
 * @param seed Make sure it's a carrot seed!
 */
function plantCarrot(seed: Seed) {
  // TODO: some gardening
}

Jumping to definition and requesting quick info on a @link tag for

更多详情,请参考 PR

在非 JavaScript 文件上的跳转到定义

许多加载器允许用户在 JavaScript 的导入语句中导入资源文件。 例如典型的 import "./styles.css" 语句。

目前为止,TypeScript 的编辑器功能不会去尝试读取这些文件,因此“跳转到定义”会失败。 在最好的情况下,“跳转到定义”会跳转到类似 declare module "*.css" 这样的声明语句上,如果它能够找到的话。

现在,在执行“跳转到定义”命令时,TypeScript 的语言服务会尝试跳转到正确的文件,即使它们不是 JavaScript 或 TypeScript 文件! 在 CSS,SVGs,PNGs,字体文件,Vue 文件等的导入语句上尝试一下吧。

更多详情,请参考 PR

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 函数被调用了,使用快速修复

TypeScript 4.1

模版字面量类型

使用字符串字面量类型能够表示仅接受特定字符串参数的函数和 API。

function setVerticalAlignment(location: 'top' | 'middle' | 'bottom') {
  // ...
}

setVerticalAlignment('middel');
//                   ^^^^^^^^
// Argument of type '"middel"' is not assignable to parameter of type '"top" | "middle" | "bottom"'.

使用字符串字面量类型的好处是它能够对字符串进行拼写检查。

此外,字符串字面量还能用于映射类型中的属性名。 从这个意义上来讲,它们可被当作构件使用。

type Options = {
  [K in 'noImplicitAny' | 'strictNullChecks' | 'strictFunctionTypes']?: boolean;
};
// same as
//   type Options = {
//       noImplicitAny?: boolean,
//       strictNullChecks?: boolean,
//       strictFunctionTypes?: boolean
//   };

还有一处字符串字面量类型可被当作构件使用,那就是在构造其它字符串字面量类型时。

这也是 TypeScript 4.1 支持模版字面量类型的原因。 它的语法与JavaScript 中的模版字面量的语法是一致的,但是是用在表示类型的位置上。 当将其与具体类型结合使用时,它会将字符串拼接并产生一个新的字符串字面量类型。

type World = 'world';

type Greeting = `hello ${World}`;
//   ^^^^^^^^^
//   "hello world"

如果在替换的位置上使用了联合类型会怎么样呢? 它将生成由各个联合类型成员所表示的字符串字面量类型的联合。

type Color = 'red' | 'blue';
type Quantity = 'one' | 'two';

type SeussFish = `${Quantity | Color} fish`;
//   ^^^^^^^^^
//   "one fish" | "two fish" | "red fish" | "blue fish"

除此之外,我们也可以在其它场景中应用它。 例如,有些 UI 组件库提供了指定垂直和水平对齐的 API,通常会使用类似于"bottom-right"的字符串来同时指定。 在垂直对齐的选项"top""middle""bottom",以及水平对齐的选项"left""center""right"之间,共有 9 种可能的字符串,前者选项之一与后者选项之一之间使用短横线连接。

type VerticalAlignment = 'top' | 'middle' | 'bottom';
type HorizontalAlignment = 'left' | 'center' | 'right';

// Takes
//   | "top-left"    | "top-center"    | "top-right"
//   | "middle-left" | "middle-center" | "middle-right"
//   | "bottom-left" | "bottom-center" | "bottom-right"

declare function setAlignment(
  value: `${VerticalAlignment}-${HorizontalAlignment}`
): void;

setAlignment('top-left'); // works!
setAlignment('top-middel'); // error!
setAlignment('top-pot'); // error! but good doughnuts if you're ever in Seattle

这样的例子还有很多,但它仍只是小例子而已,因为我们可以直接写出所有可能的值。 实际上,对于 9 个字符串来讲还算可以;但是如果需要大量的字符串,你就得考虑如何去自动生成(或者简单地使用string)。

有些值实际上是来自于动态创建的字符串字面量。 例如,假设 makeWatchedObject API 接收一个对象,并生成一个几乎等同的对象,但是带有一个新的on方法来检测属性的变化。

let person = makeWatchedObject({
  firstName: 'Homer',
  age: 42,
  location: 'Springfield',
});

person.on('firstNameChanged', () => {
  console.log(`firstName was changed!`);
});

注意,on监听的是"firstNameChanged"事件,而非仅仅是"firstName"。 那么我们如何定义类型?

type PropEventSource<T> = {
  on(eventName: `${string & keyof T}Changed`, callback: () => void): void;
};

/// Create a "watched object" with an 'on' method
/// so that you can watch for changes to properties.
declare function makeWatchedObject<T>(obj: T): T & PropEventSource<T>;

这样做的话,如果传入了错误的属性会产生一个错误!

type PropEventSource<T> = {
  on(eventName: `${string & keyof T}Changed`, callback: () => void): void;
};
declare function makeWatchedObject<T>(obj: T): T & PropEventSource<T>;
let person = makeWatchedObject({
  firstName: 'Homer',
  age: 42,
  location: 'Springfield',
});

// error!
person.on('firstName', () => {});

// error!
person.on('frstNameChanged', () => {});

我们还可以在模版字面量上做一些其它的事情:可以从替换的位置来推断类型。 我们将上面的例子改写成泛型,由eventName字符串来推断关联的属性名。

type PropEventSource<T> = {
  on<K extends string & keyof T>(
    eventName: `${K}Changed`,
    callback: (newValue: T[K]) => void
  ): void;
};

declare function makeWatchedObject<T>(obj: T): T & PropEventSource<T>;

let person = makeWatchedObject({
  firstName: 'Homer',
  age: 42,
  location: 'Springfield',
});

// works! 'newName' is typed as 'string'
person.on('firstNameChanged', newName => {
  // 'newName' has the type of 'firstName'
  console.log(`new name is ${newName.toUpperCase()}`);
});

// works! 'newAge' is typed as 'number'
person.on('ageChanged', newAge => {
  if (newAge < 0) {
    console.log('warning! negative age');
  }
});

这里我们将on定义为泛型方法。 当用户使用"firstNameChanged'来调用该方法,TypeScript 会尝试去推断出K所表示的类型。 为此,它尝试将K"Changed"之前的内容进行匹配并推断出"firstName"。 一旦 TypeScript 得到了结果,on方法就能够从原对象上获取firstName的类型,此例中是string。 类似地,当使用"ageChanged"调用时,它会找到属性age的类型为number

类型推断可以用不同的方式组合,常见的是解构字符串,再使用其它方式重新构造它们。 实际上,为了便于修改字符串字面量类型,我们引入了一些新的工具类型来修改字符大小写。

type EnthusiasticGreeting<T extends string> = `${Uppercase<T>}`;

type HELLO = EnthusiasticGreeting<'hello'>;
//   ^^^^^
//   "HELLO"

新的类型别名为UppercaseLowercaseCapitalizeUncapitalize。 前两个会转换字符串中的所有字符,而后面两个只转换字符串的首字母。

更多详情,查看原 PR以及正在进行中的切换类型别名助手的 PR.

在映射类型中更改映射的键

让我们先回顾一下,映射类型可以使用任意的键来创建新的对象类型。

type Options = {
  [K in 'noImplicitAny' | 'strictNullChecks' | 'strictFunctionTypes']?: boolean;
};
// same as
//   type Options = {
//       noImplicitAny?: boolean,
//       strictNullChecks?: boolean,
//       strictFunctionTypes?: boolean
//   };

或者,基于任意的对象类型来创建新的对象类型。

/// 'Partial<T>' 等同于 'T',只是把每个属性标记为可选的。
type Partial<T> = {
  [K in keyof T]?: T[K];
};

到目前为止,映射类型只能使用提供给它的键来创建新的对象类型;然而,很多时候我们想要创建新的键,或者过滤掉某些键。

这就是 TypeScript 4.1 允许更改映射类型中的键的原因。它使用了新的as语句。

type MappedTypeWithNewKeys<T> = {
  [K in keyof T as NewKeyType]: T[K];
  //            ^^^^^^^^^^^^^
  //            这里是新的语法!
};

通过as语句,你可以利用例如模版字面量类型,并基于原属性名来轻松地创建新属性名。

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Person {
  name: string;
  age: number;
  location: string;
}

type LazyPerson = Getters<Person>;
// type LazyPerson = {
//     getName: () => string;
//     getAge: () => number;
//     getLocation: () => string;
// }

此外,你可以巧用never类型来过滤掉某些键。 也就是说,在某些情况下你不必使用Omit工具类型。

// 删除 'kind' 属性
type RemoveKindField<T> = {
  [K in keyof T as Exclude<K, 'kind'>]: T[K];
};

interface Circle {
  kind: 'circle';
  radius: number;
}

type KindlessCircle = RemoveKindField<Circle>;

type RemoveKindField<T> = {
  [K in keyof T as Exclude<K, 'kind'>]: T[K];
};

interface Circle {
  kind: 'circle';
  radius: number;
}

type KindlessCircle = RemoveKindField<Circle>;
// type KindlessCircle = {
//     radius: number;
// }

更多详情,请参考PR

递归的有条件类型

在 JavaScript 中较为常见的是,一个函数能够以任意的层级来展平(flatten)并构建容器类型。 例如,可以拿Promise实例对象上的.then()方法来举例。 .then(...)方法能够拆解每一个Promise,直到它找到一个非Promise的值,然后将该值传递给回调函数。 Array上也存在一个相对较新的flat方法,它接收一个表示深度的参数,并以此来决定展平操作的层数。

在过去,我们无法使用 TypeScript 类型系统来表达上述例子。 虽然也存在一些 hack,但基本上都不切合实际。

TypeScript 4.1 取消了对有条件类型的一些限制 - 因此它现在可以表达上述类型。 在 TypeScript 4.1 中,允许在有条件类型的分支中立即引用该有条件类型自身,这就使得编写递归的类型别名变得更加容易。 例如,我们想定义一个类型来获取嵌套数组中的元素类型,可以定义如下的deepFlatten类型。

type ElementType<T> = T extends ReadonlyArray<infer U> ? ElementType<U> : T;

function deepFlatten<T extends readonly unknown[]>(x: T): ElementType<T>[] {
  throw 'not implemented';
}

// All of these return the type 'number[]':
deepFlatten([1, 2, 3]);
deepFlatten([[1], [2, 3]]);
deepFlatten([[1], [[2]], [[[3]]]]);

类似地,在 TypeScript 4.1 中我们可以定义Awaited类型来拆解Promise

type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;

/// 类似于 `promise.then(...)`,但是类型更准确
declare function customThen<T, U>(
  p: Promise<T>,
  onFulfilled: (value: Awaited<T>) => U
): Promise<Awaited<U>>;

一定要注意,虽然这些递归类型很强大,但要有节制地使用它。

首先,这些类型能做的更多,但也会增加类型检查的耗时。 尝试为考拉兹猜想或斐波那契数列建模是一件有趣的事儿,但请不要在 npm 上发布带有它们的.d.ts文件。

除了计算量大之外,这些类型还可能会达到内置的递归深度限制。 如果到达了递归深度限制,则会产生编译错误。 通常来讲,最好不要去定义这样的类型。

更多详情,请参考PR.

索引访问类型检查(--noUncheckedIndexedAccess

TypeScript 支持一个叫做索引签名的功能。 索引签名用于告诉类型系统,用户可以访问任意名称的属性。

interface Options {
  path: string;
  permissions: number;

  // 额外的属性可以被这个签名捕获
  [propName: string]: string | number;
}

function checkOptions(opts: Options) {
  opts.path; // string
  opts.permissions; // number

  // 以下都是允许的
  // 它们的类型为 'string | number'
  opts.yadda.toString();
  opts['foo bar baz'].toString();
  opts[Math.random()].toString();
}

上例中,Options包含了索引签名,它表示在访问未直接列出的属性时得到的类型为string | number。 这是一种乐观的做法,它假想我们非常清楚代码在做什么,但实际上 JavaScript 中的大部分值并不支持任意的属性名。 例如,大多数类型并不包含属性名为Math.random()的值。 对许多用户来讲,这不是期望的行为,就好像没有利用到--strictNullChecks提供的严格类型检查。

这就是 TypeScript 4.1 提供了--noUncheckedIndexedAccess编译选项的原因。 在该新模式下,任何属性访问(例如foo.bar)或者索引访问(例如foo["bar"])都会被认为可能为undefined。 例如在上例中,opts.yadda的类型为string | number | undefined,而不是string | number。 如果需要访问那个属性,你可以先检查属性是否存在或者使用非空断言运算符(!后缀字符)。

// @noUncheckedIndexedAccess
interface Options {
  path: string;
  permissions: number;

  // 额外的属性可以被这个签名捕获
  [propName: string]: string | number;
}
// ---cut---
function checkOptions(opts: Options) {
  opts.path; // string
  opts.permissions; // number

  // 在 noUncheckedIndexedAccess 下,以下操作不允许
  opts.yadda.toString();
  opts['foo bar baz'].toString();
  opts[Math.random()].toString();

  // 首先检查是否存在
  if (opts.yadda) {
    console.log(opts.yadda.toString());
  }

  // 使用 ! 非空断言,“我知道在做什么”
  opts.yadda!.toString();
}

使用--noUncheckedIndexedAccess的一个结果是,通过索引访问数组元素时也会进行严格类型检查,就算是在遍历检查过边界的数组时。

// @noUncheckedIndexedAccess
function screamLines(strs: string[]) {
  // 下面会有问题
  for (let i = 0; i < strs.length; i++) {
    console.log(strs[i].toUpperCase());
  }
}

如果你不需要使用索引,那么可以使用for-of循环或forEach来遍历。

// @noUncheckedIndexedAccess
function screamLines(strs: string[]) {
  // 可以正常工作
  for (const str of strs) {
    console.log(str.toUpperCase());
  }

  // 可以正常工作
  strs.forEach(str => {
    console.log(str.toUpperCase());
  });
}

这个选项虽可以用来捕获访问越界的错误,但对大多数代码来讲有些烦,因此它不会被--strict选项自动启用;然而,如果你对此选项感兴趣,可以尝试一下,看它是否适用于你的代码。

更多详情,请参考PR.

不带 baseUrlpaths

路径映射的使用很常见 - 通常它用于优化导入语句,以及模拟在单一代码仓库中进行链接的行为。

不幸的是,在使用paths时必须指定baseUrl,它允许裸路径描述符基于baseUrl进行解析。 它会导致在自动导入时会使用较差的路径。

在 TypeScript 4.1 中,paths不必与baseUrl一起使用。 它会一定程序上帮助解决上述的问题。

checkJs 默认启用 allowJs

从前,如果你想要对 JavaScript 工程执行类型检查,你需要同时启用allowJscheckJs。 这样的体验让人讨厌,因此现在checkJs会默认启用allowJs

更多详情,请参考PR

React 17 JSX 工厂

TypeScript 4.1 通过以下两个编译选项来支持 React 17 中的jsxjsxs工厂函数:

  • react-jsx
  • react-jsxdev

这两个编译选项分别用于生产环境和开发环境中。 通常,编译选项之间可以继承。 例如,用于生产环境的tsconfig.json如下:

// ./src/tsconfig.json
{
  "compilerOptions": {
    "module": "esnext",
    "target": "es2015",
    "jsx": "react-jsx",
    "strict": true
  },
  "include": ["./**/*"]
}

另外一个用于开发环境的tsconfig.json如下:

// ./src/tsconfig.dev.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "jsx": "react-jsxdev"
  }
}

更多详情,请参考PR

在编辑器中支持 JSDoc @see 标签

编辑器对 TypeScript 和 JavaScript 代码中的 JSDoc 标签@see有了更好的支持。 它允许你使用像“跳转到定义”这样的功能。 例如,在下例中的 JSDoc 里可以使用跳转到定义到firstC

// @filename: first.ts
export class C {}

// @filename: main.ts
import * as first from './first';

/**
 * @see first.C
 */
function related() {}

感谢贡献者Wenlu Wang实现了这个功能

破坏性改动

lib.d.ts 更新

lib.d.ts包含一些 API 变动,在某种程度上是因为 DOM 类型是自动生成的。 一个具体的变动是Reflect.enumerate被删除了,因为它在 ES2016 中被删除了。

abstract 成员不能被标记为 async

abstract成员不再可以被标记为async。 这可以通过删除async关键字来修复,因为调用者只关注返回值类型。

any/unknown Are Propagated in Falsy Positions

从前,对于表达式foo && somethingElse,若foo的类型为anyunknown,那么整个表达式的类型为somethingElse

例如,在以前此处的x的类型为{ someProp: string }

declare let foo: unknown;
declare let somethingElse: { someProp: string };

let x = foo && somethingElse;

然而,在 TypeScript 4.1 中,会更谨慎地确定该类型。 由于不清楚&&左侧的类型,我们会传递anyunknown类型,而不是&&右侧的类型。

常见的模式是检查与boolean的兼容性,尤其是在谓词函数中。

function isThing(x: any): boolean {
  return x && typeof x === 'object' && x.blah === 'foo';
}

一种合适的修改是使用!!foo && someExpression来代替foo && someExpression

Promiseresolve的参数不再是可选的

在编写如下的代码时

new Promise(resolve => {
  doSomethingAsync(() => {
    doSomething();
    resolve();
  });
});

你可能会得到如下的错误:

  resolve()
  ~~~~~~~~~
error TS2554: Expected 1 arguments, but got 0.
  An argument for 'value' was not provided.

这是因为resolve不再有可选参数,因此默认情况下,必须给它传值。 它通常能够捕获Promise的 bug。 典型的修复方法是传入正确的参数,以及添加明确的类型参数。

new Promise<number>(resolve => {
  //     ^^^^^^^^
  doSomethingAsync(value => {
    doSomething();
    resolve(value);
    //      ^^^^^
  });
});

然而,有时resolve()确实需要不带参数来调用 在这种情况下,我们可以给Promise传入明确的void泛型类型参数(例如,Promise<void>)。 它利用了 TypeScript 4.1 中的一个新功能,一个潜在的void类型的末尾参数会变成可选参数。

new Promise<void>(resolve => {
  //     ^^^^^^
  doSomethingAsync(() => {
    doSomething();
    resolve();
  });
});

TypeScript 4.1 提供了快速修复选项来解决该问题。

有条件展开会创建可选属性

在 JavaScript 中,对象展开(例如,{ ...foo })不会操作假值。 因此,在{ ...foo }代码中,如果foo的值为nullundefined,则它会被略过。

很多人利用该性质来可选地展开属性。

interface Person {
  name: string;
  age: number;
  location: string;
}

interface Animal {
  name: string;
  owner: Person;
}

function copyOwner(pet?: Animal) {
  return {
    ...(pet && pet.owner),
    otherStuff: 123,
  };
}

// We could also use optional chaining here:

function copyOwner(pet?: Animal) {
  return {
    ...pet?.owner,
    otherStuff: 123,
  };
}

此处,如果pet定义了,那么pet.owner的属性会被展开 - 否则,不会有属性被展开到目标对象中。

在之前,copyOwner的返回值类型为基于每个展开运算结果的联合类型: The return type of copyOwner was previously a union type based on each spread:

{ x: number } | { x: number, name: string, age: number, location: string }

它精确地展示了操作是如何进行的:如果pet定义了,那么Person中的所有属性都存在;否则,在结果中不存在Person中的任何属性。 它是一种要么全有要么全无的的操作。

然而,我们发现这个模式被过度地使用了,在单一对象中存在数以百计的展开运算,每一个展开操作可能会添加成百上千的操作。 结果就是这项操作可能非常耗时,并且用处不大。

在 TypeScript 4.1 中,返回值类型有时会使用全部的可选类型。

{
    x: number;
    name?: string;
    age?: number;
    location?: string;
}

这样的结果是有更好的性能以及更佳地展示。

更多详情,请参考PR。 目前,该行为还不完全一致,我们期待在未来会有所改进。

从前 TypeScript 在关联参数时,如果参数之间没有联系,则会将其关联为any类型。 由于TypeScript 4.1 的改动,TypeScript 会完全跳过这个过程。 这意味着一些可赋值性检查会失败,同时也意味着重载解析可能会失败。 例如,在解析 Node.js 中util.promisify函数的重载时可能会选择不同的重载签名,这可能会导致产生新的错误。

做为一个变通方法,你可能需要使用类型断言来消除错误。

TypeScript 4.0

可变参元组类型

在 JavaScript 中有一个函数concat,它接受两个数组或元组并将它们连接在一起构成一个新数组。

function concat(arr1, arr2) {
  return [...arr1, ...arr2];
}

再假设有一个tail函数,它接受一个数组或元组并返回除首个元素外的所有元素。

function tail(arg) {
  const [_, ...result] = arg;
  return result;
}

那么,我们如何在 TypeScript 中为这两个函数添加类型?

在旧版本的 TypeScript 中,对于concat函数我们能做的是编写一些函数重载签名。

function concat(arr1: [], arr2: []): [];
function concat<A>(arr1: [A], arr2: []): [A];
function concat<A, B>(arr1: [A, B], arr2: []): [A, B];
function concat<A, B, C>(arr1: [A, B, C], arr2: []): [A, B, C];
function concat<A, B, C, D>(arr1: [A, B, C, D], arr2: []): [A, B, C, D];
function concat<A, B, C, D, E>(arr1: [A, B, C, D, E], arr2: []): [A, B, C, D, E];
function concat<A, B, C, D, E, F>(arr1: [A, B, C, D, E, F], arr2: []): [A, B, C, D, E, F];)

在保持第二个数组为空的情况下,我们已经编写了七个重载签名。 接下来,让我们为arr2添加一个参数。

function concat<A2>(arr1: [], arr2: [A2]): [A2];
function concat<A1, A2>(arr1: [A1], arr2: [A2]): [A1, A2];
function concat<A1, B1, A2>(arr1: [A1, B1], arr2: [A2]): [A1, B1, A2];
function concat<A1, B1, C1, A2>(
  arr1: [A1, B1, C1],
  arr2: [A2]
): [A1, B1, C1, A2];
function concat<A1, B1, C1, D1, A2>(
  arr1: [A1, B1, C1, D1],
  arr2: [A2]
): [A1, B1, C1, D1, A2];
function concat<A1, B1, C1, D1, E1, A2>(
  arr1: [A1, B1, C1, D1, E1],
  arr2: [A2]
): [A1, B1, C1, D1, E1, A2];
function concat<A1, B1, C1, D1, E1, F1, A2>(
  arr1: [A1, B1, C1, D1, E1, F1],
  arr2: [A2]
): [A1, B1, C1, D1, E1, F1, A2];

这已经开始变得不合理了。 不巧的是,在给tail函数添加类型时也会遇到同样的问题。

在受尽了“重载的折磨”后,它依然没有完全解决我们的问题。 它只能针对已编写的重载给出正确的类型。 如果我们想要处理所有情况,则还需要提供一个如下的重载:

function concat<T, U>(arr1: T[], arr2: U[]): Array<T | U>;

但是这个重载签名没有反映出输入的长度,以及元组元素的顺序。

TypeScript 4.0 带来了两项基础改动,还伴随着类型推断的改善,因此我们能够方便地添加类型。

第一个改动是展开元组类型的语法支持泛型。 这就是说,我们能够表示在元组和数组上的高阶操作,尽管我们不清楚它们的具体类型。 在实例化泛型展开时 当在这类元组上进行泛型展开实例化(或者使用实际类型参数进行替换)时,它们能够产生另一组数组和元组类型。

例如,我们可以像下面这样给tail函数添加类型,避免了“重载的折磨”。

function tail<T extends any[]>(arr: readonly [any, ...T]) {
  const [_ignored, ...rest] = arr;
  return rest;
}

const myTuple = [1, 2, 3, 4] as const;
const myArray = ['hello', 'world'];

const r1 = tail(myTuple);
//    [2, 3, 4]

const r2 = tail([...myTuple, ...myArray] as const);
//    [2, 3, 4, ...string[]]

第二个改动是,剩余元素可以出现在元组中的任意位置上 - 不只是末尾位置!

type Strings = [string, string];
type Numbers = [number, number];

type StrStrNumNumBool = [...Strings, ...Numbers, boolean];
//   [string, string, number, number, boolean]

在以前,TypeScript 会像下面这样产生一个错误:

剩余元素必须出现在元组类型的末尾。

但是在 TypeScript 4.0 中放开了这个限制。

注意,如果展开一个长度未知的类型,那么后面的所有元素都将被纳入到剩余元素类型。

type Strings = [string, string];
type Numbers = number[];

type Unbounded = [...Strings, ...Numbers, boolean];
//   [string, string, ...(number | boolean)[]]

结合使用这两种行为,我们能够为concat函数编写一个良好的类型签名:

type Arr = readonly any[];

function concat<T extends Arr, U extends Arr>(arr1: T, arr2: U): [...T, ...U] {
  return [...arr1, ...arr2];
}

虽然这个签名仍有点长,但是我们不再需要像重载那样重复多次,并且对于任何数组或元组它都能够给出期望的类型。

该功能本身已经足够好了,但是它的强大更体现在一些复杂的场景中。 例如,考虑有一个支持部分参数应用的函数partialCallpartialCall接受一个函数(例如叫作f),以及函数f需要的一些初始参数。 它返回一个新的函数,该函数接受f需要的额外参数,并最终以初始参数和额外参数来调用f

function partialCall(f, ...headArgs) {
  return (...tailArgs) => f(...headArgs, ...tailArgs);
}

TypeScript 4.0 改进了剩余参数和剩余元组元素的类型推断,因此我们可以为这种使用场景添加类型。

type Arr = readonly unknown[];

function partialCall<T extends Arr, U extends Arr, R>(
  f: (...args: [...T, ...U]) => R,
  ...headArgs: T
) {
  return (...tailArgs: U) => f(...headArgs, ...tailArgs);
}

此例中,partialCall知道能够接受哪些初始参数,并返回一个函数,它能够正确地选择接受或拒绝额外的参数。

// @errors: 2345 2554 2554 2345
type Arr = readonly unknown[];

function partialCall<T extends Arr, U extends Arr, R>(
  f: (...args: [...T, ...U]) => R,
  ...headArgs: T
) {
  return (...tailArgs: U) => f(...headArgs, ...tailArgs);
}
// ---cut---
const foo = (x: string, y: number, z: boolean) => {};

const f1 = partialCall(foo, 100);
//                          ~~~
// Argument of type 'number' is not assignable to parameter of type 'string'.

const f2 = partialCall(foo, 'hello', 100, true, 'oops');
//                                              ~~~~~~
// Expected 4 arguments, but got 5.(2554)

// This works!
const f3 = partialCall(foo, 'hello');
//    (y: number, z: boolean) => void

// What can we do with f3 now?

// Works!
f3(123, true);

f3();

f3(123, 'hello');

可变参元组类型支持了许多新的激动人心的模式,尤其是函数组合。 我们期望能够通过它来为 JavaScript 内置的bind函数进行更好的类型检查。 还有一些其它的类型推断改进以及模式引入进来,如果你想了解更多,请参考PR

标签元组元素

改进元组类型和参数列表使用体验的重要性在于它允许我们为 JavaScript 中惯用的方法添加强类型验证 - 例如对参数列表进行切片而后传递给其它函数。 这里至关重要的一点是我们可以使用元组类型作为剩余参数类型。

例如,下面的函数使用元组类型作为剩余参数:

function foo(...args: [string, number]): void {
  // ...
}

它与下面的函数基本没有区别:

function foo(arg0: string, arg1: number): void {
  // ...
}

对于foo函数的任意调用者:

function foo(arg0: string, arg1: number): void {
  // ...
}

foo('hello', 42);

foo('hello', 42, true); // Expected 2 arguments, but got 3.
foo('hello'); // Expected 2 arguments, but got 1.

但是,如果从代码可读性的角度来看,就能够看出两者之间的差别。 在第一个例子中,参数的第一个元素和第二个元素都没有参数名。 虽然这不影响类型检查,但是元组中元素位置上缺乏标签令它们难以使用 - 很难表达出代码的意图。

这就是为什么 TypeScript 4.0 中的元组可以提供标签。

type Range = [start: number, end: number];

为了加强参数列表和元组类型之间的联系,剩余元素和可选元素的语法采用了参数列表的语法。

type Foo = [first: number, second?: string, ...rest: any[]];

在使用标签元组时有一些规则要遵守。 其一是,如果一个元组元素使用了标签,那么所有元组元素必须都使用标签。

type Bar = [first: string, number];
// Tuple members must all have names or all not have names.(5084)

元组标签名不影响解构变量名,它们不必相同。 元组标签仅用于文档和工具目的。

function foo(x: [first: string, second: number]) {
  // ...

  // 注意:不需要命名为'first'和'second'
  const [a, b] = x;
  a;
  //  string
  b;
  //  number
}

总的来说,标签元组对于元组和参数列表模式以及实现类型安全的重载时是很便利的。 实际上,在代码编辑器中 TypeScript 会尽可能地将它们显示为重载。

Signature help displaying a union of labeled tuples as in a parameter list as two signatures

更多详情请参考PT

从构造函数中推断类属性

在 TypeScript 4.0 中,当启用了noImplicitAny时,编译器能够根据基于控制流的分析来确定类中属性的类型

class Square {
  // 在旧版本中,以下两个属性均为any类型
  area; // number
  sideLength; // number

  constructor(sideLength: number) {
    this.sideLength = sideLength;
    this.area = sideLength ** 2;
  }
}

如果没有在构造函数中的所有代码执行路径上为实例成员进行赋值,那么该属性会被认为可能为undefined类型。

class Square {
  sideLength; // number | undefined

  constructor(sideLength: number) {
    if (Math.random()) {
      this.sideLength = sideLength;
    }
  }

  get area() {
    return this.sideLength ** 2;
    //     ~~~~~~~~~~~~~~~
    //     对象可能为'undefined'
  }
}

如果你清楚地知道属性类型(例如,类中存在类似于initialize的初始化方法),你仍需要明确地使用类型注解来指定类型,以及需要使用确切赋值断言(!)如果你启用了strictPropertyInitialization模式。

class Square {
  // 确切赋值断言
  //        v
  sideLength!: number;
  //         ^^^^^^^^
  //         类型注解

  constructor(sideLength: number) {
    this.initialize(sideLength);
  }

  initialize(sideLength: number) {
    this.sideLength = sideLength;
  }

  get area() {
    return this.sideLength ** 2;
  }
}

更多详情请参考PR.

断路赋值运算符

JavaScript 以及其它很多编程语言支持一些复合赋值运算符。 复合赋值运算符作用于两个操作数,并将运算结果赋值给左操作数。 你从前可能见到过以下代码:

// 加
// a = a + b
a += b;

// 减
// a = a - b
a -= b;

// 乘
// a = a * b
a *= b;

// 除
// a = a / b
a /= b;

// 幂
// a = a ** b
a **= b;

// 左移位
// a = a << b
a <<= b;

JavaScript 中的许多运算符都具有一个对应的赋值运算符! 目前为止,有三个值得注意的例外:逻辑&&),逻辑||)和逻辑空值合并??)。

这就是为什么 TypeScript 4.0 支持了一个 ECMAScript 的新特性,增加了三个新的赋值运算符&&=||=??=

这三个运算符可以用于替换以下代码:

a = a && b;
a = a || b;
a = a ?? b;

或者相似的if语句

// could be 'a ||= b'
if (!a) {
  a = b;
}

还有以下的惰性初始化值的例子:

let values: string[];
(values ?? (values = [])).push('hello');

// After
(values ??= []).push('hello');

少数情况下当你使用带有副作用的存取器时,值得注意的是这些运算符只在必要时才执行赋值操作。 也就是说,不仅是运算符右操作数会“短路”,整个赋值操作也会“短路”

obj.prop ||= foo();

// roughly equivalent to either of the following

obj.prop || (obj.prop = foo());

if (!obj.prop) {
  obj.prop = foo();
}

尝试运行这个例子来查看与 始终执行赋值间的差别。

const obj = {
  get prop() {
    console.log('getter has run');

    // Replace me!
    return Math.random() < 0.5;
  },
  set prop(_val: boolean) {
    console.log('setter has run');
  },
};

function foo() {
  console.log('right side evaluated');
  return true;
}

console.log('This one always runs the setter');
obj.prop = obj.prop || foo();

console.log('This one *sometimes* runs the setter');
obj.prop ||= foo();

非常感谢社区成员Wenlu Wang为该功能的付出!

更多详情请参考PR. 你还可以查看该特性的 TC39 提案.

catch语句中的unknown类型

在 TypeScript 的早期版本中,catch语句中的捕获变量总为any类型。 这意味着你可以在捕获变量上执行任意的操作。

try {
  // Do some work
} catch (x) {
  // x 类型为 'any'
  console.log(x.message);
  console.log(x.toUpperCase());
  x++;
  x.yadda.yadda.yadda();
}

上述代码可能导致错误处理语句中产生了更多的错误,因此该行为是不合理的。 因为捕获变量默认为any类型,所以它不是类型安全的,你可以在上面执行非法操作。

TypeScript 4.0 允许将catch语句中的捕获变量类型声明为unknown类型。 unknown类型比any类型更加安全,因为它要求在使用之前必须进行类型检查。

try {
  // ...
} catch (e: unknown) {
  // Can't access values on unknowns
  console.log(e.toUpperCase());

  if (typeof e === 'string') {
    // We've narrowed 'e' down to the type 'string'.
    console.log(e.toUpperCase());
  }
}

由于catch语句捕获变量的类型不会被默认地改变成unknown类型,因此我们考虑在未来添加一个新的--strict标记来有选择性地引入该行为。 目前,我们可以通过使用代码静态检查工具来强制catch捕获变量使用了明确的类型注解: any: unknown

更多详情请参考PR.

自定义 JSX 工厂

在使用 JSX 时,fragment类型的 JSX 元素允许返回多个子元素。 当 TypeScript 刚开始实现 fragments 时,我们不太清楚其它代码库该如何使用它们。 最近越来越多的库开始使用 JSX 并支持与 fragments 结构相似的 API。

在 TypeScript 4.0 中,用户可以使用jsxFragmentFactory选项来自定义 fragment 工厂。

例如,下例的tsconfig.json文件告诉 TypeScript 使用与 React 兼容的方式来转换 JSX,但使用h来代替React.createElement工厂,同时使用Fragment来代替React.Fragment

{
  compilerOptions: {
    target: 'esnext',
    module: 'commonjs',
    jsx: 'react',
    jsxFactory: 'h',
    jsxFragmentFactory: 'Fragment',
  },
}

如果针对每个文件具有不同的 JSX 工厂,你可以使用新的/** @jsxFrag */编译指令注释。 示例:

// 注意:这些编译指令注释必须使用JSDoc风格,否则不起作用

/** @jsx h */
/** @jsxFrag Fragment */

import { h, Fragment } from 'preact';

export const Header = (
  <>
    <h1>Welcome</h1>
  </>
);

上述代码会转换为如下的 JavaScript

// 注意:这些编译指令注释必须使用JSDoc风格,否则不起作用

/** @jsx h */
/** @jsxFrag Fragment */

import { h, Fragment } from 'preact';

export const Header = h(Fragment, null, h('h1', null, 'Welcome'));

非常感谢社区成员Noj Vek为该特性的付出。

更多详情请参考PR

对启用了--noEmitOnError的`build 模式进行速度优化

在以前,当启用了--noEmitOnError编译选项时,如果在--incremental构建模式下的前一次构建出错了,那么接下来的构建会很慢。 这是因为当启用了--noEmitOnError时,前一次失败构建的信息不会被缓存到.tsbuildinfo文件中。

TypeScript 4.0 对此做出了一些改变,极大地提升了这种情况下的编译速度,改善了应用--build模式的场景(包含--incremental--noEmitOnError)。

更多详情请参考PR

--incremental--noEmit

TypeScript 4.0 允许同时使用--incremental--noEmit。 这在之前是不允许的,因为--incremental需要生成.tsbuildinfo文件; 然而,提供更快地增量构建对所有用户来讲都是十分重要的。

更多详情请参考PR

编辑器改进

TypeScript 编译器不但支持在大部分编辑器中编写 TypeScript 代码,还支持着在 Visual Studio 系列的编辑器中编写 JavaScript 代码。 因此,我们主要工作之一是改善编辑器支持 - 这也是程序员花费了大量时间的地方。

针对不同的编辑器,在使用 TypeScript/JavaScript 的新功能时可能会有所区别,但是

这里是支持 TypeScript 的编辑器列表,到这里查看你喜爱的编译器是否支持最新版本的 TypeScript。

转换为可选链

可选链是一个较新的大家喜爱的特性。 TypeScript 4.0 带来了一个新的重构工具来转换常见的代码模式,以利用可选链空值合并

将a && a.b.c && a.b.c.d.e.f()转换为a?.b.c?.d.e.f.()

注意,虽然该项重构不能完美地捕获真实情况(由于 JavaScript 中较复杂的真值/假值关系),但是我们坚信它能够适用于大多数使用场景,尤其是在 TypeScript 清楚地知道代码类型信息的时候。

更多详情请参考PR

/** @deprecated */支持

TypeScript 现在能够识别代码中的/** @deprecated *JSDoc 注释,并对编辑器提供支持。 该信息会显示在自动补全列表中以及建议诊断信息,编辑器可以特殊处理它。 在类似于 VS Code 的编辑器中,废弃的值会显示为删除线,例如~~like this~~。

Some examples of deprecated declarations with strikethrough text in the editor

感谢Wenlu Wang为该特性的付出。 更多详情请参考PR

启动时的局部语义模式

我们从用户反馈得知在启动一个大的工程时需要很长的时间。 罪魁祸首是一个叫作程序构造的处理过程。 该处理是从一系列根文件开始解析并查找它们的依赖,然后再解析依赖,然后再解析依赖的依赖,以此类推。 你的工程越大,你等待的时间就越长,在这之前你不能使用编辑器的诸如“跳转到定义”等功能。

这就是为什么我们要提供一个新的编辑器模式,在语言服务被完全加载之前提供局部编辑体验。 这里的主要想法是,编辑器可以运行一个轻量级的局部语言服务,它只关注编辑器当前打开的文件。

很难准确地形容能够获得多大的提升,但听说在 Visual Studio Code 项目中,以前需要等待20 秒到 1 分钟的时间来完全加载语言服务。 做为对比,*新的局部语义模式看起来能够将上述时间减少到几秒钟。* 示例,从下面的视频中,你可以看到左侧的 TypeScript 3.9 与右侧的 TypeScript 4.0 的对比。

当在编辑器中打开一个大型的代码仓库时,TypeScript 3.9 根本无法提供代码补全以及信息提示。 反过来,安装了 TypeScript 4.0 的编辑器能够在当前文件上立即提供丰富的编辑体验,尽管后台仍然在加载整个工程。

目前,唯一一个支持该模块的编辑器是Visual Studio Code,并且在Visual Studio Code Insiders版本中还带来了一些体验上的优化。 我们发现该特性在用户体验和功能性上仍有优化空间,我们总结了一个优化列表。 我们也期待你的使用反馈。

更多详情请参考原始的提议功能实现的 PR,以及后续的跟踪帖.

更智能的自动导入

自动导入是个特别好的功能,它让编码更加容易;然而,每一次自动导入不好用的时候,它就会导致一部分用户流失。 一个特殊的问题是,自动导入对于使用 TypeScript 编写的依赖不好用 - 也就是说,用户必须在工程中的某处明确地编写一个导入语句。

那么为什么自动导入在@types包上是好用的,但是对于自己编写的代码却不好用? 这表明自动导入功能只适用于工程中已经引入的包。 因为 TypeScript 会自动地将node_modules/@types下面的包引入进工程,那些包才会被自动导入。 另一方面,其它的包会被排除,因为遍历node_modules下所有的包相当费时。

这就导致了在自动导入一个刚刚安装完但还没有开始使用的包时具有相当差的体验。

TypeScript 4.0 对编辑器环境进行了一点小改动,它会自动引入你的工程下的package.json文件中dependencies(和peerDependencies)字段里列出的包。 这些引入的包只用于改进自动导入功能,它们对类型检查等其它功能没有任何影响。 这使得自动导入功能对于项目中所有带有类型的依赖项都是可用的,同时不必遍历node_modules

少数情况下,若在package.json中列出了多于 10 个未导入的带有类型的依赖,那么该功能会被自动禁用以避免过慢的工程加载。 若想要强制启用该功能,或完全禁用该功能,则需要配置你的编辑器。 针对 Visual Studio Code,对应到“Include Package JSON Auto Imports”配置(或者typescript.preferences.includePackageJsonAutoImports配置)。

Configuring 'include package JSON auto imports' For more details, you can see the proposal issue along with the implementing pull request.

我们的新网站

最近,我们重写了TypeScript 官网并且已经发布!

A screenshot of the new TypeScript website

我们在这里介绍了关于新网站的一些信息;但仍期望用户给予更多的反馈! 如果你有问题或建议,请到这里提交 Issue

改进类型推断和Promise.all

TypeScript 的最近几个版本(3.7 前后)更新了像Promise.allPromise.race等的函数声明。 不巧的是,它引入了一些回归问题,尤其是在和nullundefined混合使用的场景中。

interface Lion {
  roar(): void;
}

interface Seal {
  singKissFromARose(): void;
}

async function visitZoo(
  lionExhibit: Promise<Lion>,
  sealExhibit: Promise<Seal | undefined>
) {
  let [lion, seal] = await Promise.all([lionExhibit, sealExhibit]);
  lion.roar();
  //   ~~~~
  //  对象可能为'undefined'
}

这是一种奇怪的行为! 事实上,只有sealExhibit包含了undefined值,但是它却让lion也含有了undefined值。

得益于Jack Bates提交的PR,这个问题已经被修复了,它改进了 TypeScript 3.9 中的类型推断流程。 上面的例子中已经不再产生错误。 如果你在旧版本的 TypeScript 中被Promise的这个问题所困扰,我们建议你尝试一下 3.9 版本!

awaited 类型

如果你一直关注 TypeScript,那么你可能会注意到一个新的类型运算符awaited。 这个类型运算符的作用是准确地表达 JavaScript 中Promise的工作方式。

我们原计划在 TypeScript 3.9 中支持awaited,但在现有的代码中测试过该特性后,我们发现还需要进行一些设计,以便让所有人能够顺利地使用它。 因此,我们从主分支中暂时移除了这个特性。 我们将继续试验这个特性,它不会被包含进本次发布。

速度优化

TypeScript 3.9 提供了多项速度优化。 TypeScript 在material-uistyled-components代码包中拥有非常慢的编辑速度和编译速度。在发现了这点后,TypeScript 团队集中了精力解决性能问题。 TypeScript 优化了大型联合类型、交叉类型、有条件类型和映射类型。

上面列出的每一个 PR 都能够减少 5-10%的编译时间(对于某些代码库)。 对于material-ui库而言,现在能够节约大约 40%的编译时间!

我们还调整了在编辑器中的文件重命名功能。 从 Visual Studio Code 团队处得知,当重命名一个文件时,计算出需要更新的import语句要花费 5 到 10 秒的时间。 TypeScript 3.9 通过改变编译器和语言服务缓存文件查询的内部实现解决了这个问题。

尽管仍有优化的空间,我们希望当前的改变能够为每个人带来更流畅的体验。

// @ts-expect-error 注释

设想一下,我们正在使用 TypeScript 编写一个代码库,它对外开放了一个公共函数doStuff。 该函数的类型声明了它接受两个string类型的参数,因此其它 TypeScript 的用户能够看到类型检查的结果,但该函数还进行了运行时的检查以便 JavaScript 用户能够看到一个有帮助的错误。

function doStuff(abc: string, xyz: string) {
  assert(typeof abc === 'string');
  assert(typeof xyz === 'string');

  // do some stuff
}

如果有人错误地使用了该函数,那么 TypeScript 用户能够看到红色的波浪线和错误提示,JavaScript 用户会看到断言错误。 然后,我们想编写一条单元测试来测试该行为。

expect(() => {
  doStuff(123, 456);
}).toThrow();

不巧的是,如果你使用 TypeScript 来编译单元测试,TypeScript 会提示一个错误!

doStuff(123, 456);
//      ~~~
// 错误:类型'number'不能够赋值给类型'string'。

这就是 TypeScript 3.9 添加了// @ts-expect-error注释的原因。 当一行代码带有// @ts-expect-error注释时,TypeScript 不会提示上例的错误; 但如果该行代码没有错误,TypeScript 会提示没有必要使用// @ts-expect-error

示例,以下的代码是正确的:

// @ts-expect-error
console.log(47 * 'octopus');

但是下面的代码:

// @ts-expect-error
console.log(1 + 1);

会产生错误:

未使用的 '@ts-expect-error' 指令。

非常感谢Josh Goldberg实现了这个功能。 更多信息请参考the ts-expect-error pull request

ts-ignore 还是 ts-expect-error?

某些情况下,// @ts-expect-error// @ts-ignore是相似的,都能够阻止产生错误消息。 两者的不同在于,如果下一行代码没有错误,那么// @ts-ignore不会做任何事。

你可能会想要抛弃// @ts-ignore注释转而去使用// @ts-expect-error,并且想要知道哪一个更适用于以后的代码。 实际上,这完全取决于你和你的团队,下面列举了一些具体情况。

如果满足以下条件,那么选择ts-expect-error

  • 你在编写单元测试,并且想让类型系统提示错误
  • 你知道此处有问题,并且很快会回来改正它,只是暂时地忽略该错误
  • 你的团队成员都很积极,大家想要在代码回归正常后及时地删除忽略类型检查注释

如果满足以下条件,那么选择ts-ignore

  • 项目规模较大,产生了一些错误但是找不到相应代码的负责人
  • 正处于 TypeScript 版本升级的过程中,某些错误只在特定版本的 TypeScript 中存在,但是在其它版本中并不存在
  • 你没有足够的时间考虑究竟应该使用// @ts-ignore还是// @ts-expect-error

在条件表达式中检查未被调用的函数

在 TypeScript 3.7 中,我们引入了未进行函数调用的检查,当你忘记去调用某个函数时会产生错误。

function hasImportantPermissions(): boolean {
  // ...
}

// Oops!
if (hasImportantPermissions) {
  //  ~~~~~~~~~~~~~~~~~~~~~~~
  // 这个条件永远返回true,因为函数已经被定义。
  // 你是否想要调用该函数?
  deleteAllTheImportantFiles();
}

然而,这个错误只会在if条件语句中才会提示。 多亏了Alexander Tarasyuk提交的PR,现在这个特性也支持在三元表达式中使用,例如cond ? trueExpr : falseExpr

declare function listFilesOfDirectory(dirPath: string): string[];
declare function isDirectory(): boolean;

function getAllFiles(startFileName: string) {
  const result: string[] = [];
  traverse(startFileName);
  return result;

  function traverse(currentPath: string) {
    return isDirectory
      ? // ~~~~~~~~~~~
        // 该条件永远返回true
        // 因为函数已经被定义。
        // 你是否想要调用该函数?
        listFilesOfDirectory(currentPath).forEach(traverse)
      : result.push(currentPath);
  }
}

https://github.com/microsoft/TypeScript/issues/36048

编辑器改进

TypeScript 编译器不但支持在大部分编辑器中编写 TypeScript 代码,还支持着在 Visual Studio 系列的编辑器中编写 JavaScript 代码。 针对不同的编辑器,在使用 TypeScript/JavaScript 的新功能时可能会有所区别,但是

在 JavaScript 中自动导入 CommonJS 模块

在使用了 CommonJS 模块的 JavaScript 文件中,我们对自动导入功能进行了一个非常棒的改进。

在旧的版本中,TypeScript 总是假设你想要使用 ECMAScript 模块风格的导入语句,并且无视你的文件类型。

import * as fs from 'fs';

然而,在编写 JavaScript 文件时,并不总是想要使用 ECMAScript 模块风格。 非常多的用户仍然在使用 CommonJS 模块,例如require(...)

const fs = require('fs');

现在,TypeScript 会自动检测你正在使用的导入语句风格,并使用当前的导入语句风格。

更新信息请参考PR.

Code Actions 保留换行符

TypeScript 的重构工具和快速修复工具对换行符的处理不是非常好。 一个基本的示例如下。

const maxValue = 100;

/*start*/
for (let i = 0; i <= maxValue; i++) {
  // First get the squared value.
  let square = i ** 2;

  // Now print the squared value.
  console.log(square);
}
/*end*/

如果我们选中从/*start*//*end*/,然后进行“提取到函数”操作,我们会得到如下的代码。

const maxValue = 100;

printSquares();

function printSquares() {
  for (let i = 0; i <= maxValue; i++) {
    // First get the squared value.
    let square = i ** 2;
    // Now print the squared value.
    console.log(square);
  }
}

在旧版本的TypeScript中,将循环提取到函数时,换行符没有被保留。

这不是我们想要的 - 在for循环中,每条语句之间都有一个空行,但是重构后它们被移除了! TypeScript 3.9 调整后,它会保留我们编写的代码。

const maxValue = 100;

printSquares();

function printSquares() {
  for (let i = 0; i <= maxValue; i++) {
    // First get the squared value.
    let square = i ** 2;

    // Now print the squared value.
    console.log(square);
  }
}

在TypeScript 3.9中,将循环提取到函数时,会保留一个换行符。

更多信息请参考PR

快速修复:缺失的返回值表达式

有时候,我们可能忘记在函数的最后添加返回值语句,尤其是在将简单箭头函数转换成还有花括号的箭头函数时。

// before
let f1 = () => 42;

// oops - not the same!
let f2 = () => {
  42;
};

感谢开源社区的Wenlu WangPR,TypeScript 提供了快速修复功能来添加return语句,删除花括号,或者为箭头函数体添加小括号用以区分对象字面量。

示例

支持"Solution Style"的tsconfig.json文件

编译器需要知道一个文件被哪个配置文件所管理,因此才能够应用适当的配置选项并且计算出当前“工程”包含了哪些文件。 在默认情况下,编辑器使用 TypeScript 语言服务来向上遍历父级目录以查找tsconfig.json文件。

有一种特殊情况是tsconfig.json文件仅用于引用其它tsconfig.json文件。

// tsconfig.json
{
  files: [],
  references: [
    { path: './tsconfig.shared.json' },
    { path: './tsconfig.frontend.json' },
    { path: './tsconfig.backend.json' },
  ],
}

这个文件除了用来管理其它项目的配置文件之外什么也没做,在某些环境中它被叫作“solution”。 这里,任何一个tsconfig.*.json文件都不会被 TypeScript 语言服务所选用,但是我们希望语言服务能够分析出当前的.ts文件被上述tsconfig.json中引用的哪个配置文件所管理。

TypeScript 3.9 为这种类型的配置方式添加了编辑器的支持。 更多信息请参考PR.

TypeScript 3.8

类型导入和导出(Type-Only Imports and Exports)

This feature is something most users may never have to think about; however, if you've hit issues under --isolatedModules, TypeScript's transpileModule API, or Babel, this feature might be relevant.

TypeScript 3.8 adds a new syntax for type-only imports and exports.

import type { SomeThing } from './some-module.js';

export type { SomeThing };

import type only imports declarations to be used for type annotations and declarations. It always gets fully erased, so there's no remnant of it at runtime. Similarly, export type only provides an export that can be used for type contexts, and is also erased from TypeScript's output.

It's important to note that classes have a value at runtime and a type at design-time, and the use is context-sensitive. When using import type to import a class, you can't do things like extend from it.

import type { Component } from 'react';

interface ButtonProps {
  // ...
}

class Button extends Component<ButtonProps> {
  //               ~~~~~~~~~
  // error! 'Component' only refers to a type, but is being used as a value here.
  // ...
}

If you've used Flow before, the syntax is fairly similar. One difference is that we've added a few restrictions to avoid code that might appear ambiguous.

// Is only 'Foo' a type? Or every declaration in the import?
// We just give an error because it's not clear.

import type Foo, { Bar, Baz } from 'some-module';
//     ~~~~~~~~~~~~~~~~~~~~~~
// error! A type-only import can specify a default import or named bindings, but not both.

In conjunction with import type, TypeScript 3.8 also adds a new compiler flag to control what happens with imports that won't be utilized at runtime: importsNotUsedAsValues. This flag takes 3 different values:

  • remove: this is today's behavior of dropping these imports. It's going to continue to be the default, and is a non-breaking change.
  • preserve: this preserves all imports whose values are never used. This can cause imports/side-effects to be preserved.
  • error: this preserves all imports (the same as the preserve option), but will error when a value import is only used as a type. This might be useful if you want to ensure no values are being accidentally imported, but still make side-effect imports explicit.

For more information about the feature, you can take a look at the pull request, and relevant changes around broadening where imports from an import type declaration can be used.

ECMAScript 私有变量(ECMAScript Private Fields

TypeScript 3.8 brings support for ECMAScript's private fields, part of the stage-3 class fields proposal.

class Person {
  #name: string;

  constructor(name: string) {
    this.#name = name;
  }

  greet() {
    console.log(`Hello, my name is ${this.#name}!`);
  }
}

let jeremy = new Person('Jeremy Bearimy');

jeremy.#name;
//     ~~~~~
// Property '#name' is not accessible outside class 'Person'
// because it has a private identifier.

Unlike regular properties (even ones declared with the private modifier), private fields have a few rules to keep in mind. Some of them are:

  • Private fields start with a # character. Sometimes we call these private names.
  • Every private field name is uniquely scoped to its containing class.
  • TypeScript accessibility modifiers like public or private can't be used on private fields.
  • Private fields can't be accessed or even detected outside of the containing class - even by JS users! Sometimes we call this hard privacy.

Apart from "hard" privacy, another benefit of private fields is that uniqueness we just mentioned. For example, regular property declarations are prone to being overwritten in subclasses.

class C {
  foo = 10;

  cHelper() {
    return this.foo;
  }
}

class D extends C {
  foo = 20;

  dHelper() {
    return this.foo;
  }
}

let instance = new D();
// 'this.foo' refers to the same property on each instance.
console.log(instance.cHelper()); // prints '20'
console.log(instance.dHelper()); // prints '20'

With private fields, you'll never have to worry about this, since each field name is unique to the containing class.

class C {
  #foo = 10;

  cHelper() {
    return this.#foo;
  }
}

class D extends C {
  #foo = 20;

  dHelper() {
    return this.#foo;
  }
}

let instance = new D();
// 'this.#foo' refers to a different field within each class.
console.log(instance.cHelper()); // prints '10'
console.log(instance.dHelper()); // prints '20'

Another thing worth noting is that accessing a private field on any other type will result in a TypeError!

class Square {
  #sideLength: number;

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

  equals(other: any) {
    return this.#sideLength === other.#sideLength;
  }
}

const a = new Square(100);
const b = { sideLength: 100 };

// Boom!
// TypeError: attempted to get private field on non-instance
// This fails because 'b' is not an instance of 'Square'.
console.log(a.equals(b));

Finally, for any plain .js file users, private fields always have to be declared before they're assigned to.

class C {
  // No declaration for '#foo'
  // :(

  constructor(foo: number) {
    // SyntaxError!
    // '#foo' needs to be declared before writing to it.
    this.#foo = foo;
  }
}

JavaScript has always allowed users to access undeclared properties, whereas TypeScript has always required declarations for class properties. With private fields, declarations are always needed regardless of whether we're working in .js or .ts files.

class C {
  /** @type {number} */
  #foo;

  constructor(foo: number) {
    // This works.
    this.#foo = foo;
  }
}

For more information about the implementation, you can check out the original pull request

Which should I use?

We've already received many questions on which type of privates you should use as a TypeScript user: most commonly, "should I use the private keyword, or ECMAScript's hash/pound (#) private fields?" It depends!

When it comes to properties, TypeScript's private modifiers are fully erased - that means that at runtime, it acts entirely like a normal property and there's no way to tell that it was declared with a private modifier. When using the private` keyword, privacy is only enforced at compile-time/design-time, and for JavaScript consumers it's entirely intent-based.

class C {
  private foo = 10;
}

// This is an error at compile time,
// but when TypeScript outputs .js files,
// it'll run fine and print '10'.
console.log(new C().foo); // prints '10'
//                  ~~~
// error! Property 'foo' is private and only accessible within class 'C'.

// TypeScript allows this at compile-time
// as a "work-around" to avoid the error.
console.log(new C()['foo']); // prints '10'

The upside is that this sort of "soft privacy" can help your consumers temporarily work around not having access to some API, and also works in any runtime.

On the other hand, ECMAScript's # privates are completely inaccessible outside of the class.

class C {
  #foo = 10;
}

console.log(new C().#foo); // SyntaxError
//                  ~~~~
// TypeScript reports an error *and*
// this won't work at runtime!

console.log(new C()['#foo']); // prints undefined
//          ~~~~~~~~~~~~~~~
// TypeScript reports an error under 'noImplicitAny',
// and this prints 'undefined'.

This hard privacy is really useful for strictly ensuring that nobody can take use of any of your internals. If you're a library author, removing or renaming a private field should never cause a breaking change.

As we mentioned, another benefit is that subclassing can be easier with ECMAScript's # privates because they really are private. When using ECMAScript # private fields, no subclass ever has to worry about collisions in field naming. When it comes to TypeScript's private property declarations, users still have to be careful not to trample over properties declared in superclasses.

One more thing to think about is where you intend for your code to run. TypeScript currently can't support this feature unless targeting ECMAScript 2015 (ES6) targets or higher. This is because our downleveled implementation uses WeakMaps to enforce privacy, and WeakMaps can't be polyfilled in a way that doesn't cause memory leaks. In contrast, TypeScript's private-declared properties work with all targets - even ECMAScript 3!

A final consideration might be speed: private properties are no different from any other property, so accessing them is as fast as any other property access no matter which runtime you target. In contrast, because # private fields are downleveled using WeakMaps, they may be slower to use. While some runtimes might optimize their actual implementations of # private fields, and even have speedy WeakMap implementations, that might not be the case in all runtimes.

export * as ns Syntax

It's often common to have a single entry-point that exposes all the members of another module as a single member.

import * as utilities from './utilities.js';
export { utilities };

This is so common that ECMAScript 2020 recently added a new syntax to support this pattern!

export * as utilities from './utilities.js';

This is a nice quality-of-life improvement to JavaScript, and TypeScript 3.8 implements this syntax. When your module target is earlier than es2020, TypeScript will output something along the lines of the first code snippet.

顶层 await(Top-Level await)

TypeScript 3.8 provides support for a handy upcoming ECMAScript feature called "top-level await".

JavaScript users often introduce an async function in order to use await, and then immediately called the function after defining it.

async function main() {
  const response = await fetch('...');
  const greeting = await response.text();
  console.log(greeting);
}

main().catch(e => console.error(e));

This is because previously in JavaScript (along with most other languages with a similar feature), await was only allowed within the body of an async function. However, with top-level await, we can use await at the top level of a module.

const response = await fetch('...');
const greeting = await response.text();
console.log(greeting);

// Make sure we're a module
export {};

Note there's a subtlety: top-level await only works at the top level of a module, and files are only considered modules when TypeScript finds an import or an export. In some basic cases, you might need to write out export {} as some boilerplate to make sure of this.

Top level await may not work in all environments where you might expect at this point. Currently, you can only use top level await when the target compiler option is es2017 or above, and module is esnext or system. Support within several environments and bundlers may be limited or may require enabling experimental support.

For more information on our implementation, you can check out the original pull request.

es2020 for target and module

TypeScript 3.8 supports es2020 as an option for module and target. This will preserve newer ECMAScript 2020 features like optional chaining, nullish coalescing, export * as ns, and dynamic import(...) syntax. It also means bigint literals now have a stable target below esnext.

JSDoc 属性修饰词(JSDoc Property Modifiers)

TypeScript 3.8 supports JavaScript files by turning on the allowJs flag, and also supports type-checking those JavaScript files via the checkJs option or by adding a // @ts-check comment to the top of your .js files.

Because JavaScript files don't have dedicated syntax for type-checking, TypeScript leverages JSDoc. TypeScript 3.8 understands a few new JSDoc tags for properties.

First are the accessibility modifiers: @public, @private, and @protected. These tags work exactly like public, private, and protected respectively work in TypeScript.

// @ts-check

class Foo {
  constructor() {
    /** @private */
    this.stuff = 100;
  }

  printStuff() {
    console.log(this.stuff);
  }
}

new Foo().stuff;
//        ~~~~~
// error! Property 'stuff' is private and only accessible within class 'Foo'.
  • @public 是默认的,可以省略,它代表了一个属性可以从任何地方访问它
  • @private 表示一个属性只能在包含的类中访问
  • @protected 表示该属性只能在所包含的类及子类中访问,但不能在类的实例中访问

下一步,我们计划添加 @readonly 修饰符,来确保一个属性只能在初始化时被修改:

// @ts-check

class Foo {
  constructor() {
    /** @readonly */
    this.stuff = 100;
  }

  writeToStuff() {
    this.stuff = 200;
    //   ~~~~~
    // Cannot assign to 'stuff' because it is a read-only property.
  }
}

new Foo().stuff++;
//        ~~~~~
// Cannot assign to 'stuff' because it is a read-only property.

Better Directory Watching on Linux and watchOptions

TypeScript 3.8 ships a new strategy for watching directories, which is crucial for efficiently picking up changes to node_modules.

For some context, on operating systems like Linux, TypeScript installs directory watchers (as opposed to file watchers) on node_modules and many of its subdirectories to detect changes in dependencies. This is because the number of available file watchers is often eclipsed by the of files in node_modules, whereas there are way fewer directories to track.

Older versions of TypeScript would immediately install directory watchers on folders, and at startup that would be fine; however, during an npm install, a lot of activity will take place within node_modules and that can overwhelm TypeScript, often slowing editor sessions to a crawl. To prevent this, TypeScript 3.8 waits slightly before installing directory watchers to give these highly volatile directories some time to stabilize.

Because every project might work better under different strategies, and this new approach might not work well for your workflows, TypeScript 3.8 introduces a new watchOptions field in tsconfig.json and jsconfig.json which allows users to tell the compiler/language service which watching strategies should be used to keep track of files and directories.

{
  // Some typical compiler options
  compilerOptions: {
    target: 'es2020',
    moduleResolution: 'node',
    // ...
  },

  // NEW: Options for file/directory watching
  watchOptions: {
    // Use native file system events for files and directories
    watchFile: 'useFsEvents',
    watchDirectory: 'useFsEvents',

    // Poll files for updates more frequently
    // when they're updated a lot.
    fallbackPolling: 'dynamicPriority',
  },
}

watchOptions 包含四种新的选项:

  • watchFile: 监听单个文件的策略,它可以有以下值
    • fixedPollingInterval: 以固定的时间间隔,检查文件的更改
    • priorityPollingInterval: 以固定的时间间隔,检查文件的更改,但是使用「heuristics」检查某些类型的文件的频率比其他文件低(heuristics 怎么翻?)
    • dynamicPriorityPolling: 使用动态队列,在该队列中,较少检查不经常修改的文件
    • useFsEvents (默认): 尝试使用操作系统/文件系统原生事件来监听文件更改
    • useFsEventsOnParentDirectory: 尝试使用操作系统/文件系统原生事件来监听文件、目录的更改,这样可以使用较小的文件监听程序,但是准确性可能较低
  • watchDirectory: 在缺少递归文件监听功能的系统中,使用哪种策略监听整个目录树,它可以有以下值 :
    • fixedPollingInterval: 以固定的时间间隔,检查目录树的更改
    • dynamicPriorityPolling: 使用动态队列,在该队列中,较少检查不经常修改的目录
    • useFsEvents (默认): 尝试使用操作系统/文件系统原生事件来监听目录更改
  • fallbackPolling: 当使用文件系统的事件,该选项用来指定使用特定策略,它可以有以下值
    • fixedPollingInterval: (同上)
    • priorityPollingInterval: (同上)
    • dynamicPriorityPolling: (同上)
  • synchronousWatchDirectory: 在目录上禁用延迟监听功能。在可能一次发生大量文件(如 node_modules)更改时,它非常有用,但是你可能需要一些不太常见的设置时,禁用它。

For more information on these changes, head over to GitHub to see the pull request to read more.

"Fast and Loose" Incremental Checking

TypeScript 3.8 introduces a new compiler option called assumeChangesOnlyAffectDirectDependencies. When this option is enabled, TypeScript will avoid rechecking/rebuilding all truly possibly-affected files, and only recheck/rebuild files that have changed as well as files that directly import them.

For example, consider a file fileD.ts that imports fileC.ts that imports fileB.ts that imports fileA.ts as follows:

fileA.ts <- fileB.ts <- fileC.ts <- fileD.ts

In --watch mode, a change in fileA.ts would typically mean that TypeScript would need to at least re-check fileB.ts, fileC.ts, and fileD.ts. Under assumeChangesOnlyAffectDirectDependencies, a change in fileA.ts means that only fileA.ts and fileB.ts need to be re-checked.

In a codebase like Visual Studio Code, this reduced rebuild times for changes in certain files from about 14 seconds to about 1 second. While we don't necessarily recommend this option for all codebases, you might be interested if you have an extremely large codebase and are willing to defer full project errors until later (e.g. a dedicated build via a tsconfig.fullbuild.json or in CI).

For more details, you can see the original pull request.

TypeScript 3.7

可选链(Optional Chaining)

Playground

在我们的 issue 列表上,可选链是 issue #16。感受一下,从那之后 TypeScript 的 issue 列表中新增了 23,000 条 issues。

可选链的核心是,在我们编写代码中,当遇到 nullundefined,TypeScript 可以立即停止解析一部分表达式。 可选链的关键点是一个为 可选属性访问 提供的新的运算符 ?.。 比如我们可以这样写代码:

let x = foo?.bar.baz();

意思是,当 foo 有定义时,执行 foo.bar.baz() 的计算;但是当 foonullundefined 时,停止后续的解析,直接返回 undefined

更明确地说,上面的代码和下面的代码等价。

let x = foo === null || foo === undefined ? undefined : foo.bar.baz();

注意,当 barnullundefined,我们的代码访问 baz 依然会报错。 同理,当 baznullundefined,在调用时也会报错。 ?. 只检查它 左边 的值是不是 nullundefined,不检查后续的属性。

你会发现自己可以使用 ?. 来替换用了 && 的大量空值检查代码。

// 以前
if (foo && foo.bar && foo.bar.baz) {
  // ...
}

// 以后
if (foo?.bar?.baz) {
  // ...
}

注意,?.&& 的行为略有不同,因为 && 会作用在所有“假”值上(例如,空字符串、0NaN 以及 false),但 ?. 是一个仅作用于结构上的特性。 它不会在有效数据(比如 0 或空字符串)上进行短路计算。

可选链还包括两个另外的用法。 首先是 可选元素访问,表现类似于可选属性访问,但是也允许我们访问非标识符属性(例如:任意字符串、数字和 symbol):

/**
 * 如果 arr 是一个数组,返回第一个元素
 * 否则返回 undefined
 */
function tryGetFirstElement<T>(arr?: T[]) {
  return arr?.[0];
  // 等价于:
  //   return (arr === null || arr === undefined) ?
  //       undefined :
  //       arr[0];
}

另一个是 可选调用,判断条件是当该表达式不是 nullundefined,我们就可以调用它。

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 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;
  //     ~~~~~~~~
  // Error: Object is possibly undefined.
}

想了解更多细节,你可以 检阅完整的草案 以及 查看原始的 PR

空值合并(Nullish Coalescing)

Playground

空值合并运算符 是另一个即将到来的 ECMAScript 特性(与可选链一起),我们的团队也参与了 TC39 的的讨论工作。

你可以考虑使用 ?? 运算符来实现:当字段是 nullundefined 时,“回退”到默认值。 比如我们可以这样写代码:

let x = foo ?? bar();

这种新方式的意思是,当 foo “存在”时 x 等于 foo; 但假如 foonullundefined ,x 等于 bar() 的计算结果。

同样的,上面的代码可以写出等价代码。

let x = foo !== null && foo !== undefined ? foo : bar();

当尝试使用默认值时,?? 运算符可以代替 || 的作用。 例如,下面的代码片段尝试获取上一次储存在 localStorage 中的 volume(如果它已保存); 但是因为使用了 || ,留下一个 bug。

function initializeAudio() {
  let volume = localStorage.volume || 0.5;

  // ...
}

如果 localStorage.volume 的值是 0,这段代码将会把 volume 的值设置为 0.5,这是一个意外情况。 而 ?? 避免了将 0NaN"" 视为假值的意外情况。

我们非常感谢社区成员 Wenlu WangTitian Cernicova Dragomir 实现了这个特性! 想了解更多细节,你可以 查看他们的 PR空值合并草案的 Repo

断言函数

Playground

有一类特定的函数,用于在出现非预期结果时抛出一个错误。 这样的函数叫做“断言”函数(Assertion Function)。 比方说,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 依然能检查出来就太棒了!
}

有一个替代的写法,可以让 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 参数必须为真(否则它应该抛出一个 Error)。 这意味着对于同作用域中的后续代码,条件必须为真。 回到例子上,用这个断言函数意味着我们 能够 捕获之前 yell 示例中的错误。

function yell(str) {
  assert(typeof str === 'string');

  return str.toUppercase();
  //         ~~~~~~~~~~~
  // error: Property 'toUppercase' does not exist on type 'string'.
  //        Did you mean '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();
  //         ~~~~~~~~~~~
  // error: Property 'toUppercase' does not exist on type 'string'.
  //        Did you mean 'toUpperCase'?
}

这些断言方法签名类似于类型谓词(type predicate)签名:

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}`
    );
  }
}

想了解更多断言签名的细节,可以 查看原始的 PR

更好地支持返回 never 的函数

作为断言签名实现的一部分,TypeScript 需要编码更多关于调用位置和调用函数的细节。 这给了我们机会扩展对另一类函数的支持——返回 never 的函数。

返回 never 的函数,即永远不会返回的函数。 它表明抛出了异常、触发了停止错误条件、或程序退出的情况。 例如,@types/node 中的 process.exit(...) 就被指定为返回 never

为了确保函数永远不会潜在地返回 undefined、或者从所有代码路径中有效地返回,TypeScript 需要借助一些语法标志——函数结尾处的 returnthrow。 这样,使用者就会发现自己的代码在“返回”一个停机函数。

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

你可以和在断言函数的 同一个 PR 中查看更多细节

(更加)递归的类型别名

Playground

类型别名在“递归”引用方面一直存在局限性。 原因是,类型别名必须能用它代表的东西来代替自己。 这在某些情况下是不可能的,因此编译器会拒绝某些递归别名,比如下面这个:

type Foo = Foo;

这是一个合理的限制,因为任何对 Foo 的使用都可以替换为 Foo,同时这个 Foo 能够替换为 Foo,而这个 Foo 应该……(产生了无限循环)希望你理解到这个意思了! 到最后,没有类型可以用来代替 Foo

其他语言也是这么处理类型别名的,但是它确实会产生一些令人困惑的情形,影响类型别名的使用。 例如,在 TypeScript 3.6 和更低的版本中,下面的代码会报错:

type ValueOrArray<T> = T | Array<ValueOrArray<T>>;
//   ~~~~~~~~~~~~
// error: Type alias 'ValueOrArray' circularly references itself.

这很令人困惑,因为使用者总是可以用接口来编写具有相同作用的代码,那么从技术上讲这没什么问题。

type ValueOrArray<T> = T | ArrayOfValueOrArray<T>;

interface ArrayOfValueOrArray<T> extends Array<ValueOrArray<T>> {}

因为接口(以及其他对象 type)引入了一个间接的层级,并且它们的完整结构不需要立即建立,所以 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> {}

终于可以重写成不需要借助 interface 的形式。

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"],
];

想了解更多细节,你可以 查看原始的 PR

--declaration--allowJs

--declaration 选项允许我们从 TypeScript 源文件(诸如 .ts.tsx 文件)生成 .d.ts 文件(声明文件)。 .d.ts 文件的重要性有几个方面:

首先,它们使得 TypeScript 能够对外部项目进行类型检查,同时避免重复检查其源代码。 另一方面,它们使得 TypeScript 能够与现存的 JavaScript 库相互配合,即使这些库构建时并未使用 TypeScript。 最后,还有一个通常被忽略的好处:在使用支持 TypeScript 的编辑器时,TypeScript JavaScript 使用者都可以从这些文件中受益,例如更高级的自动完成。

不幸的是,--declaration 不能与 --allowJs 选项一起使用,--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;

/**
 * Produces a blurred image from an input buffer.
 *
 * @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 文件

/**
 * Produces a blurred image from an input buffer.
 *
 * @param input {Uint8Array}
 * @param width {number}
 * @param height {number}
 */
export function blurImage(
  input: Uint8Array,
  width: number,
  height: number
): Uint8Array;

除了基本的带有 @param 标记的函数,也支持其他情形, 请看下面这个例子:

/**
 * @callback Job
 * @returns {void}
 */

/** Queues work */
export class Worker {
  constructor(maxDepth = 10) {
    this.started = false;
    this.depthLimit = maxDepth;
    /**
     * NOTE: queued jobs may add more items to queue
     * @type {Job[]}
     */
    this.queue = [];
  }
  /**
   * Adds a work item to the queue
   * @param {Job} work
   */
  push(work) {
    if (this.queue.length + 1 > this.depthLimit) throw new Error('Queue full!');
    this.queue.push(work);
  }
  /**
   * Starts the queue if it has not yet started
   */
  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}
 */
/** Queues work */
export class Worker {
  constructor(maxDepth?: number);
  started: boolean;
  depthLimit: number;
  /**
   * NOTE: queued jobs may add more items to queue
   * @type {Job[]}
   */
  queue: Job[];
  /**
   * Adds a work item to the queue
   * @param {Job} work
   */
  push(work: Job): void;
  /**
   * Starts the queue if it has not yet started
   */
  start(): boolean;
}
export type Job = () => void;

注意,当同时启用这两个选项时,TypeScript 不一定必须得编译成 .js 文件。 如果只是简单的想让 TypeScript 创建 .d.ts 文件,你可以启用 --emitDeclarationOnly 编译选项。

想了解更多细节,你可以 查看原始的 PR

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() {
    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' now has a 'declare' modifier,
  // and won't produce any output code.

  constructor(dog: Dog) {
    super(dog);
  }
}

目前,只有当编译目标是 ES5 及以上时 useDefineForClassFields 才可用,因为 ES3 中不支持 Object.defineProperty。 要检查类似的问题,你可以创建一个分离的项目,设定编译目标为 ES5 并使用 --noEmit 来避免完全构建。

想了解更多细节,你可以 去原始的 PR 查看这些改动

我们强烈建议使用者尝试 useDefineForClassFields,并在 issues 或下面的评论区域中提供反馈。 应该碰到编译选项在使用难度上的反馈,这样我们就能够了解如何使迁移变得更容易。

利用项目引用实现无构建编辑

TypeScript 的项目引用功能,为我们提供了一种简单的方法来分解代码库,从而使编译速度更快。 遗憾的是,当我们编辑一个依赖未曾构建(或者构建结果过时)的项目时,体验不好。

在 TypeScript 3.7 中,当打开一个带有依赖的项目时,TypeScript 将自动切换为使用依赖中的 .ts/.tsx 源码文件。 这意味着在带有外部引用的项目中,代码的修改会即时同步和生效,编码体验会得到提升。 你也可以适当地打开编译器选项 disableSourceOfProjectReferenceRedirect 来禁用这个引用的功能,因为在超大型项目中这个功能可能会影响性能。

你可以 阅读这个 PR 来了解这个改动的更多细节

检查未调用的函数

一个常见且危险的错误是:忘记调用一个函数,特别是当该函数不需要参数,或者它的命名容易被误认为是一个属性而不是函数时。

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) {
    //  ~~~~~~~~~~~~~~~~~~~~
    // error! This condition will always return true since the function is always defined.
    //        Did you mean to call it instead?

这个检查功能是一个破坏性变更,基于这个因素,检查会非常保守。 因此对这类错误的提示仅限于 if 条件语句中。当问题函数是可选属性、或未开启 strictNullChecks 选项、或该函数在 if 的代码块中有被调用,在这些情况下不会被视为错误:

interface User {
  isAdministrator(): boolean;
  notify(): void;
  doNotDisturb?(): boolean;
}

function issueNotification(user: User) {
  if (user.doNotDisturb) {
    // OK,属性是可选的
  }
  if (user.notify) {
    // OK,调用了该函数
    user.notify();
  }
}

如果你打算对该函数进行测试但不调用它,你可以修改它的类型定义,让它可能是 undefined/null,或使用 !! 来编写类似 if (!!user.isAdministrator) 的代码,表示代码逻辑确实是这样的。

我们非常感谢社区成员 @jwbay 提出了 这个问题的概念 并持续跟进实现了 这个需求的当前版本

TypeScript 文件中的 // @ts-nocheck

TypeScript 3.7 允许我们在 TypeScript 文件的顶部添加一行 // @ts-nocheck 注释来关闭语义检查。 这个注释原本只在 checkJs 选项启用时的 JavaScript 源文件中有效,但我们扩展了它,让它能够支持 TypeScript 文件,这样所有使用者在迁移的时候会更方便。

分号格式化选项

JavaScript 有一个自动分号插入(ASI,automatic semicolon insertion)规则,TypeScript 内置的格式化程序现在能支持在可选的尾分号位置插入或删除分号。该设置现在在 Visual Studio Code Insiders ,以及 Visual Studio 16.4 Preview 2 中的“工具选项”菜单中可用。

New semicolon formatter option in VS Code

将值设定为 “insert” 或 “remove” 同时也会影响自动导入、类型提取、以及其他 TypeScript 服务提供的自动生成代码的格式。将设置保留为默认值 “ignore” 可以使生成代码的分号自动配置匹配当前文件的风格。

3.7 的破坏性变更

DOM 变更

lib.dom.d.ts 中的类型声明已更新。 这些变更大部分是与空值检查有关的检测准确性变更,最终的影响取决于你的代码库。

类字段处理

正如上文提到的,TypeScript 3.7 现在能够在 .d.ts 文件中编译出 get/set,这可能对 3.5 和更低版本的 TypeScript 使用者来说是破坏性变更。 TypeScript 3.6 的使用者不会受影响,因为该版本对这个功能已经进行了预兼容。

useDefineForClassFields 选项虽然自身没有破坏性变更,但不排除以下情形:

  • 在派生类中用属性声明覆盖了基类的访问器
  • 覆盖声明属性,但是没有初始值

要了解全部的影响,请查看 上面关于 useDefineForClassFields 的章节

函数真值检查

正如上文提到的,现在当函数在 if 条件语句中未被调用时 TypeScript 会报错。 当 if 条件语句中判断的是函数时将会报错,除非符合以下情形:

  • 该函数是可选属性
  • 未开启 strictNullChecks 选项
  • 该函数在 if 的代码块中有被调用

本地和导入的类型声明现在会产生冲突

TypeScript 之前有一个 bug,导致允许以下代码结构:

// ./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); // Error! 'x' doesn't exist on 'SomeType'
}

这里,SomeType 同时来源于 import 声明和本地 interface 声明。 出人意料的是,在模块内部,SomeType 只会指向 import 的定义,而本地声明的 SomeType 仅在另一个文件的导入中起效。 这很令人困惑,我们对类似的个例进行的调查表明,广大开发者通常理解的情况不一样。

在 TypeScript 3.7 中,这个问题中的重复声明现在可以被正确地识别为一个错误。 合理的修复方案取决于开发者的原始意图,并应该逐案解决。 通常,命名冲突不是故意的,最好的办法是重命名导入的那个类型。 如果是要扩展导入的类型,则可以编写模块扩展(module augmentation)来代替。

3.7 API 变化

为了实现上文中提到的递归的类型别名模式,TypeReference 接口已经移除了 typeArguments 属性。开发者应该在 TypeChecker 实例上使用 getTypeArguments 函数来代替。

TypeScript 3.6

更严格的生成器

TypeScript 3.6 对迭代器和生成器函数引入了更严格的检查。在之前的版本中,用户无法区分一个值是生成的还是被返回的。

function* foo() {
  if (Math.random() < 0.5) yield 100;
  return 'Finished!';
}

let iter = foo();
let curr = iter.next();
if (curr.done) {
  // TypeScript 3.5 以及之前的版本会认为 `value` 为 'string | number'。
  // 当 `done` 为 `true` 的时候,它应该知道 `value` 为 'string'!
  curr.value;
}

另外,生成器只假定 yield 的类型为 any

function* bar() {
  let x: { hello(): void } = yield;
  x.hello();
}

let iter = bar();
iter.next();
iter.next(123); // 不好! 运行时错误!

在 TypeScript 3.6 中,在我们第一个例子中检查器现在知道 curr.value 的正确类型应该是 string ,并且,在最后一个例子中当我们调用 next() 时会准确的提示错误。这要感谢在 IteratorIteratorResule 的类型定义包含了一些新的类型参数,并且一个被叫做 Generator 的新类型在 TypeScript 中用来表示生成器。

类型 Iterator 现在允许用户明确的定义生成的类型,返回的类型和 next 能够接收的类型。

interface Iterator<T, TReturn = any, TNext = undefined> {
  // 接受 0 或者 1 个参数 - 不接受 'undefined'
  next(...args: [] | [TNext]): IteratorResult<T, TReturn>;
  return?(value?: TReturn): IteratorResult<T, TReturn>;
  throw?(e?: any): IteratorResult<T, TReturn>;
}

以此为基础,新的 Generator 类型是一个迭代器,它总是有 returnthrow 方法,并且也是可迭代的。

interface Generator<T = unknown, TReturn = any, TNext = unknown>
  extends Iterator<T, TReturn, TNext> {
  next(...args: [] | [TNext]): IteratorResult<T, TReturn>;
  return(value: TReturn): IteratorResult<T, TReturn>;
  throw(e: any): IteratorResult<T, TReturn>;
  [Symbol.iterator](): Generator<T, TReturn, TNext>;
}

为了允许在返回值和生成值之间进行区分,TypeScript 3.6 转变 IteratorResult 类型为一个区别对待的联合类型:

type IteratorResult<T, TReturn = any> =
  | IteratorYieldResult<T>
  | IteratorReturnResult<TReturn>;

interface IteratorYieldResult<TYield> {
  done?: false;
  value: TYield;
}

interface IteratorReturnResult<TReturn> {
  done: true;
  value: TReturn;
}

简而言之,这意味当直接处理迭代器时,你将有能力细化值的类型。

为了正确的表示在调用生成器的 next() 方法的时候能被传入的类型,TypeScript 3.6 还可以在生成器函数内推断出 yield 的某些用法。

function* foo() {
  let x: string = yield;
  console.log(x.toUpperCase());
}

let x = foo();
x.next(); // 第一次调用 `next` 总是被忽略
x.next(42); // 错啦!'number' 和 'string' 不匹配

如果你更喜欢显示的,你还可以使用显示的返回类型强制申明从生成表达式返回的、生成的和计算的的值的类型。下面,next() 只能被 booleans 值调用,并且根据 done 的值,value 可以是 string 或者 number

/**
 * - yields numbers
 * - returns strings
 * - can be passed in booleans
 */
function* counter(): Generator<number, string, boolean> {
  let i = 0;
  while (true) {
    if (yield i++) {
      break;
    }
  }
  return 'done!';
}

var iter = counter();
var curr = iter.next();
while (!curr.done) {
  console.log(curr.value);
  curr = iter.next(curr.value === 5);
}
console.log(curr.value.toUpperCase());

// prints:
//
// 0
// 1
// 2
// 3
// 4
// 5
// DONE!

有关更多详细的改变,查看 pull request

更准确的数组展开

在 ES2015 之前的目标中,对于像循环和数组展开之类的结构最忠实的生成可能有点繁重。因此,TypeScript 默认使用更简单的生成,它只支持数组类型,并支持使用 --downlevelIteration 标志迭代其它类型。在此标志下,发出的代码更准确,但更大。

默认情况下 --downlevelIteration 默认关闭效果很好,因为大多数以 ES5 为目标的用户只计划使用带数组的迭代结构。但是,我们支持数组的生成在某些边缘情况下仍然存在一些可观察到的差异。

例如,以下示例:

[...Array(5)];

相当于以下数组:

[undefined, undefined, undefined, undefined, undefined];

但是,TypeScript 会将原始代码转换为此代码:

Array(5).slice();

这略有不同。 Array(5) 生成一个长度为 5 的数组,但并没有在其中插入任何元素!

1 in [undefined, undefined, undefined]; // true
1 in Array(3); // false

当 TypeScript 调用 slice() 时,它还会创建一个索引尚未设置的数组。

这可能看起来有点深奥,但事实证明许多用户遇到了这种令人不快的行为。 TypeScript 3.6 不是使用 slice() 和内置函数,而是引入了一个新的 __spreadArrays 辅助程序,以准确地模拟 ECMAScript 2015 中在 --downlevelIteration 之外的旧目标中发生的事情。 __spreadArrays 也可以在 tslib 中使用(如果你正在寻找更小的包,那么值得一试)。

有关更多信息,请参阅相关的 pull request

改进了 Promises 的 UX

Promise 是当今使用异步数据的常用方法之一。不幸的是,使用面向 Promise 的 API 通常会让用户感到困惑。 TypeScript 3.6 引入了一些改进,以防止错误的处理 Promise

例如,在将它传递给另一个函数之前忘记 .then() 或等待 Promise 的完成通常是很常见的。TypeScript 的错误消息现在是专门的,并告知用户他们可能应该考虑使用 await 关键字。

interface User {
  name: string;
  age: number;
  location: string;
}

declare function getUserData(): Promise<User>;
declare function displayUser(user: User): void;

async function f() {
  displayUser(getUserData());
  //            ~~~~~~~~~~~~~
  // 'Promise <User>' 类型的参数不能分配给 'User' 类型的参数。
  //   ...
  // 你忘记使用 'await' 吗?
}

在等待或 .then() - Promise 之前尝试访问方法也很常见。这是另一个例子,在许多其他方面,我们能够做得更好。

async function getCuteAnimals() {
  fetch('https://reddit.com/r/aww.json').json();
  // ~~~~
  // 'Promise <Response>'类型中不存在属性'json'。
  // 你忘记使用'await'吗?
}

目的是即使用户不知道需要等待,至少,这些消息提供了更多关于从何处开始的上下文。

与可发现性相同,让您的生活更轻松 - 除了 Promises 上更好的错误消息之外,我们现在还在某些情况下提供快速修复。

正在应用快速修复以添加缺少的 await 关键字。

有关更多详细信息,请参阅原始问题以及链接回来的 pull request

标识符更好的支持 Unicode

当发射到 ES2015 及更高版本的目标时,TypeScript 3.6 在标识符中包含对 Unicode 字符的更好支持。

const 𝓱𝓮𝓵𝓵𝓸 = 'world'; // previously disallowed, now allowed in '--target es2015'
// 以前不允许,现在在 '--target es2015' 中允许

支持在 SystemJS 中使用 import.meta

当模块目标设置为 system 时,TypeScript 3.6 支持将 import.meta 转换为 context.meta

// 此模块:
console.log(import.meta.url);

// 获得如下的转变:
System.register([], function (exports, context) {
  return {
    setters: [],
    execute: function () {
      console.log(context.meta.url);
    },
  };
});

在环境上下文中允许 getset 访问者

在以前的 TypeScript 版本中,该语言不允许在环境上下文中使用 getset 访问器(例如,在 declare-d 类中,或者在 .d.ts 文件中)。理由是,就这些属性的写作和阅读而言,访问者与属性没有区别,但是,因为 ECMAScript 的类字段提议可能与现有版本的 TypeScript 具有不同的行为,我们意识到我们需要一种方法来传达这种不同的行为,以便在子类中提供适当的错误。

因此,用户可以在 TypeScript 3.6 中的环境上下文中编写 gettersetter

declare class Foo {
  // 3.6+ 允许
  get x(): number;
  set x(val: number): void;
}

在 TypeScript 3.7 中,编译器本身将利用此功能,以便生成的 .d.ts 文件也将生成 get / set 访问器。

环境类和函数可以合并

在以前版本的 TypeScript 中,在任何情况下合并类和函数都是错误的。现在,环境类和函数(具有 declare 修饰符的类/函数或 .d.ts 文件中)可以合并。这意味着现在您可以编写以下内容:

export declare function Point2D(x: number, y: number): Point2D;
export declare class Point2D {
  x: number;
  y: number;
  constructor(x: number, y: number);
}

而不需要使用

export interface Point2D {
  x: number;
  y: number;
}
export declare var Point2D: {
  (x: number, y: number): Point2D;
  new (x: number, y: number): Point2D;
};

这样做的一个优点是可以很容易地表达可调用的构造函数模式,同时还允许名称空间与这些声明合并(因为 var 声明不能与名称空间合并)。

在 TypeScript 3.7 中,编译器将利用此功能,以便从 .js 文件生成的 .d.ts 文件可以适当地捕获类类函数的可调用性和可构造性。

有关更多详细信息,请参阅 GitHub 上的原始 PR

APIs 支持 --build--incremental

TypeScript 3.0 引入了对引用其他项目的支持,并使用 --build 标志以增量方式构建它们。此外,TypeScript 3.4 引入了 --incremental 标志,用于保存有关以前编译的信息,仅重建某些文件。这些标志对于更灵活地构建项目和加速构建非常有用。不幸的是,使用这些标志不适用于 Gulp 和 Webpack 等第三方构建工具。TypeScript 3.6 现在公开了两组 API 来操作项目引用和增量构建。

对于创建 --incremental 构建,用户可以利用 createIncrementalProgramcreateIncrementalCompilerHost API。用户还可以使用新公开的 readBuilderProgram 函数从此 API 生成的 .tsbuildinfo 文件中重新保存旧程序实例,该函数仅用于创建新程序(即,您无法修改返回的实例 - 它意味着用于其他 create * Program 函数中的 oldProgram 参数)。

为了利用项目引用,公开了一个新的 createSolutionBuilder 函数,它返回一个新类型 SolutionBuilder 的实例。

有关这些 API 的更多详细信息,您可以查看原始 pull request

新的 TypeScript Playground

TypeScript Playground 已经获得了急需的刷新功能,并提供了便利的新功能!Playground 主要是 Artem TyurinTypeScript Playground 的一个分支,社区成员越来越多地使用它。我们非常感谢 Artem 在这里提供帮助!

新的 Playground 现在支持许多新的选项,包括:

  • target 选项(允许用户切换输出 es5es3es2015esnext 等)
  • 所有的严格检查标记(包括 just strict
  • 支持纯 JavaScript 文件(使用 allowJs 和可选的 checkJs

当分享 Playground 的链接时,这些选项也会保存下来,允许用户更可靠地分享示例,而无需告诉受众“哦,别忘了打开 noImplicitAny 选项!”。

在不久的将来,我们将更新 Playground 样本,添加 JSX 支持和改进自动类型获取,这意味着您将能够在 Playground 上体验到与编辑器中相同的体验。

随着我们改进 Playground 和网站,我们欢迎 GitHub 上的issue 和 pull request

代码编辑的分号感知

对于 Visual Studio 和 Visual Studio Code 编辑器可以自动的应用快速修复、重构和自动从其它模块导入值等其它的转换。这些转换都由 TypeScript 来驱动,老版本的 TypeScript 无条件的在语句的末尾添加分号,不幸的是,这和大多数用户的代码风格不相符,并且,很多用户对于编辑器自动输入分号很不爽。

TypeScript 现在在应用这些简短的编辑的时候,已经足够的智能去检测你的文件分号的使用情况。如果你的文件通常缺少分号,TypeScript 就不会添加分号。

更多细节,查看这些 pull request

更智能的自动导入

JavaScript 有大量不同的模块语法或者约定:EMACScript standard、CommonJS、AMD、System.js 等等。在大多数的情况下,TypeScript 默认使用 ECMAScript standard 语法自动导入,这在具有不同编译器设置的某些 TypeScript 项目中通常是不合适的,或者在使用纯 JavaScript 和需要调用的 Node 项目中。

在决定如何自动导入模块之前,TypeScript 3.6 现在会更加智能的查看你的现有导入。你可以通过这些 pull request查看更多细节。

接下来?

要了解团队将要开展的工作,请查看今年 7 月至 12 月的 6 个月路线图

与往常一样,我们希望这个版本的 TypeScript 能让编码体验更好,让您更快乐。如果您有任何建议或遇到任何问题,我们总是感兴趣,所以随时在 GitHub 上提一个 issue

参考

TypeScript 3.5

改进速度

TypeScript 3.5 为类型检查和增量构建采用了几个优化。

类型检查速度提升

TypeScript 3.5 包含对 TypeScript 3.4 的某些优化,可以更高效地进行类型检查。 在代码补全列表等类型检查驱动的操作上,这些改进效果显著。

改进 --incremental

TypeScript 3.5 通过缓存计算状态的信息(编译器设置、寻找文件的原因、文件在哪里被找到等等),改进了在 3.4 中的 --incremental 构建模式。我们发现重新构建花费的时间比 TypeScript 3.4 减少了 68%!

有关更多信息,你可以查看这些 pull requests

Omit 辅助类型

TypeScript 3.5 添加了新的 Omit 辅助类型,这个类型用来创建从原始类型中移除了某些属性的新类型。

type Person = {
  name: string;
  age: number;
  location: string;
};

type QuantumPerson = Omit<Person, 'location'>;

// 相当于
type QuantumPerson = {
  name: string;
  age: number;
};

使用 Omit 辅助,我们有能力复制 Person 中除了 location 之外的所有属性。

有关更多细节,在 GitHub 查看添加 Omit 的 pull request, 以及有关剩余对象使用 Omit 的更改

改进了联合类型中多余属性的检查

在 TypeScript 3.4 及之前的版本中,会出现确实不应该存在的多余属性却被允许存在的情况。 例如,TypeScript 3.4 在对象字面量上允许不正确的 name 属性,甚至它的类型在 PointLabel 之中都不匹配。

type Point = {
  x: number;
  y: number;
};

type Label = {
  name: string;
};

const thing: Point | Label = {
  x: 0,
  y: 0,
  name: true, // uh-oh!
};

以前,一个无区别的联合在它的成员上不会进行任何多余属性的检查,结果,类型错误的 name 属性溜了进来。

在 TypeScript 3.5 中,类型检查器至少会验证所有提供的属性属于某个联合类型的成员,且类型恰当,这意味着,上面的例子会正确的进行错误提示。

注意,只要属性类型有效,仍允许部分重叠。

const pl: Point | Label = {
  x: 0,
  y: 0,
  name: 'origin', // okay
};

--allowUmdGlobalAccess 标志

在 TypeScript 3.5 中,使用新的 --allowUmdGlobalAccess 标志,你现在可以从任何位置引用全局的 UMD 申明——甚至模块。

export as namespace foo;

此模式增加了混合和匹配第三方库的灵活性,其中库声明的全局变量总是可以被使用,甚至可以从模块内部使用。

有关更多细节,查看 GitHub 上的 pull request

更智能的联合类型检查

在 TypeScript 3.4 以及之前的版本中,下面的例子会无效:

type S = { done: boolean; value: number };
type T = { done: false; value: number } | { done: true; value: number };

declare let source: S;
declare let target: T;

target = source;

这是因为 S 无法被分配给 { done: false, value: number } 或者 { done: true, value: number }。 为啥? 因为属性 doneS 不够具体——他是 boolean。而 T 的的每个成员有一个明确的为 true 或者 false 属性 done

这就是我们单独检查每个成员的意义:TypeScript 不只是将每个属性合并在一起,看看是否可以赋予 S

如果这样做,一些糟糕的代码可能会像下面这样:

interface Foo {
  kind: 'foo';
  value: string;
}

interface Bar {
  kind: 'bar';
  value: number;
}

function doSomething(x: Foo | Bar) {
  if (x.kind === 'foo') {
    x.value.toLowerCase();
  }
}

// uh-oh - 幸运的是, TypeScript 在这里会提示错误!
doSomething({
  kind: 'foo',
  value: 123,
});

然而,对于原始的例子,这有点过于严格。 如果你弄清除 S 的任何可能值的精确类型,你实际上可以看到它与 T 中的类型完全匹配。

在 TypeScript 3.5 中,当分配具有辨别属性的类型时,如 T,实际上进一步将类似 S 的类型分解为每个可能的成员类型的并集。 在这种情况下,由于 booleantruefalse 的联合,S 将被视为 {done:false,value:number}{done:true,value:number }

有关更多细节,你可以在 GitHub 上查看原始的 pull request

泛型构造函数的高阶类型推断

在 TypeScript 3.4 中,我们改进了对返回函数的泛型函数的推断:

function compose<T, U, V>(f: (x: T) => U, g: (y: U) => V): (x: T) => V {
  return x => g(f(x));
}

将其他泛型函数作为参数,如下所示:

function arrayify<T>(x: T): T[] {
  return [x];
}

type Box<U> = { value: U };
function boxify<U>(y: U): Box<U> {
  return { value: y };
}

let newFn = compose(arrayify, boxify);

TypeScript 3.4 的推断允许 newFn 是泛型的。它的新类型是 <T>(x:T)=> Box <T []>。而不是旧版本推断的,相对无用的类型,如 (x:{})=> Box <{} []>

TypeScript 3.5 在处理构造函数的时候推广了这种行为。

class Box<T> {
  kind: 'box';
  value: T;
  constructor(value: T) {
    this.value = value;
  }
}

class Bag<U> {
  kind: 'bag';
  value: U;
  constructor(value: U) {
    this.value = value;
  }
}

function composeCtor<T, U, V>(
  F: new (x: T) => U,
  G: new (y: U) => V
): (x: T) => V {
  return x => new G(new F(x));
}

let f = composeCtor(Box, Bag); // 拥有类型 '<T>(x: T) => Bag<Box<T>>'
let a = f(1024); // 拥有类型 'Bag<Box<number>>'

除了上面的组合模式之外,这种对泛型构造函数的新推断意味着在某些 UI 库(如 React )中对类组件进行操作的函数可以更正确地对泛型类组件进行操作。

type ComponentClass<P> = new (props: P) => Component<P>;
declare class Component<P> {
  props: P;
  constructor(props: P);
}

declare function myHoc<P>(C: ComponentClass<P>): ComponentClass<P>;

type NestedProps<T> = { foo: number; stuff: T };

declare class GenericComponent<T> extends Component<NestedProps<T>> {}

// 类型为 'new <T>(props: NestedProps<T>) => Component<NestedProps<T>>'
const GenericComponent2 = myHoc(GenericComponent);

想学习更多,在 GitHub 上查看原始的 pull requet

参考

TypeScript 3.4

使用 --incremental 标志加快后续构建

TypeScript 3.4 引入了一个名为 --incremental 的新标志,它告诉 TypeScript 从上一次编译中保存有关项目图的信息。

下次使用 --incremental 调用 TypeScript 时,它将使用该信息来检测类型检查和生成对项目更改成本最低的方法。

// tsconfig.json
{
  "compilerOptions": {
    "incremental": true,
    "outDir": "./lib"
  },
  "include": ["./src"]
}

默认使用这些设置,当我们运行 tsc 时,TypeScript 将在输出目录(./lib)中查找名为 .tsbuildinfo 的文件。 如果 ./lib/.tsbuildinfo 不存在,它将被生成。 但如果存在,tsc 将尝试使用该文件逐步进行类型检查并更新输出文件。

这些 .tsbuildinfo 文件可以安全地删除,并且在运行时对我们的代码没有任何影响——它们纯粹用于更快地编译。 我们也可以将它们命名为我们想要的任何名字,并使用 --tsBuildInfoFile 标志将它们放在我们想要的任何位置。

// front-end.tsconfig.json
{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": "./buildcache/front-end",
    "outDir": "./lib"
  },
  "include": ["./src"]
}

复合项目

复合项目的意图的一部分(tsconfig.jsons,composite 设置为 true)是不同项目之间的引用可以增量构建。 因此,复合项目将始终生成 .tsbuildinfo 文件。

outFile

当使用 outFile 时,构建信息文件的名称将基于输出文件的名称。 例如,如果我们的输出 JavaScript 文件是 ./ output / foo.js,那么在 --incremental 标志下,TypeScript 将生成文件./output/foo.tsbuildinfo。 如上所述,这可以通过 --tsBuildInfoFile 标志来控制。

泛型函数的高阶类型推断

当来自其它泛型函数的推断产生用于推断的自由类型变量时,TypeScript 3.4 现在可以生成泛型函数类型。

这意味着在 3.4 中许多函数组合模式现在运行的更好了。

为了更具体,让我们建立一些动机并考虑以下 compose 函数:

function compose<A, B, C>(f: (arg: A) => B, g: (arg: B) => C): (arg: A) => C {
  return x => g(f(x));
}

compose 还有两个其他函数:

  • f 它接受一些参数(类型为 A)并返回类型为 B 的值
  • g 采用类型为 B 的参数(类型为 f 返回),并返回类型为 C 的值

compose 然后返回一个函数,它通过 f 然后 g 来提供它的参数。

调用此函数时,TypeScript 将尝试通过一个名为 type argument inference 的进程来计算出 ABC 的类型。 这个推断过程通常很有效:

interface Person {
  name: string;
  age: number;
}

function getDisplayName(p: Person) {
  return p.name.toLowerCase();
}

function getLength(s: string) {
  return s.length;
}

// 拥有类型 '(p: Person) => number'
const getDisplayNameLength = compose(getDisplayName, getLength);

// 有效并返回 `number` 类型
getDisplayNameLength({ name: 'Person McPersonface', age: 42 });

推断过程在这里相当简单,因为 getDisplayNamegetLength 使用的是可以轻松引用的类型。 但是,在 TypeScript 3.3 及更早版本中,泛型函数如 compose 在传递其他泛型函数时效果不佳。

interface Box<T> {
  value: T;
}

function makeArray<T>(x: T): T[] {
  return [x];
}

function makeBox<U>(value: U): Box<U> {
  return { value };
}

// 类型为 '(arg: {}) => Box<{}[]>'
const makeBoxedArray = compose(makeArray, makeBox);

makeBoxedArray('hello!').value[0].toUpperCase();
//                                ~~~~~~~~~~~
// 错误:类型 '{}' 没有 'toUpperCase' 属性

在旧版本中,当从其他类型变量(如 TU)推断时,TypeScript 会推断出空对象类型({})。

在 TypeScript 3.4 中的类型参数推断时,对于返回函数的泛型函数的调用,TypeScript (视情况而定)把类型参数从泛型函数参数传递到生成的函数类型中。

换句话说,而不是生成类型

(arg: {}) => Box<{}[]>;

TypeScript 3.4 生成的类型

<T>(arg: T) => Box<T[]>;

注意,T 已从 makeArray 传递到结果类型的类型参数列表中。 这意味着来自 compose 参数的泛型已被保留,我们的 makeBoxedArray 示例将正常运行!

interface Box<T> {
  value: T;
}

function makeArray<T>(x: T): T[] {
  return [x];
}

function makeBox<U>(value: U): Box<U> {
  return { value };
}

// 类型为 '<T>(arg: T) => Box<T[]>'
const makeBoxedArray = compose(makeArray, makeBox);

// 正常运行!
makeBoxedArray('hello!').value[0].toUpperCase();

更多细节,你可以读到更多从这些原始的变动

改进 ReadonlyArrayreadonly 元祖

TypeScript 3.4 让使用只读的类似数组的类型更简单了。

一个与 ReadonlyArray 相关的新语法

ReadonlyArray 类型描述 Array 是只读的。

任何带有 ReadonlyArray 引用的变量不能被添加、移除或者替换数组中的任何元素。

function foo(arr: ReadonlyArray<string>) {
  arr.slice(); // okay
  arr.push('hello!'); // error!
}

当期待数组不可变时使用 ReadonlyArray 替代 Array 是好实践,考虑到数组有一个更棒的语法的情况下这通常有一点痛苦。 尤其是,number[] 是一个省略版的 Array<number>,就像 Date[] 是省略版的 Array<Date>

TypeScript 3.4 为 ReadonlyArray 引入了一个新的语法,就是在数组类型上使用了新的 readonly 修饰语。

function foo(arr: readonly string[]) {
  arr.slice(); // okay
  arr.push('hello!'); // 错误!
}

readonly 元祖

TypeScript 3.4 同样引入了对 readonly 元祖的支持。 我们可以在任何元祖类型上加上前置 readonly 关键字用来表示它是 readonly 元祖,非常像我们现在可以对数组使用的省略版语法。 就像你可能期待的,不像插槽可写的普通元祖,readonly 元祖只允许从那些位置读。

function foo(pair: readonly [string, string]) {
  console.log(pair[0]); // okay
  pair[1] = 'hello!'; // 错误
}

普通的元祖是用相同的方式从 Array 继承的——一个元祖T1, T2, ... Tn 继承自 Array< T1 | T2 | ... Tn > - readonly 元祖是继承自类型 ReadonlyArray。所以,一个 readonly 元祖 T1, T2, ... Tn 继承自 ReadonlyArray< T1 | T2 | ... Tn >

映射类型修饰语 readonlyreadonly 数组

在之前的 TypeScript 版本中,我们一般使用映射类型操作不同的类似数组的结构。

这意味着,一个映射类型像 Boxify 可以在数组上生效,元祖也是。

interface Box<T> {
  value: T;
}

type Boxify<T> = {};

// { a: Box<string>, b: Box<number> }
type A = Boxify<{ a: string; b: number }>;

// Array<Box<number>>
type B = Boxify<number[]>;

// [Box<string>, Box<number>]
type C = Boxify<[string, boolean]>;

不幸的是,映射类型像 Readonly 实用类型在数组和元祖类型上实际上是无用的。

// lib.d.ts
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

// 在 TypeScript 3.4 之前代码会如何执行

// { readonly a: string, readonly b: number }
type A = Readonly<{ a: string; b: number }>;

// number[]
type B = Readonly<number[]>;

// [string, boolean]
type C = Readonly<[string, boolean]>;

在 TypeScript 3.4,在映射类型中的 readonly 修饰符将自动的转换类似数组结构到他们相符合的 readonly 副本。

// 在 TypeScript 3.4 中代码会如何运行

// { readonly a: string, readonly b: number }
type A = Readonly<{ a: string; b: number }>;

// readonly number[]
type B = Readonly<number[]>;

// readonly [string, boolean]
type C = Readonly<[string, boolean]>;

类似地,你可以编写一个类似 Writable 映射类型的实用程序类型来移除 readonly-ness,并将 readonly 数组容器转换回它们的可变等价物。

type Writable<T> = {
  -readonly [K in keyof T]: T[K];
};

// { a: string, b: number }
type A = Writable<{
  readonly a: string;
  readonly b: number;
}>;

// number[]
type B = Writable<readonly number[]>;

// [string, boolean]
type C = Writable<readonly [string, boolean]>;

注意事项

它不是一个通用型操作,尽管它看起来像。 readonly 类型修饰符只能用于数组类型和元组类型的语法。

let err1: readonly Set<number>; // 错误!
let err2: readonly Array<boolean>; // 错误!

let okay: readonly boolean[]; // 有效

你可以查看 pull request 了解更多详情

const 断言

TypeScript 3.4 引入了一个叫 const 断言的字面量值的新构造。 它的语法是用 const 代替类型名称的类型断言(例如 123 as const)。 当我们用 const 断言构造新的字面量表达式时,我们可以用来表示:

  • 该表达式中的字面量类型不应粗化(例如,不要从 'hello'string
  • 对象字面量获得 readonly 属性
  • 数组字面量成为 readonly 元组
// Type '"hello"'
let x = 'hello' as const;

// Type 'readonly [10, 20]'
let y = [10, 20] as const;

// Type '{ readonly text: "hello" }'
let z = { text: 'hello' } as const;

也可以使用尖括号断言语法,除了 .tsx 文件之外。

// Type '"hello"'
let x = <const>'hello';

// Type 'readonly [10, 20]'
let y = <const>[10, 20];

// Type '{ readonly text: "hello" }'
let z = <const>{ text: 'hello' };

此功能意味着通常可以省略掉仅用于将不可变性示意给编译器的类型。

// 不使用引用或声明的类型。
// 我们只需要一个 const 断言。
function getShapes() {
  let result = [
    { kind: 'circle', radius: 100 },
    { kind: 'square', sideLength: 50 },
  ] as const;

  return result;
}

for (const shape of getShapes()) {
  // 完美细化
  if (shape.kind === 'circle') {
    console.log('Circle radius', shape.radius);
  } else {
    console.log('Square side length', shape.sideLength);
  }
}

请注意,上面的例子不需要类型注释。 const 断言允许 TypeScript 采用最具体的类型表达式。

如果你选择不使用 TypeScript 的 enum 结构,这甚至可以用于在纯 JavaScript 代码中使用类似 enum 的模式。

export const Colors = {
  red: 'RED',
  blue: 'BLUE',
  green: 'GREEN',
} as const;

// 或者使用 'export default'

export default {
  red: 'RED',
  blue: 'BLUE',
  green: 'GREEN',
} as const;

注意事项

需要注意的是,const 断言只能直接应用于简单的字面量表达式上。

// 错误!'const' 断言只能用在 string, number, boolean, array, object literal。
let a = (Math.random() < 0.5 ? 0 : 1) as const;

// 有效!
let b = Math.random() < 0.5 ? (0 as const) : (1 as const);

另一件得记住的事是 const 上下文不会直接将表达式转换为完全不可变的。

let arr = [1, 2, 3, 4];

let foo = {
  name: 'foo',
  contents: arr,
} as const;

foo.name = 'bar'; // 错误!
foo.contents = []; // 错误!

foo.contents.push(5); // ...有效!

更多详情,你可以查看相应的 pull request

globalThis 的类型检查

TypeScript 3.4 引入了对 ECMAScript 新 globalThis 全局变量的类型检查的支持,它指向的是全局作用域。 与上述解决方案不同,globalThis 提供了一种访问全局作用域的标准方法,可以在不同环境中使用。

// 在一个全局文件里:

var abc = 100;

// 指向上面的 `abc`
globalThis.abc = 200;

注意,使用 letconst 声明的全局变量不会显示在 globalThis 上。

let answer = 42;

// 错误!'typeof globalThis' 没有 'answer' 属性。
globalThis.answer = 333333;

同样重要的是要注意,在编译为老版本的 ECMAScript 时,TypeScript 不会转换引用到 globalThis 上。 因此,除非您的目标是常青浏览器(已经支持 globalThis),否则您可能需要使用 polyfill

更多详细信息,请参阅该功能的 pull request

参考

TypeScript 3.3

改进调用联合类型时的行为

在 TypeScript 之前的版本中,将可调用类型联合后仅在它们具有相同的参数列表时才能被调用。

type Fruit = 'apple' | 'orange';
type Color = 'red' | 'orange';

type FruitEater = (fruit: Fruit) => number; // eats and ranks the fruit
type ColorConsumer = (color: Color) => string; // consumes and describes the colors

declare let f: FruitEater | ColorConsumer;

// Cannot invoke an expression whose type lacks a call signature.
//   Type 'FruitEater | ColorConsumer' has no compatible call signatures.ts(2349)
f('orange');

然而,上例中,FruitEaterColorConsumer应该都可以使用"orange",并返回numberstring

在 TypeScript 3.3 里,这个错误不存在了。

type Fruit = 'apple' | 'orange';
type Color = 'red' | 'orange';

type FruitEater = (fruit: Fruit) => number; // eats and ranks the fruit
type ColorConsumer = (color: Color) => string; // consumes and describes the colors

declare let f: FruitEater | ColorConsumer;

f('orange'); // It works! Returns a 'number | string'.

f('apple'); // error - Argument of type '"apple"' is not assignable to parameter of type '"orange"'.

f('red'); // error - Argument of type '"red"' is not assignable to parameter of type '"orange"'.

TypeScript 3.3,这些签名的参数被连结在一起构成了一个新的签名。

在上例中,fruitcolor连结在一起形成新的参数类型Fruit & ColorFruit & Color("apple" | "orange") & ("red" | "orange")是一样的,都相当于("apple" & "red") | ("apple" & "orange") | ("orange" & "red") | ("orange" & "orange")。 那些不可能交叉的会规约成never类型,只剩下"orange" & "orange",就是"orange"

警告

这个新行为仅在满足如下情形时生效:

  • 联合类型中最多有一个类型具有多个重载,
  • 联合类型中最多有一个类型有泛型签名。

这意味着,像map这种操作number[] | string[]的方法,还是不能调用,因为map是泛型函数。

另一方面,像forEach就可以调用,因为它不是泛型函数,但在noImplicitAny模式可能有些问题。

interface Dog {
  kind: 'dog';
  dogProp: any;
}
interface Cat {
  kind: 'cat';
  catProp: any;
}

const catOrDogArray: Dog[] | Cat[] = [];

catOrDogArray.forEach(animal => {
  //                ~~~~~~ error!
  // Parameter 'animal' implicitly has an 'any' type.
});

添加显式的类型信息可以解决。

interface Dog {
  kind: 'dog';
  dogProp: any;
}
interface Cat {
  kind: 'cat';
  catProp: any;
}

const catOrDogArray: Dog[] | Cat[] = [];
catOrDogArray.forEach((animal: Dog | Cat) => {
  if (animal.kind === 'dog') {
    animal.dogProp;
    // ...
  } else if (animal.kind === 'cat') {
    animal.catProp;
    // ...
  }
});

在合复合工程中增量地检测文件的变化 --build --watch

TypeScript 3.0 引入了一个新特性来按结构进行构建,称做“复合工程”。 目的是让用户能够把大型工程拆分成小的部分从而快速构建并保留项目结构。 正是因为支持了复合工程,TypeScript 可以使用--build模式仅重新编译部分工程和依赖。 可以把它当做工作内部构建的一种优化。

TypeScript 2.7 还引入了--watch构建模式,它使用了新的增量"builder"API。 背后的想法都是仅重新检查和生成改动过的文件或者是依赖项可能影响类型检查的文件。 可以把它们当成工程内部构建的优化。

在 3.3 之前,使用--build --watch构建复合工程不会真正地使用增量文件检测机制。 在--build --watch模式下,一个工程里的一处改动会导致整个工程重新构建,而非仅检查那些真正受到影响的文件。

在 TypeScript 3.3 里,--build模式的--watch标记也会使用增量文件检测。 因此--build --watch模式下构建非常快。 我们的测试结果显示,这个功能会减少 50%到 75%的构建时间,相比于原先的--build --watch。 具体数字在这这个pull request里,我们相信大多数复合工程用户会看到明显效果。

TypeScript 3.2

strictBindCallApply

TypeScript 3.2 引入了一个新的--strictBindCallApply编译选项(是--strict选项家族之一)。在使用了此选项后,函数对象上的bindcallapply方法将应用强类型并进行严格的类型检查。

function foo(a: number, b: string): string {
  return a + b;
}

let a = foo.apply(undefined, [10]); // error: too few argumnts
let b = foo.apply(undefined, [10, 20]); // error: 2nd argument is a number
let c = foo.apply(undefined, [10, 'hello', 30]); // error: too many arguments
let d = foo.apply(undefined, [10, 'hello']); // okay! returns a string

它的实现是通过引入了两种新类型来完成的,即lib.d.ts里的CallableFunctionNewableFunction。这些类型包含了针对常规函数和构造函数上bindcallapply的泛型方法声明。这些声明使用了泛型剩余参数来捕获和反射参数列表,使之具有强类型。在--strictBindCallApply模式下,这些声明作用在Function类型声明出现的位置。

警告

由于更严格的检查可能暴露之前没发现的错误,因此这是--strict模式下的一个破坏性改动。

此外,这个新功能还有另一个警告。由于有这些限制,bindcallapply无法为重载的泛型函数或重载的函数进行完整地建模。 当在泛型函数上使用这些方法时,类型参数会被替换为空对象类型({}),并且若在有重载的函数上使用这些方法时,只有最后一个重载会被建模。

对象字面量的泛型展开表达式

TypeScript 3.2 开始,对象字面量允许泛型展开表达式,它产生交叉类型,和Object.assign函数或 JSX 字面量类似。例如:

function taggedObject<T, U extends string>(obj: T, tag: U) {
  return { ...obj, tag }; // T & { tag: U }
}

let x = taggedObject({ x: 10, y: 20 }, 'point'); // { x: number, y: number } & { tag: "point" }

属性赋值和非泛型展开表达式会最大程度地合并到泛型展开表达式的一侧。例如:

function foo1<T>(t: T, obj1: { a: string }, obj2: { b: string }) {
  return { ...obj1, x: 1, ...t, ...obj2, y: 2 }; // { a: string, x: number } & T & { b: string, y: number }
}

非泛型展开表达式与之前的行为相同:函数调用签名和构造签名被移除,仅有非方法的属性被保留,针对同名属性则只有出现在最右侧的会被使用。它与交叉类型不同,交叉类型会连接调用签名和构造签名,保留所有的属性,合并同名属性的类型。因此,当展开使用泛型初始化的相同类型时可能会产生不同的结果:

function spread<T, U>(t: T, u: U) {
  return { ...t, ...u }; // T & U
}

declare let x: { a: string; b: number };
declare let y: { b: string; c: boolean };

let s1 = { ...x, ...y }; // { a: string, b: string, c: boolean }
let s2 = spread(x, y); // { a: string, b: number } & { b: string, c: boolean }
let b1 = s1.b; // string
let b2 = s2.b; // number & string

泛型对象剩余变量和参数

TypeScript 3.2 开始允许从泛型变量中解构剩余绑定。它是通过使用lib.d.ts里预定义的PickExclude助手类型,并结合使用泛型类型和解构式里的其它绑定名实现的。

function excludeTag<T extends { tag: string }>(obj: T) {
  let { tag, ...rest } = obj;
  return rest; // Pick<T, Exclude<keyof T, "tag">>
}

const taggedPoint = { x: 10, y: 20, tag: 'point' };
const point = excludeTag(taggedPoint); // { x: number, y: number }

BigInt

BigInt 里 ECMAScript 的一项提案,它在理论上允许我们建模任意大小的整数。 TypeScript 3.2 可以为 BigInit 进行类型检查,并支持在目标为esnext时输出 BigInit 字面量。

为支持 BigInt,TypeScript 引入了一个新的原始类型bigint(全小写)。 可以通过调用BigInt()函数或书写 BigInt 字面量(在整型数字字面量末尾添加n)来获取bigint

let foo: bigint = BigInt(100); // the BigInt function
let bar: bigint = 100n; // a BigInt literal

// *Slaps roof of fibonacci function*
// This bad boy returns ints that can get *so* big!
function fibonacci(n: bigint) {
  let result = 1n;
  for (let last = 0n, i = 0n; i < n; i++) {
    const current = result;
    result += last;
    last = current;
  }
  return result;
}

fibonacci(10000n);

尽管你可能会认为numberbigint能互换使用,但它们是不同的东西。

declare let foo: number;
declare let bar: bigint;

foo = bar; // error: Type 'bigint' is not assignable to type 'number'.
bar = foo; // error: Type 'number' is not assignable to type 'bigint'.

ECMAScript 里规定,在算术运算符里混合使用numberbigint是一个错误。 应该显式地将值转换为BigInt

console.log(3.141592 * 10000n); // error
console.log(3145 * 10n); // error
console.log(BigInt(3145) * 10n); // okay!

还有一点要注意的是,对bigint使用typeof操作符返回一个新的字符串:"bigint"。 因此,TypeScript 能够正确地使用typeof细化类型。

function whatKindOfNumberIsIt(x: number | bigint) {
  if (typeof x === 'bigint') {
    console.log("'x' is a bigint!");
  } else {
    console.log("'x' is a floating-point number");
  }
}

感谢Caleb Sander为实现此功能的付出。

警告

BigInt 仅在目标为esnext时才支持。 可能不是很明显的一点是,因为 BigInts 针对算术运算符+, -, *等具有不同的行为,为老旧版(如es2017及以下)提供此功能时意味着重写出现它们的每一个操作。 TypeScript 需根据类型和涉及到的每一处加法,字符串拼接,乘法等产生正确的行为。

因为这个原因,我们不会立即提供向下的支持。 好的一面是,Node 11 和较新版本的 Chrome 已经支持了这个特性,因此你可以在目标为esnext时,使用 BigInt。

一些目标可能包含 polyfill 或类似 BigInt 的运行时对象。 基于这些考虑,你可能会想要添加esnext.bigintlib编译选项里。

Non-unit types as union discriminants

TypeScript 3.2 放宽了作为判别式属性的限制,来让类型细化变得容易。 如果联合类型的共同属性包含了某些单体类型(如,字面符字面量,nullundefined)且不包含泛型,那么它就可以做为判别式。

因此,TypeScript 3.2 认为下例中的error属性可以做为判别式。这在之前是不可以的,因为Error并非是一个单体类型。 那么,unwrap函数体里的类型细化就可以正确地工作了。

type Result<T> = { error: Error; data: null } | { error: null; data: T };

function unwrap<T>(result: Result<T>) {
  if (result.error) {
    // Here 'error' is non-null
    throw result.error;
  }

  // Now 'data' is non-null
  return result.data;
}

tsconfig.json可以通过 Node.js 包来继承

TypeScript 3.2 现在可以从node_modules里解析tsconfig.json。如果tsconfig.json文件里的"extends"设置为空,那么 TypeScript 会检测node_modules包。 When using a bare path for the "extends" field in tsconfig.json, TypeScript will dive into node_modules packages for us.

{
    "extends": "@my-team/tsconfig-base",
    "include": ["./**/*"]
    "compilerOptions": {
        // Override certain options on a project-by-project basis.
        "strictBindCallApply": false,
    }
}

这里,TypeScript 会去node_modules目录里查找@my-team/tsconfig-base包。针对每一个包,TypeScript 检查package.json里是否包含"tsconfig"字段,如果是,TypeScript 会尝试从那里加载配置文件。如果两者都不存在,TypeScript 尝试从根目录读取tsconfig.json。这与 Nodejs 查找.js文件或 TypeScript 查找.d.ts文件的已有过程类似。

这个特性对于大型组织或具有很多分布的依赖的工程特别有帮助。

The new --showConfig flag

tsc,TypeScript 编译器,支持一个新的标记--showConfig。 运行tsc --showConfig时,TypeScript 计算生效的tsconfig.json并打印(继承的配置也会计算在内)。 这对于调试诊断配置问题很有帮助。

JavaScript 的Object.defineProperty声明

在编写 JavaScript 文件时(使用allowJs),TypeScript 能识别出使用Object.defineProperty声明。 也就是说会有更好的代码补全功能,和强类型检查,这需要在 JavaScript 文件里启用类型检查功能(打开checkJs选项或在文件顶端添加// @ts-check注释)。

// @ts-check

let obj = {};
Object.defineProperty(obj, 'x', { value: 'hello', writable: false });

obj.x.toLowercase();
//    ~~~~~~~~~~~
//    error:
//     Property 'toLowercase' does not exist on type 'string'.
//     Did you mean 'toLowerCase'?

obj.x = 'world';
//  ~
//  error:
//   Cannot assign to 'x' because it is a read-only property.

TypeScript 3.1

元组和数组上的映射类型

TypeScript 3.1,在元组和数组上的映射对象类型现在会生成新的元组/数组,而非创建一个新的类型并且这个类型上具有如push()pop()length这样的成员。 例子:

type MapToPromise<T> = { [K in keyof T]: Promise<T[K]> };

type Coordinate = [number, number];

type PromiseCoordinate = MapToPromise<Coordinate>; // [Promise<number>, Promise<number>]

MapToPromise接收参数T,当它是个像Coordinate这样的元组时,只有数值型属性会被转换。 [number, number]具有两个数值型属性:01。 针对这样的数组,MapToPromise会创建一个新的元组,01属性是原类型的一个Promise。 因此PromiseCoordinate的类型为[Promise<number>, Promise<number>]

函数上的属性声明

TypeScript 3.1 提供了在函数声明上定义属性的能力,还支持const声明的函数。只需要在函数直接给属性赋值就可以了。 这样我们就可以规范 JavaScript 代码,不必再借助于namespace。 例子:

function readImage(path: string, callback: (err: any, image: Image) => void) {
  // ...
}

readImage.sync = (path: string) => {
  const contents = fs.readFileSync(path);
  return decodeImageSync(contents);
};

这里,readImage函数异步地读取一张图片。 此外,我们还在readImage上提供了一个便捷的函数readImage.sync

一般来说,使用 ECMAScript 导出是个更好的方式,但这个新功能支持此风格的代码能够在 TypeScript 里执行。 此外,这种属性声明的方式允许我们表达一些常见的模式,例如 React 函数组件(之前叫做 SFC)里的defaultPropspropTpes

export const FooComponent = ({ name }) => <div>Hello! I am {name}</div>;

FooComponent.defaultProps = {
  name: '(anonymous)',
};

[1] 更确切地说,是上面那种同态映射类型。

使用typesVersions选择版本

由社区的反馈还有我们的经验得知,利用最新的 TypeScript 功能的同时容纳旧版本的用户很困难。 TypeScript 引入了叫做typesVersions的新特性来解决这种情况。

在 TypeScript 3.1 里使用 Node 模块解析时,TypeScript 会读取package.json文件,找到它需要读取的文件,它首先会查看名字为typesVersions的字段。 一个带有typesVersions字段的package.json文件:

{
  "name": "package-name",
  "version": "1.0",
  "types": "./index.d.ts",
  "typesVersions": {
    ">=3.1": { "*": ["ts3.1/*"] }
  }
}

package.json告诉 TypeScript 去检查当前版本的 TypeScript 是否正在运行。 如果是 3.1 或以上的版本,它会找出你导入的包的路径,然后读取这个包里面的ts3.1文件夹里的内容。 这就是{ "*": ["ts3.1/*"] }的意义 - 如果你对路径映射熟悉,它们的工作方式类似。

因此在上例中,如果我们正在从"package-name"中导入,并且正在运行的 TypeScript 版本为 3.1,我们会尝试从[...]/node_modules/package-name/ts3.1/index.d.ts开始解析。 如果是从package-name/foo导入,由会查找[...]/node_modules/package-name/ts3.1/foo.d.ts[...]/node_modules/package-name/ts3.1/foo/index.d.ts

那如果当前运行的 TypeScript 版本不是 3.1 呢? 如果typesVersions里没有能匹配上的版本,TypeScript 将回退到查看types字段,因此 TypeScript 3.0 及之前的版本会重定向到[...]/node_modules/package-name/index.d.ts

匹配行为

TypeScript 使用 Node 的semver ranges去决定编译器和语言版本。

多个字段

typesVersions支持多个字段,每个字段都指定了一个匹配范围。

{
  "name": "package-name",
  "version": "1.0",
  "types": "./index.d.ts",
  "typesVersions": {
    ">=3.2": { "*": ["ts3.2/*"] },
    ">=3.1": { "*": ["ts3.1/*"] }
  }
}

因为范围可能会重叠,因此指定的顺序是有意义的。 在上例中,尽管>=3.2>=3.1都匹配 TypeScript 3.2 及以上版本,反转它们的顺序将会有不同的结果,因此上例与下面的代码并不等同。

{
  "name": "package-name",
  "version": "1.0",
  "types": "./index.d.ts",
  "typesVersions": {
    // 注意,这样写不生效
    ">=3.1": { "*": ["ts3.1/*"] },
    ">=3.2": { "*": ["ts3.2/*"] }
  }
}

TypeScript 3.0

工程引用

TypeScript 3.0 引入了一个叫做工程引用的新概念。工程引用允许 TypeScript 工程依赖于其它 TypeScript 工程 - 特别要提的是允许tsconfig.json文件引用其它tsconfig.json文件。当指明了这些依赖后,就可以方便地将代码分割成单独的小工程,有助于 TypeScript(以及周边的工具)了解构建顺序和输出结构。

TypeScript 3.0 还引入了一种新的tsc模式,即--build标记,它与工程引用同时运用可以加速构建 TypeScript。

相关详情请阅读工程引用手册

剩余参数和展开表达式里的元组

TypeScript 3.0 增加了支持以元组类型与函数参数列表进行交互的能力。 如下:

有了这些特性后,便有可能将转换函数和它们参数列表的高阶函数变为强类型的。

带元组类型的剩余参数

当剩余参数里有元组类型时,元组类型被扩展为离散参数序列。 例如,如下两个声明是等价的:

declare function foo(...args: [number, string, boolean]): void;
declare function foo(args_0: number, args_1: string, args_2: boolean): void;

带有元组类型的展开表达式

在函数调用中,若最后一个参数是元组类型的展开表达式,那么这个展开表达式相当于元组元素类型的离散参数序列。

因此,下面的调用都是等价的:

const args: [number, string, boolean] = [42, 'hello', true];
foo(42, 'hello', true);
foo(args[0], args[1], args[2]);
foo(...args);

泛型剩余参数

剩余参数允许带有泛型类型,这个泛型类型被限制为是一个数组类型,类型推断系统能够推断这类泛型剩余参数里的元组类型。这样就可以进行高阶捕获和展开部分参数列表:

例子

declare function bind<T, U extends any[], V>(
  f: (x: T, ...args: U) => V,
  x: T
): (...args: U) => V;

declare function f3(x: number, y: string, z: boolean): void;

const f2 = bind(f3, 42); // (y: string, z: boolean) => void
const f1 = bind(f2, 'hello'); // (z: boolean) => void
const f0 = bind(f1, true); // () => void

f3(42, 'hello', true);
f2('hello', true);
f1(true);
f0();

上例的f2声明,类型推断可以推断出number[string, boolean]void做为TUV

注意,如果元组类型是从参数序列中推断出来的,之后又扩展成参数列表,就像U那样,原来的参数名称会被用在扩展中(然而,这个名字没有语义上的意义且是察觉不到的)。

元组类型里的可选元素

元组类型现在允许在其元素类型上使用?后缀,表示这个元素是可选的:

例子

let t: [number, string?, boolean?];
t = [42, 'hello', true];
t = [42, 'hello'];
t = [42];

--strictNullChecks模式下,?修饰符会自动地在元素类型中包含undefined,类似于可选参数。

在元组类型的一个元素类型上使用?后缀修饰符来把它标记为可忽略的元素,且它右侧所有元素也同时带有了?修饰符。

当剩余参数推断为元组类型时,源码中的可选参数在推断出的类型里成为了可选元组元素。

带有可选元素的元组类型的length属性是表示可能长度的数字字面量类型的联合类型。 例如,[number, string?, boolean?]元组类型的length属性的类型是1 | 2 | 3

元组类型里的剩余元素

元组类型里最后一个元素可以是剩余元素,形式为...X,这里X是数组类型。 剩余元素代表元组类型是开放的,可以有零个或多个额外的元素。 例如,[number, ...string[]]表示带有一个number元素和任意数量string类型元素的元组类型。

例子

function tuple<T extends any[]>(...args: T): T {
  return args;
}

const numbers: number[] = getArrayOfNumbers();
const t1 = tuple('foo', 1, true); // [string, number, boolean]
const t2 = tuple('bar', ...numbers); // [string, ...number[]]

这个带有剩余元素的元组类型的length属性类型是number

新的unknown类型

TypeScript 3.0 引入了一个顶级的unknown类型。 对照于anyunknown是类型安全的。 任何值都可以赋给unknown,但是当没有类型断言或基于控制流的类型细化时unknown不可以赋值给其它类型,除了它自己和any外。 同样地,在unknown没有被断言或细化到一个确切类型之前,是不允许在其上进行任何操作的。

例子

// In an intersection everything absorbs unknown

type T00 = unknown & null; // null
type T01 = unknown & undefined; // undefined
type T02 = unknown & null & undefined; // null & undefined (which becomes never)
type T03 = unknown & string; // string
type T04 = unknown & string[]; // string[]
type T05 = unknown & unknown; // unknown
type T06 = unknown & any; // any

// In a union an unknown absorbs everything

type T10 = unknown | null; // unknown
type T11 = unknown | undefined; // unknown
type T12 = unknown | null | undefined; // unknown
type T13 = unknown | string; // unknown
type T14 = unknown | string[]; // unknown
type T15 = unknown | unknown; // unknown
type T16 = unknown | any; // any

// Type variable and unknown in union and intersection

type T20<T> = T & {}; // T & {}
type T21<T> = T | {}; // T | {}
type T22<T> = T & unknown; // T
type T23<T> = T | unknown; // unknown

// unknown in conditional types

type T30<T> = unknown extends T ? true : false; // Deferred
type T31<T> = T extends unknown ? true : false; // Deferred (so it distributes)
type T32<T> = never extends T ? true : false; // true
type T33<T> = T extends never ? true : false; // Deferred

// keyof unknown

type T40 = keyof any; // string | number | symbol
type T41 = keyof unknown; // never

// Only equality operators are allowed with unknown

function f10(x: unknown) {
  x == 5;
  x !== 10;
  x >= 0; // Error
  x + 1; // Error
  x * 2; // Error
  -x; // Error
  +x; // Error
}

// No property accesses, element accesses, or function calls

function f11(x: unknown) {
  x.foo; // Error
  x[5]; // Error
  x(); // Error
  new x(); // Error
}

// typeof, instanceof, and user defined type predicates

declare function isFunction(x: unknown): x is Function;

function f20(x: unknown) {
  if (typeof x === 'string' || typeof x === 'number') {
    x; // string | number
  }
  if (x instanceof Error) {
    x; // Error
  }
  if (isFunction(x)) {
    x; // Function
  }
}

// Homomorphic mapped type over unknown

type T50<T> = { [P in keyof T]: number };
type T51 = T50<any>; // { [x: string]: number }
type T52 = T50<unknown>; // {}

// Anything is assignable to unknown

function f21<T>(pAny: any, pNever: never, pT: T) {
  let x: unknown;
  x = 123;
  x = 'hello';
  x = [1, 2, 3];
  x = new Error();
  x = x;
  x = pAny;
  x = pNever;
  x = pT;
}

// unknown assignable only to itself and any

function f22(x: unknown) {
  let v1: any = x;
  let v2: unknown = x;
  let v3: object = x; // Error
  let v4: string = x; // Error
  let v5: string[] = x; // Error
  let v6: {} = x; // Error
  let v7: {} | null | undefined = x; // Error
}

// Type parameter 'T extends unknown' not related to object

function f23<T extends unknown>(x: T) {
  let y: object = x; // Error
}

// Anything but primitive assignable to { [x: string]: unknown }

function f24(x: { [x: string]: unknown }) {
  x = {};
  x = { a: 5 };
  x = [1, 2, 3];
  x = 123; // Error
}

// Locals of type unknown always considered initialized

function f25() {
  let x: unknown;
  let y = x;
}

// Spread of unknown causes result to be unknown

function f26(x: {}, y: unknown, z: any) {
  let o1 = { a: 42, ...x }; // { a: number }
  let o2 = { a: 42, ...x, ...y }; // unknown
  let o3 = { a: 42, ...x, ...y, ...z }; // any
}

// Functions with unknown return type don't need return expressions

function f27(): unknown {}

// Rest type cannot be created from unknown

function f28(x: unknown) {
  let { ...a } = x; // Error
}

// Class properties of type unknown don't need definite assignment

class C1 {
  a: string; // Error
  b: unknown;
  c: any;
}

在 JSX 里支持defaultProps

TypeScript 2.9 和之前的版本不支持在 JSX 组件里使用React 的defaultProps声明。 用户通常不得不将属性声明为可选的,然后在render里使用非null的断言,或者在导出之前对组件的类型使用类型断言。

TypeScript 3.0 在JSX命名空间里支持一个新的类型别名LibraryManagedAttributes。 这个助手类型定义了在检查 JSX 表达式之前在组件Props上的一个类型转换;因此我们可以进行定制:如何处理提供的props与推断props之间的冲突,推断如何映射,如何处理可选性以及不同位置的推断如何结合在一起。

我们可以利用它来处理 React 的defaultProps以及propTypes

export interface Props {
    name: string;
}

export class Greet extends React.Component<Props> {
    render() {
        const { name } = this.props;
        return <div>Hello {name.toUpperCase()}!</div>;
    }
    static defaultProps = { name: "world"};
}

// Type-checks! No type assertions needed!
let el = <Greet />

说明

defaultProps的确切类型

默认类型是从defaultProps属性的类型推断而来。如果添加了显式的类型注释,比如static defaultProps: Partial<Props>;,编译器无法识别哪个属性具有默认值(因为defaultProps类型包含了Props的所有属性)。

使用static defaultProps: Pick<Props, "name">;做为显式的类型注释,或者不添加类型注释。

对于函数组件(之前叫做 SFC),使用 ES2015 默认的初始化器:

function Greet({ name = "world" }: Props) {
    return <div>Hello {name.toUpperCase()}!</div>;
}

@types/React的改动

仍需要在@types/ReactJSX命名空间上添加LibraryManagedAttributes定义。

/// <reference lib="..." />指令

TypeScript 增加了一个新的三斜线指令(/// <reference lib="name" />),允许一个文件显式地包含一个已知的内置lib文件。

内置的lib文件的引用和tsconfig.json里的编译器选项"lib"相同(例如,使用lib="es2015"而不是lib="lib.es2015.d.ts"等)。

当你写的声明文件依赖于内置类型时,例如 DOM APIs 或内置的 JS 运行时构造函数如SymbolIterable,推荐使用三斜线引用指令。之前,这个.d.ts文件不得不添加重覆的类型声明。

例子

在某个文件里使用 /// <reference lib="es2017.string" />等同于指定--lib es2017.string编译选项。

/// <reference lib="es2017.string" />

'foo'.padStart(4);

TypeScript 2.9

keyof和映射类型支持用numbersymbol命名的属性

TypeScript 2.9 增加了在索引类型和映射类型上支持用numbersymbol命名属性。 在之前,keyof操作符和映射类型只支持string命名的属性。

改动包括:

  • 对某些类型T,索引类型keyof Tstring | number | symbol的子类型。
  • 映射类型{ [P in K]: XXX },其中K允许是可以赋值给string | number | symbol的任何值。
  • 针对泛型T的对象的for...in语句,迭代变量推断类型之前为keyof T,现在是Extract<keyof T, string>。(换句话说,是keyof T的子集,它仅包含类字符串的值。)

对于对象类型Xkeyof X将按以下方式解析:

  • 如果X带有字符串索引签名,则keyof Xstringnumber和表示 symbol-like 属性的字面量类型的联合,否则
  • 如果X带有数字索引签名,则keyof Xnumber和表示 string-like 和 symbol-like 属性的字面量类型的联合,否则
  • keyof X为表示 string-like,number-like 和 symbol-like 属性的字面量类型的联合。

在何处:

  • 对象类型的 string-like 属性,是那些使用标识符,字符串字面量或计算后值为字符串字面量类型的属性名所声明的。
  • 对象类型的 number-like 属性是那些使用数字字面量或计算后值为数字字面量类型的属性名所声明的。
  • 对象类型的 symbol-like 属性是那些使用计算后值为 symbol 字面量类型的属性名所声明的。

对于映射类型{ [P in K]: XXX }K的每个字符串字面量类型都会引入一个名字为字符串的属性,K的每个数字字面量类型都会引入一个名字为数字的属性,K的每个 symbol 字面量类型都会引入一个名字为 symbol 的属性。 并且,如果K包含string类型,那个同时也会引入字符串索引类型,如果K包含number类型,那个同时也会引入数字索引类型。

例子

const c = 'c';
const d = 10;
const e = Symbol();

const enum E1 {
  A,
  B,
  C,
}
const enum E2 {
  A = 'A',
  B = 'B',
  C = 'C',
}

type Foo = {
  a: string; // String-like name
  5: string; // Number-like name
  [c]: string; // String-like name
  [d]: string; // Number-like name
  [e]: string; // Symbol-like name
  [E1.A]: string; // Number-like name
  [E2.A]: string; // String-like name
};

type K1 = keyof Foo; // "a" | 5 | "c" | 10 | typeof e | E1.A | E2.A
type K2 = Extract<keyof Foo, string>; // "a" | "c" | E2.A
type K3 = Extract<keyof Foo, number>; // 5 | 10 | E1.A
type K4 = Extract<keyof Foo, symbol>; // typeof e

现在通过在键值类型里包含number类型,keyof就能反映出数字索引签名的存在,因此像Partial<T>Readonly<T>的映射类型能够正确地处理带数字索引签名的对象类型:

type Arrayish<T> = {
  length: number;
  [x: number]: T;
};

type ReadonlyArrayish<T> = Readonly<Arrayish<T>>;

declare const map: ReadonlyArrayish<string>;
let n = map.length;
let x = map[123]; // Previously of type any (or an error with --noImplicitAny)

此外,由于keyof支持用numbersymbol命名的键值,现在可以对对象的数字字面量(如数字枚举类型)和唯一的 symbol 属性的访问进行抽象。

const enum Enum {
  A,
  B,
  C,
}

const enumToStringMap = {
  [Enum.A]: 'Name A',
  [Enum.B]: 'Name B',
  [Enum.C]: 'Name C',
};

const sym1 = Symbol();
const sym2 = Symbol();
const sym3 = Symbol();

const symbolToNumberMap = {
  [sym1]: 1,
  [sym2]: 2,
  [sym3]: 3,
};

type KE = keyof typeof enumToStringMap; // Enum (i.e. Enum.A | Enum.B | Enum.C)
type KS = keyof typeof symbolToNumberMap; // typeof sym1 | typeof sym2 | typeof sym3

function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

let x1 = getValue(enumToStringMap, Enum.C); // Returns "Name C"
let x2 = getValue(symbolToNumberMap, sym3); // Returns 3

这是一个破坏性改动;之前,keyof操作符和映射类型只支持string命名的属性。 那些把总是把keyof T的类型当做string的代码现在会报错。

例子

function useKey<T, K extends keyof T>(o: T, k: K) {
  var name: string = k; // 错误:keyof T不能赋值给字符串
}

推荐

  • 如果函数只能处理字符串命名属性的键,在声明里使用Extract<keyof T, string>

    function useKey<T, K extends Extract<keyof T, string>>(o: T, k: K) {
      var name: string = k; // OK
    }
    
  • 如果函数能处理任何属性的键,那么可以在下游进行改动:

    function useKey<T, K extends keyof T>(o: T, k: K) {
      var name: string | number | symbol = k;
    }
    
  • 否则,使用--keyofStringsOnly编译器选项来禁用新的行为。

JSX 元素里的泛型参数

JSX 元素现在允许传入类型参数到泛型组件里。

例子

class GenericComponent<P> extends React.Component<P> {
  internalProp: P;
}

type Props = { a: number; b: string };

const x = <GenericComponent<Props> a={10} b="hi" />; // OK

const y = <GenericComponent<Props> a={10} b={20} />; // Error

泛型标记模版里的泛型参数

标记模版是 ECMAScript 2015 引入的一种调用形式。 类似调用表达式,可以在标记模版里使用泛型函数,TypeScript 会推断使用的类型参数。

TypeScript 2.9 允许传入泛型参数到标记模版字符串。

例子

declare function styledComponent<Props>(
  strs: TemplateStringsArray
): Component<Props>;

interface MyProps {
  name: string;
  age: number;
}

styledComponent<MyProps>`
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`;

declare function tag<T>(strs: TemplateStringsArray, ...args: T[]): T;

// inference fails because 'number' and 'string' are both candidates that conflict
let a = tag<string | number>`${100} ${'hello'}`;

import类型

模块可以导入在其它模块里声明的类型。但是非模块的全局脚本不能访问模块里声明的类型。这里,import类型登场了。

在类型注释的位置使用import("mod"),就可以访问一个模块和它导出的声明,而不必导入它。

例子

在一个模块文件里,有一个Pet类的声明:

// module.d.ts

export declare class Pet {
  name: string;
}

它可以被用在非模块文件global-script.ts

// global-script.ts

function adopt(p: import('./module').Pet) {
  console.log(`Adopting ${p.name}...`);
}

它也可以被放在.js文件的 JSDoc 注释里,来引用模块里的类型:

// a.js

/**
 * @param p { import("./module").Pet }
 */
function walk(p) {
  console.log(`Walking ${p.name}...`);
}

放开声明生成时可见性规则

随着import类型的到来,许多在声明文件生成阶段报的可见性错误可以被编译器正确地处理,而不需要改变输入。

例如:

import { createHash } from 'crypto';

export const hash = createHash('sha256');
//           ^^^^
// Exported variable 'hash' has or is using name 'Hash' from external module "crypto" but cannot be named.

TypeScript 2.9 不会报错,生成文件如下:

export declare const hash: import('crypto').Hash;

支持import.meta

TypeScript 2.9 引入对import.meta的支持,它是当前TC39 建议里的一个元属性。

import.meta类型是全局的ImportMeta类型,它在lib.es5.d.ts里定义。 这个接口地使用十分有限。 添加众所周知的 Node 和浏览器属性需要进行接口合并,还有可能需要根据上下文来增加全局空间。

例子

假设__dirname永远存在于import.meta,那么可以通过重新开放ImportMeta接口来进行声明:

// node.d.ts
interface ImportMeta {
  __dirname: string;
}

用法如下:

import.meta.__dirname; // Has type 'string'

import.meta仅在输出目标为ESNext模块和 ECMAScript 时才生效。

新的--resolveJsonModule

在 Node.js 应用里经常需要使用.json。TypeScript 2.9 的--resolveJsonModule允许从.json文件里导入,获取类型。

例子

// settings.json

{
    "repo": "TypeScript",
    "dry": false,
    "debug": false
}
// a.ts

import settings from './settings.json';

settings.debug === true; // OK
settings.dry === 2; // Error: Operator '===' cannot be applied boolean and number
// tsconfig.json

{
    "compilerOptions": {
        "module": "commonjs",
        "resolveJsonModule": true,
        "esModuleInterop": true
    }
}

默认--pretty输出

从 TypeScript 2.9 开始,如果应用支持彩色文字,那么错误输出时会默认应用--pretty。 TypeScript 会检查输出流是否设置了isTty属性。

使用--pretty false命令行选项或tsconfig.json里设置"pretty": false来禁用--pretty输出。

新的--declarationMap

随着--declaration一起启用--declarationMap,编译器在生成.d.ts的同时还会生成.d.ts.map。 语言服务现在也能够理解这些 map 文件,将声明文件映射到源码。

换句话说,在启用了--declarationMap后生成的.d.ts文件里点击 go-to-definition,将会导航到源文件里的位置(.ts),而不是导航到.d.ts文件里。

TypeScript 2.8

有条件类型

TypeScript 2.8 引入了有条件类型,它能够表示非统一的类型。 有条件的类型会以一个条件表达式进行类型关系检测,从而在两种类型中选择其一:

T extends U ? X : Y

上面的类型意思是,若T能够赋值给U,那么类型是X,否则为Y

有条件的类型T extends U ? X : Y或者解析X,或者解析Y,再或者延迟解析,因为它可能依赖一个或多个类型变量。 是否直接解析或推迟取决于:

  • 首先,令T'U'分别为TU的实例,并将所有类型参数替换为any,如果T'不能赋值给U',则将有条件的类型解析成Y。直观上讲,如果最宽泛的T的实例不能赋值给最宽泛的U的实例,那么我们就可以断定不存在可以赋值的实例,因此可以解析为Y
  • 其次,针对每个在U内由推断声明引入的类型变量,依据从T推断到U来收集一组候选类型(使用与泛型函数类型推断相同的推断算法)。对于给定的推断类型变量V,如果有候选类型是从协变的位置上推断出来的,那么V的类型是那些候选类型的联合。反之,如果有候选类型是从逆变的位置上推断出来的,那么V的类型是那些候选类型的交叉类型。否则V的类型是never
  • 然后,令T''T的一个实例,所有推断的类型变量用上一步的推断结果替换,如果T''明显可赋值U,那么将有条件的类型解析为X。除去不考虑类型变量的限制之外,明显可赋值的关系与正常的赋值关系一致。直观上,当一个类型明显可赋值给另一个类型,我们就能够知道它可以赋值给那些类型的所有实例。
  • 否则,这个条件依赖于一个或多个类型变量,有条件的类型解析被推迟进行。

例子

type TypeName<T> = T extends string
  ? 'string'
  : T extends number
  ? 'number'
  : T extends boolean
  ? 'boolean'
  : T extends undefined
  ? 'undefined'
  : T extends Function
  ? 'function'
  : 'object';

type T0 = TypeName<string>; // "string"
type T1 = TypeName<'a'>; // "string"
type T2 = TypeName<true>; // "boolean"
type T3 = TypeName<() => void>; // "function"
type T4 = TypeName<string[]>; // "object"

分布式有条件类型

如果有条件类型里待检查的类型是naked type parameter,那么它也被称为“分布式有条件类型”。 分布式有条件类型在实例化时会自动分发成联合类型。 例如,实例化T extends U ? X : YT的类型为A | B | C,会被解析为(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

例子

type T10 = TypeName<string | (() => void)>; // "string" | "function"
type T12 = TypeName<string | string[] | undefined>; // "string" | "object" | "undefined"
type T11 = TypeName<string[] | number[]>; // "object"

T extends U ? X : Y的实例化里,对T的引用被解析为联合类型的一部分(比如,T指向某一单个部分,在有条件类型分布到联合类型之后)。 此外,在X内对T的引用有一个附加的类型参数约束U(例如,T被当成在X内可赋值给U)。

例子

type BoxedValue<T> = { value: T };
type BoxedArray<T> = { array: T[] };
type Boxed<T> = T extends any[] ? BoxedArray<T[number]> : BoxedValue<T>;

type T20 = Boxed<string>; // BoxedValue<string>;
type T21 = Boxed<number[]>; // BoxedArray<number>;
type T22 = Boxed<string | number[]>; // BoxedValue<string> | BoxedArray<number>;

注意在Boxed<T>true分支里,T有个额外的约束any[],因此它适用于T[number]数组元素类型。同时也注意一下有条件类型是如何分布成联合类型的。

有条件类型的分布式的属性可以方便地用来过滤联合类型:

type Diff<T, U> = T extends U ? never : T; // Remove types from T that are assignable to U
type Filter<T, U> = T extends U ? T : never; // Remove types from T that are not assignable to U

type T30 = Diff<'a' | 'b' | 'c' | 'd', 'a' | 'c' | 'f'>; // "b" | "d"
type T31 = Filter<'a' | 'b' | 'c' | 'd', 'a' | 'c' | 'f'>; // "a" | "c"
type T32 = Diff<string | number | (() => void), Function>; // string | number
type T33 = Filter<string | number | (() => void), Function>; // () => void

type NonNullable<T> = Diff<T, null | undefined>; // Remove null and undefined from T

type T34 = NonNullable<string | number | undefined>; // string | number
type T35 = NonNullable<string | string[] | null | undefined>; // string | string[]

function f1<T>(x: T, y: NonNullable<T>) {
  x = y; // Ok
  y = x; // Error
}

function f2<T extends string | undefined>(x: T, y: NonNullable<T>) {
  x = y; // Ok
  y = x; // Error
  let s1: string = x; // Error
  let s2: string = y; // Ok
}

有条件类型与映射类型结合时特别有用:

type FunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;

type NonFunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;

interface Part {
  id: number;
  name: string;
  subparts: Part[];
  updatePart(newName: string): void;
}

type T40 = FunctionPropertyNames<Part>; // "updatePart"
type T41 = NonFunctionPropertyNames<Part>; // "id" | "name" | "subparts"
type T42 = FunctionProperties<Part>; // { updatePart(newName: string): void }
type T43 = NonFunctionProperties<Part>; // { id: number, name: string, subparts: Part[] }

与联合类型和交叉类型相似,有条件类型不允许递归地引用自己。比如下面的错误。

例子

type ElementType<T> = T extends any[] ? ElementType<T[number]> : T; // Error

有条件类型中的类型推断

现在在有条件类型的extends子语句中,允许出现infer声明,它会引入一个待推断的类型变量。 这个推断的类型变量可以在有条件类型的 true 分支中被引用。 允许出现多个同类型变量的infer

例如,下面代码会提取函数类型的返回值类型:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

有条件类型可以嵌套来构成一系列的匹配模式,按顺序进行求值:

type Unpacked<T> = T extends (infer U)[]
  ? U
  : T extends (...args: any[]) => infer U
  ? U
  : T extends Promise<infer U>
  ? U
  : T;

type T0 = Unpacked<string>; // string
type T1 = Unpacked<string[]>; // string
type T2 = Unpacked<() => string>; // string
type T3 = Unpacked<Promise<string>>; // string
type T4 = Unpacked<Promise<string>[]>; // Promise<string>
type T5 = Unpacked<Unpacked<Promise<string>[]>>; // string

下面的例子解释了在协变位置上,同一个类型变量的多个候选类型会被推断为联合类型:

type Foo<T> = T extends { a: infer U; b: infer U } ? U : never;
type T10 = Foo<{ a: string; b: string }>; // string
type T11 = Foo<{ a: string; b: number }>; // string | number

相似地,在抗变位置上,同一个类型变量的多个候选类型会被推断为交叉类型:

type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void }
  ? U
  : never;
type T20 = Bar<{ a: (x: string) => void; b: (x: string) => void }>; // string
type T21 = Bar<{ a: (x: string) => void; b: (x: number) => void }>; // string & number

当推断具有多个调用签名(例如函数重载类型)的类型时,用最后的签名(大概是最自由的包含所有情况的签名)进行推断。 无法根据参数类型列表来解析重载。

declare function foo(x: string): number;
declare function foo(x: number): string;
declare function foo(x: string | number): string | number;
type T30 = ReturnType<typeof foo>; // string | number

无法在正常类型参数的约束子语句中使用infer声明:

type ReturnType<T extends (...args: any[]) => infer R> = R; // 错误,不支持

但是,可以这样达到同样的效果,在约束里删掉类型变量,用有条件类型替换:

type AnyFunction = (...args: any[]) => any;
type ReturnType<T extends AnyFunction> = T extends (...args: any[]) => infer R
  ? R
  : any;

预定义的有条件类型

TypeScript 2.8 在lib.d.ts里增加了一些预定义的有条件类型:

  • Exclude<T, U> -- 从T中剔除可以赋值给U的类型。
  • Extract<T, U> -- 提取T中可以赋值给U的类型。
  • NonNullable<T> -- 从T中剔除nullundefined
  • ReturnType<T> -- 获取函数返回值类型。
  • InstanceType<T> -- 获取构造函数类型的实例类型。

Example

type T00 = Exclude<'a' | 'b' | 'c' | 'd', 'a' | 'c' | 'f'>; // "b" | "d"
type T01 = Extract<'a' | 'b' | 'c' | 'd', 'a' | 'c' | 'f'>; // "a" | "c"

type T02 = Exclude<string | number | (() => void), Function>; // string | number
type T03 = Extract<string | number | (() => void), Function>; // () => void

type T04 = NonNullable<string | number | undefined>; // string | number
type T05 = NonNullable<(() => string) | string[] | null | undefined>; // (() => string) | string[]

function f1(s: string) {
  return { a: 1, b: s };
}

class C {
  x = 0;
  y = 0;
}

type T10 = ReturnType<() => string>; // string
type T11 = ReturnType<(s: string) => void>; // void
type T12 = ReturnType<<T>() => T>; // {}
type T13 = ReturnType<<T extends U, U extends number[]>() => T>; // number[]
type T14 = ReturnType<typeof f1>; // { a: number, b: string }
type T15 = ReturnType<any>; // any
type T16 = ReturnType<never>; // any
type T17 = ReturnType<string>; // Error
type T18 = ReturnType<Function>; // Error

type T20 = InstanceType<typeof C>; // C
type T21 = InstanceType<any>; // any
type T22 = InstanceType<never>; // any
type T23 = InstanceType<string>; // Error
type T24 = InstanceType<Function>; // Error

注意:Exclude类型是建议的Diff类型的一种实现。我们使用Exclude这个名字是为了避免破坏已经定义了Diff的代码,并且我们感觉这个名字能更好地表达类型的语义。我们没有增加Omit<T, K>类型,因为它可以很容易的用Pick<T, Exclude<keyof T, K>>来表示。

改进对映射类型修饰符的控制

映射类型支持在属性上添加readonly?修饰符,但是它们不支持移除修饰符。 这对于同态映射类型有些影响,因为同态映射类型默认保留底层类型的修饰符。

TypeScript 2.8 为映射类型增加了增加或移除特定修饰符的能力。 特别地,映射类型里的readonly?属性修饰符现在可以使用+-前缀,来表示修饰符是添加还是移除。

例子

type MutableRequired<T> = { -readonly [P in keyof T]-?: T[P] }; // 移除readonly和?
type ReadonlyPartial<T> = { +readonly [P in keyof T]+?: T[P] }; // 添加readonly和?

不带+-前缀的修饰符与带+前缀的修饰符具有相同的作用。因此上面的ReadonlyPartial<T>类型与下面的一致

type ReadonlyPartial<T> = { readonly [P in keyof T]?: T[P] }; // 添加readonly和?

利用这个特性,lib.d.ts现在有了一个新的Required<T>类型。 它移除了T的所有属性的?修饰符,因此所有属性都是必需的。

例子

type Required<T> = { [P in keyof T]-?: T[P] };

注意在--strictNullChecks模式下,当同态映射类型移除了属性底层类型的?修饰符,它同时也移除了那个属性上的undefined类型:

例子

type Foo = { a?: string }; // 等同于 { a?: string | undefined }
type Bar = Required<Foo>; // 等同于 { a: string }

改进交叉类型上的keyof

TypeScript 2.8 作用于交叉类型的keyof被转换成作用于交叉成员的keyof的联合。 换句话说,keyof (A & B)会被转换成keyof A | keyof B。 这个改动应该能够解决keyof表达式推断不一致的问题。

例子

type A = { a: string };
type B = { b: string };

type T1 = keyof (A & B); // "a" | "b"
type T2<T> = keyof (T & B); // keyof T | "b"
type T3<U> = keyof (A & U); // "a" | keyof U
type T4<T, U> = keyof (T & U); // keyof T | keyof U
type T5 = T2<A>; // "a" | "b"
type T6 = T3<B>; // "a" | "b"
type T7 = T4<A, B>; // "a" | "b"

更好的处理.js文件中的命名空间模式

TypeScript 2.8 加强了识别.js文件里的命名空间模式。 JavaScript 顶层的空对象字面量声明,就像函数和类,会被识别成命名空间声明。

var ns = {}; // recognized as a declaration for a namespace `ns`
ns.constant = 1; // recognized as a declaration for var `constant`

顶层的赋值应该有一致的行为;也就是说,varconst声明不是必需的。

app = {}; // does NOT need to be `var app = {}`
app.C = class {};
app.f = function () {};
app.prop = 1;

立即执行的函数表达式做为命名空间

立即执行的函数表达式返回一个函数,类或空的对象字面量,也会被识别为命名空间:

var C = (function () {
  function C(n) {
    this.p = n;
  }
  return C;
})();
C.staticProperty = 1;

默认声明

“默认声明”允许引用了声明的名称的初始化器出现在逻辑或的左边:

my = window.my || {};
my.app = my.app || {};

原型赋值

你可以把一个对象字面量直接赋值给原型属性。独立的原型赋值也可以:

var C = function (p) {
  this.p = p;
};
C.prototype = {
  m() {
    console.log(this.p);
  },
};
C.prototype.q = function (r) {
  return this.p === r;
};

嵌套与合并声明

现在嵌套的层次不受限制,并且多文件之间的声明合并也没有问题。以前不是这样的。

var app = window.app || {};
app.C = class {};

各文件的 JSX 工厂

TypeScript 2.8 增加了使用@jsx dom指令为每个文件设置 JSX 工厂名。 JSX 工厂也可以使用--jsxFactory编译参数设置(默认值为React.createElement)。TypeScript 2.8 你可以基于文件进行覆写。

例子

/** @jsx dom */
import { dom } from './renderer';
<h></h>;

生成:

var renderer_1 = require('./renderer');
renderer_1.dom('h', null);

本地范围的 JSX 命名空间

JSX 类型检查基于 JSX 命名空间里的定义,比如JSX.Element用于 JSX 元素的类型,JSX.IntrinsicElements用于内置的元素。 在 TypeScript 2.8 之前JSX命名空间被视为全局命名空间,并且一个工程只允许存在一个。 TypeScript 2.8 开始,JSX命名空间将在jsxNamespace下面查找(比如React),允许在一次编译中存在多个 jsx 工厂。 为了向后兼容,全局的JSX命名空间被当做回退选项。 使用独立的@jsx指令,每个文件可以有自己的 JSX 工厂。

新的--emitDeclarationsOnly

--emitDeclarationsOnly允许生成声明文件;使用这个标记.js/.jsx输出会被跳过。当使用其它的转换工具如 Babel 处理.js输出的时候,可以使用这个标记。

TypeScript 2.7

TypeScript 2.7

常量名属性

TypeScript 2.7 新增了以常量(包括 ECMAScript symbols)作为类属性名的类型推断支持。

例子

// Lib
export const SERIALIZE = Symbol('serialize-method-key');

export interface Serializable {
  [SERIALIZE](obj: {}): string;
}
// consumer
import { SERIALIZE, Serializable } from 'lib';

class JSONSerializableItem implements Serializable {
  [SERIALIZE](obj: {}) {
    return JSON.stringify(obj);
  }
}

这同样适用于数字和字符串的字面量

例子

const Foo = 'Foo';
const Bar = 'Bar';

let x = {
  [Foo]: 100,
  [Bar]: 'hello',
};

let a = x[Foo]; // a类型为'number'; 在之前版本,类型为'number | string',现在可以追踪到类型
let b = x[Bar]; // b类型为'string';

unique symbol类型

为了将 symbol 变量视作有唯一值的字面量,我们新增了类型unique symbolunique symbolsymbol的子类型,仅由调用Symbol()Symbol.for()或明确的类型注释生成。 该类型只允许在const声明或者 readonly static 属性声明中使用。如果要引用某个特定的unique symbol变量,你必须使用typeof操作符。 每个对unique symbols的引用都意味着一个完全唯一的声明身份,与被引用的变量声明绑定。

例子

// Works
declare const Foo: unique symbol;

// Error! 'Bar'不是const声明的
let Bar: unique symbol = Symbol();

// Works - 对变量Foo的引用,它的声明身份与Foo绑定
let Baz: typeof Foo = Foo;

// Also works.
class C {
  static readonly StaticSymbol: unique symbol = Symbol();
}

因为每个unique symbols都有个完全独立的身份,因此两个unique symbols类型之间不能赋值或比较。

Example

const Foo = Symbol();
const Bar = Symbol();

// Error: 不能比较两个unique symbols.
if (Foo === Bar) {
  // ...
}

更严格的类属性检查

TypeScript 2.7 引入了一个新的控制严格性的标记--strictPropertyInitialization。 使用这个标记后,TypeScript 要求类的所有实例属性在构造函数里或属性初始化器中都得到初始化。比如:

class C {
  foo: number;
  bar = 'hello';
  baz: boolean;
  //  ~~~
  //  Error! Property 'baz' has no initializer and is not assigned directly in the constructor.
  constructor() {
    this.foo = 42;
  }
}

上例中,baz从未被赋值,因此 TypeScript 报错了。 如果我们的本意就是让baz可以为undefined,那么应该声明它的类型为boolean | undefined

在某些场景下,属性会被间接地初始化(使用辅助方法或依赖注入库)。 这种情况下,你可以在属性上使用显式赋值断言definite assignment assertion modifiers)来帮助类型系统识别类型(下面会讨论)

class C {
  foo!: number;
  // ^
  // Notice this exclamation point!
  // This is the "definite assignment assertion" modifier.
  constructor() {
    this.initialize();
  }

  initialize() {
    this.foo = 0;
  }
}

注意,--strictPropertyInitialization会在其它--strict模式标记下被启用,这可能会影响你的工程。 你可以在tsconfig.jsoncompilerOptions里将strictPropertyInitialization设置为false, 或者在命令行上将--strictPropertyInitialization设置为false来关闭检查。

显式赋值断言

显式赋值断言允许你在实例属性和变量声明之后加一个感叹号!,来告诉 TypeScript 这个变量确实已被赋值,即使 TypeScript 不能分析出这个结果。

例子

let x: number;
initialize();
console.log(x + x);
//          ~   ~
// Error! Variable 'x' is used before being assigned.

function initialize() {
  x = 10;
}

使用显式类型断言在x的声明后加上!,Typescript 可以认为变量x确实已被赋值

// Notice the '!'
let x!: number;
initialize();

// No error!
console.log(x + x);

function initialize() {
  x = 10;
}

在某种意义上,显式类型断言运算符是非空断言运算符(在表达式后缀的!)的对偶,就像下面这个例子

let x: number;
initialize();

// No error!
console.log(x! + x!);

function initialize() {
    x = 10;

在上面的例子中,我们知道x都会被初始化,因此使用显式类型断言比使用非空断言更合适。

固定长度元组

TypeScript 2.6 之前,[number, string, string]被当作[number, string]的子类型。 这对于 TypeScript 的结构性而言是合理的——[number, string, string]的前两个元素各自是[number, string]里前两个元素的子类型。 但是,我们注意到在在实践中的大多数情形下,这并不是开发者所希望的。

在 TypeScript 2.7 中,具有不同元数的元组不再允许相互赋值。感谢Tycho Grouwstra提交的 PR,元组类型现在会将它们的元数编码进它们对应的length属性的类型里。原理是利用数字字面量类型区分出不同长度的元组。

概念上讲,你可以把[number, string]类型等同于下面的NumStrTuple声明:

interface NumStrTuple extends Array<number | string> {
  0: number;
  1: string;
  length: 2; // 注意length的类型是字面量'2',而不是'number'
}

请注意,这是一个破坏性改动。 如果你想要和以前一样,让元组仅限制最小长度,那么你可以使用一个类似的声明但不显式指定length属性,这样length属性的类型就会回退为number

interface MinimumNumStrTuple extends Array<number | string> {
  0: number;
  1: string;
}

注:这并不意味着元组是不可变长的数组,而仅仅是一个约定。

更优的对象字面量推断

TypeScript 2.7 改进了在同一上下文中的多对象字面量的类型推断。 当多个对象字面量类型组成一个联合类型,TypeScript 现在会将它们规范化为一个对象类型,该对象类型包含联合类型中的每个对象的所有属性,以及属性对应的推断类型。

考虑这样的情形:

const obj = test ? { text: 'hello' } : {}; // { text: string } | { text?: undefined }
const s = obj.text; // string | undefined

以前obj会被推断为{},第二行会报错因为obj没有属性。但这显然并不理想。

例子

// let obj: { a: number, b: number } |
//     { a: string, b?: undefined } |
//     { a?: undefined, b?: undefined }
let obj = [{ a: 1, b: 2 }, { a: 'abc' }, {}][0];
obj.a; // string | number | undefined
obj.b; // number | undefined

多个对象字面量中的同一属性的所有推断类型,会合并成一个规范化的联合类型:

declare function f<T>(...items: T[]): T;
// let obj: { a: number, b: number } |
//     { a: string, b?: undefined } |
//     { a?: undefined, b?: undefined }
let obj = f({ a: 1, b: 2 }, { a: 'abc' }, {});
obj.a; // string | number | undefined
obj.b; // number | undefined

结构相同的类和instanceof表达式的处理方式改进

TypeScript 2.7 对联合类型中结构相同的类和instanceof表达式的处理方式改进如下:

  • 联合类型中,结构相同的不同类都会保留(而不是只保留一个)
  • 联合类型中的子类型简化仅在一种情况下发生——若一个类继承自联合类型中另一个类,该子类会被简化。
  • 用于类型检查的instanceof操作符基于继承关系来判断,而不是结构兼容来判断。

这意味着联合类型和instanceof能够区分结构相同的类。

例子

class A {}
class B extends A {}
class C extends A {}
class D extends A {
  c: string;
}
class E extends D {}

let x1 = !true ? new A() : new B(); // A
let x2 = !true ? new B() : new C(); // B | C (previously B)
let x3 = !true ? new C() : new D(); // C | D (previously C)

let a1 = [new A(), new B(), new C(), new D(), new E()]; // A[]
let a2 = [new B(), new C(), new D(), new E()]; // (B | C | D)[] (previously B[])

function f1(x: B | C | D) {
  if (x instanceof B) {
    x; // B (previously B | D)
  } else if (x instanceof C) {
    x; // C
  } else {
    x; // D (previously never)
  }
}

in运算符实现类型保护

in运算符现在会起到类型细化的作用。

对于一个n in x的表达式,当n是一个字符串字面量或者字符串字面量类型,并且x是一个联合类型: 在值为"true"的分支中,x会有一个推断出来可选或被赋值的属性n;在值为"false"的分支中,x根据推断仅有可选的属性n或没有属性n

例子

interface A {
  a: number;
}
interface B {
  b: string;
}

function foo(x: A | B) {
  if ('a' in x) {
    return x.a;
  }
  return x.b; // 此时x的类型推断为B, 属性a不存在
}

使用标记--esModuleInterop引入非 ES 模块

在 TypeScript 2.7 使用--esModuleInterop标记后,为CommonJS/AMD/UMD模块生成基于__esModule指示器的命名空间记录。这次更新使得 TypeScript 编译后的输出与 Babel 的输出更加接近。

之前版本中,TypeScript 处理CommonJS/AMD/UMD模块的方式与处理 ES6 模块一致,导致了一些问题,比如:

  • TypeScript 之前处理 CommonJS/AMD/UMD 模块的命名空间导入(如import * as foo from "foo")时等同于const foo = require("foo")。这样做很简单,但如果引入的主要对象(比如这里的 foo)是基本类型、类或者函数,就有问题。ECMAScript 标准规定了命名空间记录是一个纯粹的对象,并且引入的命名空间(比如前面的foo)应该是不可调用的,然而在 TypeScript 却中可以。
  • 同样地,一个 CommonJS/AMD/UMD 模块的默认导入(如import d from "foo")被处理成等同于 const d = require("foo").default的形式。然而现在大多数可用的 CommonJS/AMD/UMD 模块并没有默认导出,导致这种引入语句在实践中不适用于非 ES 模块。比如 import fs from "fs" or import express from "express" 都不可用。

在使用标签--esModuleInterop后,这两个问题都得到了解决:

  • 命名空间导入(如import * as foo from "foo")的对象现在被修正为不可调用的。调用会报错。
  • 对 CommonJS/AMD/UMD 模块可以使用默认导入(如import d from "foo")且能正常工作了。

注: 这个新特性有可能对现有的代码产生破坏,因此以标记的方式引入。但无论是新项目还是之前的项目,我们都强烈建议使用它。对于之前的项目,命名空间导入 (import * as express from "express"; express();) 需要被改写成默认引入 (import express from "express"; express();).

例子

使用 --esModuleInterop 后,会生成两个新的辅助量 __importStar and __importDefault ,分别对应导入*和导入default,比如这样的输入:

import * as foo from 'foo';
import b from 'bar';

会生成:

'use strict';
var __importStar =
  (this && this.__importStar) ||
  function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null)
      for (var k in mod)
        if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
    result['default'] = mod;
    return result;
  };
var __importDefault =
  (this && this.__importDefault) ||
  function (mod) {
    return mod && mod.__esModule ? mod : { default: mod };
  };
exports.__esModule = true;
var foo = __importStar(require('foo'));
var bar_1 = __importDefault(require('bar'));

数字分隔符

TypeScript 2.7 支持 ECMAScript 的数字分隔符提案。 这个特性允许用户在数字之间使用下划线_来对数字分组。

const million = 1_000_000;
const phone = 555_734_2231;
const bytes = 0xff_0c_00_ff;
const word = 0b1100_0011_1101_0001;

--watch 模式下具有更简洁的输出

在 TypeScript 的--watch模式下进行重新编译后会清屏。 这样就更方便阅读最近这次编译的输出信息。

更漂亮的--pretty输出

TypeScript 的--pretty标记可以让错误信息更易阅读和管理。 我们对这个功能进行了两个主要的改进。 首先,--pretty对文件名,诊段代码和行数添加了颜色(感谢 Joshua Goldberg)。 其次,格式化了文件名和位置,以便于在常用的终端里使用 Ctrl+Click,Cmd+Click,Alt+Click 等来跳转到编译器里的相应位置。

TypeScript 2.6

严格函数类型

TypeScript 2.6 引入了新的类型检查选项,--strictFunctionTypes--strictFunctionTypes选项是--strict系列选项之一,也就是说 --strict模式下它默认是启用的。你可以通过在命令行或 tsconfig.json 中设置--strictFunctionTypes false来单独禁用它。

--strictFunctionTypes启用时,函数类型参数的检查是*抗变(contravariantly)而非双变(bivariantly)*的。关于变体 (variance) 对于函数类型意义的相关背景,请查看协变(covariance)和抗变(contravariance)是什么?

这一更严格的检查应用于除方法或构造函数声明以外的所有函数类型。方法被专门排除在外是为了确保带泛型的类和接口(如Array<T>)总体上仍然保持协变。

考虑下面这个 Animal 是 Dog 和 Cat 的父类型的例子:

declare let f1: (x: Animal) => void;
declare let f2: (x: Dog) => void;
declare let f3: (x: Cat) => void;
f1 = f2; // 启用 --strictFunctionTypes 时错误
f2 = f1; // 正确
f2 = f3; // 错误

第一个赋值语句在默认的类型检查模式中是允许的,但是在严格函数类型模式下会被标记错误。 通俗地讲,默认模式允许这么赋值,因为它可能是合理的,而严格函数类型模式将它标记为错误,因为它不能被证明合理。 任何一种模式中,第三个赋值都是错误的,因为它永远不合理。

用另一种方式来描述这个例子则是,默认类型检查模式中T在类型(x: T) => void双变的(也即协变抗变),但在严格函数类型模式中T抗变的。

例子

interface Comparer<T> {
  compare: (a: T, b: T) => number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer; // 错误
dogComparer = animalComparer; // 正确

现在第一个赋值是错误的。更明确地说,Comparer<T>中的T因为仅在函数类型参数的位置被使用,是抗变的。

另外,注意尽管有的语言(比如 C#和 Scala)要求变体标注(variance annotations)(out/in+/-),而由于 TypeScript 的结构化类型系统,它的变体是由泛型中的类型参数的实际使用自然得出的。

注意:

启用--strictFunctionTypes时,如果compare被声明为方法,则第一个赋值依然是被允许的。 更明确的说,Comparer<T>中的T因为仅在方法参数的位置被使用所以是双变的。

interface Comparer<T> {
  compare(a: T, b: T): number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer; // 正确,因为双变
dogComparer = animalComparer; // 正确

TypeScript 2.6 还改进了与抗变位置相关的类型推导:

function combine<T>(...funcs: ((x: )=> void)[]): (x: T) => void {
    return x => {
        for (const f of funcs) f(x);
    }
}

function animalFunc(x: Animal) {}
function dogFunc(x: Dog) {}

let combined = combine(animalFunc,dogFunc);  // (x: Dog) => void

这上面所有T的推断都来自抗变的位置,由此我们得出T最普遍子类型。 这与从协变位置推导出的结果恰恰相反,从协变位置我们得出的是最普遍超类型

缓存模块中的标签模板对象

TypeScript 2.6 修复了标签字符串模板的输出,以更好地遵循 ECMAScript 标准。 根据ECMAScript 标准,每一次获取模板标签的值时,应该将同一个模板字符串数组对象 (同一个 TemplateStringArray) 作为第一个参数传递。 在 TypeScript 2.6 之前,每一次生成的都是全新的模板对象。 虽然字符串的内容是一样的,这样的输出会影响通过识别字符串来实现缓存失效的库,比如 lit-html

例子

export function id(x: TemplateStringsArray) {
  return x;
}

export function templateObjectFactory() {
  return id`hello world`;
}

let result = templateObjectFactory() === templateObjectFactory(); // TS 2.6 为 true

编译后的代码:

'use strict';
var __makeTemplateObject =
  (this && this.__makeTemplateObject) ||
  function (cooked, raw) {
    if (Object.defineProperty) {
      Object.defineProperty(cooked, 'raw', { value: raw });
    } else {
      cooked.raw = raw;
    }
    return cooked;
  };

function id(x) {
  return x;
}

var _a;
function templateObjectFactory() {
  return id(
    _a || (_a = __makeTemplateObject(['hello world'], ['hello world']))
  );
}

var result = templateObjectFactory() === templateObjectFactory();

注意:这一改变引入了新的工具函数,__makeTemplateObject; 如果你在搭配使用--importHelperstslib,需要更新到 1.8 或更高版本。

本地化的命令行诊断消息

TypeScript 2.6 npm 包加入了 13 种语言的诊断消息本地化版本。 命令行中本地化消息会在使用--locale选项时显示。

例子

俄语显示的错误消息:

c:\ts>tsc --v
Version 2.6.1

c:\ts>tsc --locale ru --pretty c:\test\a.ts

../test/a.ts(1,5): error TS2322: Тип ""string"" не может быть назначен для типа "number".

1 var x: number = "string";
      ~

中文显示的帮助信息:

PS C:\ts> tsc --v
Version 2.6.1

PS C:\ts> tsc --locale zh-cn
版本 2.6.1
语法:tsc [选项] [文件 ...]

示例:tsc hello.ts
    tsc --outFile file.js file.ts
    tsc @args.txt

选项:
 -h, --help                    打印此消息。
 --all                         显示所有编译器选项。
 -v, --version                 打印编译器的版本。
 --init                        初始化 TypeScript 项目并创建 tsconfig.json 文件。
 -p 文件或目录, --project 文件或目录     编译给定了其配置文件路径或带 "tsconfig.json" 的文件夹路径的项目。
 --pretty                      使用颜色和上下文风格化错误和消息(实验)。
 -w, --watch                   监视输入文件。
 -t 版本, --target 版本            指定 ECMAScript 目标版本:"ES3"(默认)、"ES5"、"ES2015"、"ES2016"、"ES2017" 或 "ESNEXT"。
 -m 种类, --module 种类            指定模块代码生成:"none"、"commonjs"、"amd"、"system"、"umd"、"es2015"或 "ESNext"。
 --lib                         指定要在编译中包括的库文件:
                                 'es5' 'es6' 'es2015' 'es7' 'es2016' 'es2017' 'esnext' 'dom' 'dom.iterable' 'webworker' 'scripthost' 'es2015.core' 'es2015.collection' 'es2015.generator' 'es2015.iterable' 'es2015.promise' 'es2015.proxy' 'es2015.reflect' 'es2015.symbol' 'es2015.symbol.wellknown' 'es2016.array.include' 'es2017.object' 'es2017.sharedmemory' 'es2017.string' 'es2017.intl' 'esnext.asynciterable'
 --allowJs                     允许编译 JavaScript 文件。
 --jsx 种类                      指定 JSX 代码生成:"preserve"、"react-native" 或 "react"。 -d, --declaration             生成相应的 ".d.ts" 文件。
 --sourceMap                   生成相应的 ".map" 文件。
 --outFile 文件                  连接输出并将其发出到单个文件。
 --outDir 目录                   将输出结构重定向到目录。
 --removeComments              请勿将注释发出到输出。
 --noEmit                      请勿发出输出。
 --strict                      启用所有严格类型检查选项。
 --noImplicitAny               对具有隐式 "any" 类型的表达式和声明引发错误。
 --strictNullChecks            启用严格的 NULL 检查。
 --strictFunctionTypes         对函数类型启用严格检查。
 --noImplicitThis              在带隐式"any" 类型的 "this" 表达式上引发错误。
 --alwaysStrict                以严格模式进行分析,并为每个源文件发出 "use strict" 指令。
 --noUnusedLocals              报告未使用的局部变量上的错误。
 --noUnusedParameters          报告未使用的参数上的错误。
 --noImplicitReturns           在函数中的所有代码路径并非都返回值时报告错误。
 --noFallthroughCasesInSwitch  报告 switch 语句中遇到 fallthrough 情况的错误。
 --types                       要包含在编译中类型声明文件。
 @<文件>                         从文件插入命令行选项和文件。

通过 '// @ts-ignore' 注释隐藏 .ts 文件中的错误

TypeScript 2.6 支持在.ts 文件中通过在报错一行上方使用// @ts-ignore来忽略错误。

例子

if (false) {
  // @ts-ignore:无法被执行的代码的错误
  console.log('hello');
}

// @ts-ignore注释会忽略下一行中产生的所有错误。 建议实践中在@ts-ignore之后添加相关提示,解释忽略了什么错误。

请注意,这个注释仅会隐藏报错,并且我们建议你极少使用这一注释。

更快的 tsc --watch

TypeScript 2.6 带来了更快的--watch实现。 新版本优化了使用 ES 模块的代码的生成和检查。 在一个模块文件中检测到的改变会使改变的模块,以及依赖它的文件被重新生成,而不再是整个项目。 有大量文件的项目应该从这一改变中获益最多。

这一新的实现也为 tsserver 中的监听带来了性能提升。 监听逻辑被完全重写以更快响应改变事件。

只写的引用现在会被标记未使用

TypeScript 2.6 加入了修正的--noUnusedLocals--noUnusedParameters编译选项实现。 只被写但从没有被读的声明现在会被标记未使用。

例子

下面nm都会被标记为未使用,因为它们的值从未被读取。之前 TypeScript 只会检查它们的值是否被引用

function f(n: number) {
  n = 0;
}

class C {
  private m: number;
  constructor() {
    this.m = 0;
  }
}

另外仅被自己内部调用的函数也会被认为是未使用的。

例子

function f() {
  f(); // 错误:'f' 被声明,但它的值从未被使用
}

TypeScript 2.5

可选的catch语句变量

得益于@tinganho所做的工作,TypeScript 2.5 实现了一个新的 ECMAScript 特性,允许用户省略catch语句中的变量。 例如,当使用JSON.parse时,你可能需要将对应的函数调用放在try / catch中,但是最后可能并不会用到输入有误时会抛出的SyntaxError(语法错误)。

let input = '...';
try {
  JSON.parse(input);
} catch {
  // ^ 注意我们的 `catch` 语句并没有声明一个变量
  console.log('传入的 JSON 不合法\n\n' + input);
}

checkJs/@ts-check 模式中的类型断言/转换语法

TypeScript 2.5 引入了在使用纯 JavaScript 的项目中断言表达式类型的能力。对应的语法是/** @type {...} */标注注释后加上被圆括号括起来,类型需要被重新演算的表达式。举例:

var x = /** @type {SomeType} */ AnyParenthesizedExpression;

包去重和重定向

在 TypeScript 2.5 中使用Node模块解析策略进行导入时,编译器现在会检查文件是否来自 "相同" 的包。如果一个文件所在的包的package.json包含了与之前读取的包相同的nameversion,那么 TypeScript 会将它重定向到最顶层的包。这可以解决两个包可能会包含相同的类声明,但因为包含private成员导致他们在结构上不兼容的问题.

这也带来一个额外的好处,可以通过避免从重复的包中加载.d.ts文件减少内存使用和编译器及语言服务的运行时计算.

--preserveSymlinks(保留符号链接)编译器选项

TypeScript 2.5 带来了preserveSymlinks选项,它对应了Node.js 中 --preserve-symlinks选项的行为。这一选项也会带来和 Webpack 的resolve.symlinks选项相反的行为(也就是说,将 TypeScript 的preserveSymlinks选项设置为true对应了将 Webpack 的resolve.symlinks选项设为false,反之亦然)。

在这一模式中,对于模块和包的引用(比如import语句和/// <reference type=".." />指令)都会以相对符号链接文件的位置被解析,而不是相对于符号链接解析到的路径。更具体的例子,可以参考Node.js 网站的文档

TypeScript 2.4

动态导入表达式

动态的import表达式是一个新特性,它属于 ECMAScript 的一部分,允许用户在程序的任何位置异步地请求某个模块。

这意味着你可以有条件地延迟加载其它模块和库。 例如下面这个async函数,它仅在需要的时候才导入工具库:

async function getZipFile(name: string, files: File[]): Promise<File> {
  const zipUtil = await import('./utils/create-zip-file');
  const zipContents = await zipUtil.getContentAsBlob(files);
  return new File(zipContents, name);
}

许多 bundlers 工具已经支持依照这些import表达式自动地分割输出,因此可以考虑使用这个新特性并把输出模块目标设置为esnext

字符串枚举

TypeScript 2.4 现在支持枚举成员变量包含字符串构造器。

enum Colors {
  Red = 'RED',
  Green = 'GREEN',
  Blue = 'BLUE',
}

需要注意的是字符串枚举成员不能被反向映射到枚举成员的名字。 换句话说,你不能使用Colors["RED"]来得到"Red"

增强的泛型推断

TypeScript 2.4 围绕着泛型的推断方式引入了一些很棒的改变。

返回类型作为推断目标

其一,TypeScript 能够推断调用的返回值类型。 这可以优化你的体验和方便捕获错误。 如下所示:

function arrayMap<T, U>(f: (x: T) => U): (a: T[]) => U[] {
  return a => a.map(f);
}

const lengths: (a: string[]) => number[] = arrayMap(s => s.length);

下面是一个你可能会见到的出错了的例子:

let x: Promise<string> = new Promise(resolve => {
  resolve(10);
  //      ~~ Error!
});

从上下文类型中推断类型参数

在 TypeScript 2.4 之前,在下面的例子里:

let f: <T>(x: T) => T = y => y;

y将会具有any类型。 这意味着虽然程序会检查类型,但是你却可以使用y做任何事情,就比如:

let f: <T>(x: T) => T = y => y() + y.foo.bar;

这个例子实际上并不是类型安全的。

在 TypeScript 2.4 里,右手边的函数会隐式地获得类型参数,并且y的类型会被推断为那个类型参数的类型。

如果你使用y的方式是这个类型参数所不支持的,那么你会得到一个错误。 在这个例子里,T的约束是{}(隐式地),所以在最后一个例子里会出错。

对泛型函数进行更严格的检查

TypeScript 在比较两个单一签名的类型时会尝试统一类型参数。 因此,在涉及到两个泛型签名的时候会进行更严格的检查,这就可能发现一些 bugs。

type A = <T, U>(x: T, y: U) => [T, U];
type B = <S>(x: S, y: S) => [S, S];

function f(a: A, b: B) {
  a = b; // Error
  b = a; // Ok
}

回调参数的严格抗变

TypeScript 一直是以双变(bivariant)的方式来比较参数。 这样做有很多原因,总体上来说这不会有什么大问题直到我们发现它应用在PromiseObservable上时有些副作用。

TypeScript 2.4 在处理两个回调类型时引入了收紧机制。例如:

interface Mappable<T> {
  map<U>(f: (x: T) => U): Mappable<U>;
}

declare let a: Mappable<number>;
declare let b: Mappable<string | number>;

a = b;
b = a;

在 TypeScript 2.4 之前,它会成功执行。 当关联map的类型时,TypeScript 会双向地关联它们的类型(例如f的类型)。 当关联每个f的类型时,TypeScript 也会双向地关联那些参数的类型。

TS 2.4 里关联map的类型时,TypeScript 会检查是否每个参数都是回调类型,如果是的话,它会确保那些参数根据它所在的位置以抗变(contravariant)地方式进行检查。

换句话说,TypeScript 现在可以捕获上面的 bug,这对某些用户来说可能是一个破坏性改动,但却是非常帮助的。

弱类型(Weak Type)探测

TypeScript 2.4 引入了“弱类型”的概念。 任何只包含了可选属性的类型被当作是“weak”。 比如,下面的Options类型是弱类型:

interface Options {
  data?: string;
  timeout?: number;
  maxRetries?: number;
}

在 TypeScript 2.4 里给弱类型赋值时,如果这个值的属性与弱类型的属性没有任何重叠属性时会得到一个错误。 比如:

function sendMessage(options: Options) {
  // ...
}

const opts = {
  payload: 'hello world!',
  retryOnFail: true,
};

// 错误!
sendMessage(opts);
// 'opts' 和 'Options' 没有重叠的属性
// 可能我们想要用'data'/'maxRetries'来代替'payload'/'retryOnFail'

因为这是一个破坏性改动,你可能想要知道一些解决方法:

  1. 确定属性存在时再声明
  2. 给弱类型增加索引签名(比如 [propName: string]: {}
  3. 使用类型断言(比如opts as Options

TypeScript 2.3

ES5/ES3 的生成器和迭代支持

首先是一些 ES2016 的术语:

迭代器

ES2015 引入了Iterator(迭代器),它表示提供了 next,return,以及 throw 三个方法的对象,具体满足以下接口:

interface Iterator<T> {
  next(value?: any): IteratorResult<T>;
  return?(value?: any): IteratorResult<T>;
  throw?(e?: any): IteratorResult<T>;
}

这种迭代器对于迭代可用的值时很有用,比如数组的元素或者 Map 的键。如果一个对象有一个返回Iterator对象的Symbol.iterator方法,那么我们说这个对象是“可迭代的”。

迭代器协议还定义了一些 ES2015 中的特性像for..of和展开运算符以及解构赋值中的数组的剩余运算的操作对象。

生成器

ES2015 也引入了"生成器",生成器是可以通过Iterator接口和yield关键字被用来生成部分运算结果的函数。生成器也可以在内部通过yield*代理对与其他可迭代对象的调用。举例来说:

function* f() {
  yield 1;
  yield* [2, 3];
}

新的--downlevelIteration编译选项

之前迭代器只在编译目标为 ES6/ES2015 或者更新版本时可用。此外,设计迭代器协议的结构,比如for..of,如果编译目标低于 ES6/ES2015,则只能在操作数组时被支持。

TypeScript 2.3 在 ES3 和 ES5 为编译目标时由--downlevelIteration编译选项增加了完整的对生成器和迭代器协议的支持。

通过--downlevelIteration编译选项,编译器会使用新的类型检查和输出行为,尝试调用被迭代对象的[Symbol.iterator]()方法 (如果有),或者在对象上创建一个语义上的数组迭代器。

注意这需要非数组的值有原生的Symbol.iterator或者Symbol.iterator的运行时模拟实现。

使用--downlevelIteration时,在 ES5/ES3 中for..of语句、数组解构、数组中的元素展开、函数调用、new 表达式在支持Symbol.iterator时可用,但即便没有定义Symbol.iterator,它们在运行时或开发时都可以被使用到数组上.

异步迭代

TypeScript 2.3 添加了对异步迭代器和生成器的支持,描述见当前的TC39 提案

异步迭代器

异步迭代引入了AsyncIterator,它和Iterator相似。实际上的区别在于AsyncIteratornextreturnthrow方法的返回的是迭代结果的Promise,而不是结果本身。这允许AsyncIterator在生成值之前的时间点就加入异步通知。AsyncIterator的接口如下:

interface AsyncIterator<T> {
  next(value?: any): Promise<IteratorResult<T>>;
  return?(value?: any): Promise<IteratorResult<T>>;
  throw?(e?: any): Promise<IteratorResult<T>>;
}

一个支持异步迭代的对象如果有一个返回AsyncIterator对象的Symbol.asyncIterator方法,被称作是“可迭代的”。

异步生成器

异步迭代提案引入了“异步生成器”,也就是可以用来生成部分计算结果的异步函数。异步生成器也可以通过yield*代理对可迭代对象或异步可迭代对象的调用:

async function* g() {
  yield 1;
  await sleep(100);
  yield* [2, 3];
  yield* (async function* () {
    await sleep(100);
    yield 4;
  })();
}

和生成器一样,异步生成器只能是函数声明,函数表达式,或者类或对象字面量的方法。箭头函数不能作为异步生成器。异步生成器除了一个可用的Symbol.asyncIterator引用外 (原生或三方实现),还需要一个可用的全局Promise实现(既可以是原生的,也可以是 ES2015 兼容的实现)。

for-await-of语句

最后,ES2015 引入了for..of语句来迭代可迭代对象。相似的,异步迭代提案引入了for..await..of语句来迭代可异步迭代的对象。

async function f() {
  for await (const x of g()) {
    console.log(x);
  }
}

for..await..of语句仅在异步函数或异步生成器中可用。

注意事项

  • 始终记住我们对于异步迭代器的支持是建立在运行时有Symbol.asyncIterator支持的基础上的。你可能需要Symbol.asyncIterator的三方实现,虽然对于简单的目的可以仅仅是:(Symbol as any).asyncIterator = Symbol.asyncIterator || Symbol.for("Symbol.asyncIterator");
  • 如果你没有声明AsyncIterator,还需要在--lib选项中加入esnext来获取AsyncIterator声明。
  • 最后, 如果你的编译目标是 ES5 或 ES3,你还需要设置--downlevelIterators编译选项。

泛型参数默认类型

TypeScript 2.3 增加了对声明泛型参数默认类型的支持。

示例

考虑一个会创建新的HTMLElement的函数,调用时不加参数会生成一个Div,你也可以选择性地传入子元素的列表。之前你必须这么去定义:

declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;
declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;
declare function create<T extends HTMLElement, U extends HTMLElement>(
  element: T,
  children: U[]
): Container<T, U[]>;

有了泛型参数默认类型,我们可以将定义化简为:

declare function create<T extends HTMLElement = HTMLDivElement, U = T[]>(
  element?: T,
  children?: U
): Container<T, U>;

泛型参数的默认类型遵循以下规则:

  • 有默认类型的类型参数被认为是可选的。
  • 必选的类型参数不能在可选的类型参数后。
  • 如果类型参数有约束,类型参数的默认类型必须满足这个约束。
  • 当指定类型实参时,你只需要指定必选类型参数的类型实参。 未指定的类型参数会被解析为它们的默认类型。
  • 如果指定了默认类型,且类型推断无法选择一个候选类型,那么将使用默认类型作为推断结果。
  • 一个被现有类或接口合并的类或者接口的声明可以为现有类型参数引入默认类型。
  • 一个被现有类或接口合并的类或者接口的声明可以引入新的类型参数,只要它指定了默认类型。

新的--strict主要编译选项

TypeScript 加入的新检查项为了避免不兼容现有项目通常都是默认关闭的。虽然避免不兼容是好事,但这个策略的一个弊端则是使配置最高类型安全越来越复杂,这么做每次 TypeScript 版本发布时都需要显示地加入新选项。有了--strict编译选项,就可以选择最高级别的类型安全(了解随着更新版本的编译器增加了增强的类型检查特性可能会报新的错误)。

新的--strict编译器选项包含了一些建议配置的类型检查选项。具体来说,指定--strict相当于是指定了以下所有选项(未来还可能包括更多选项):

  • --strictNullChecks
  • --noImplicitAny
  • --noImplicitThis
  • --alwaysStrict

确切地说,--strict编译选项会为以上列出的编译器选项设置默认值。这意味着还可以单独控制这些选项。比如:

--strict --noImplicitThis false

这将是开启除--noImplicitThis编译选项以外的所有严格检查选项。使用这个方式可以表述除某些明确列出的项以外的所有严格检查项。换句话说,现在可以在默认最高级别的类型安全下排除部分检查。

从 TypeScript 2.3 开始,tsc --init生成的默认tsconfig.json"compilerOptions"中包含了"strict: true"设置。这样一来,用tsc --init创建的新项目默认会开启最高级别的类型安全。

改进的--init输出

除了默认的--strict设置外,tsc --init还改进了输出。tsc --init默认生成的tsconfig.json文件现在包含了一些带描述的被注释掉的常用编译器选项. 你可以去掉相关选项的注释来获得期望的结果。我们希望新的输出能简化新项目的配置并且随着项目成长保持配置文件的可读性。

--checkJS选项下 .js 文件中的错误

即便使用了--allowJs,TypeScript 编译器默认不会报 .js 文件中的任何错误。TypeScript 2.3 中使用--checkJs选项,.js文件中的类型检查错误也可以被报出.

你可以通过为它们添加// @ts-nocheck注释来跳过对某些文件的检查,反过来你也可以选择通过添加// @ts-check注释只检查一些.js文件而不需要设置--checkJs编译选项。你也可以通过添加// @ts-ignore到特定行的一行前来忽略这一行的错误.

.js文件仍然会被检查确保只有标准的 ECMAScript 特性,类型标注仅在.ts文件中被允许,在.js中会被标记为错误。JSDoc 注释可以用来为你的 JavaScript 代码添加某些类型信息,更多关于支持的 JSDoc 结构的详情,请浏览JSDoc 支持文档

有关详细信息,请浏览类型检查 JavaScript 文件文档

TypeScript 2.2

支持混合类

TypeScript 2.2 增加了对 ECMAScript 2015 混合类模式 (见MDN 混合类的描述JavaScript 类的"真"混合了解更多) 以及使用交叉来类型表达结合混合构造函数的签名及常规构造函数签名的规则.

首先是一些术语

混合构造函数类型指仅有单个构造函数签名,且该签名仅有一个类型为 any[] 的变长参数,返回值为对象类型. 比如, 有 X 为对象类型, new (...args: any[]) => X 是一个实例类型为 X 的混合构造函数类型。

混合类指一个extends(扩展)了类型参数类型的表达式的类声明或表达式. 以下规则对混合类声明适用:

  • extends表达式的类型参数类型必须是混合构造函数.
  • 混合类的构造函数 (如果有) 必须有且仅有一个类型为any[]的变长参数, 并且必须使用展开运算符在super(...args)调用中将这些参数传递。

假设有类型参数为T且约束为X的表达式Bas,处理混合类class C extends Base {...}时会假设BaseX类型,处理结果为交叉类型typeof C & T。换言之,一个混合类被表达为混合类构造函数类型与参数基类构造函数类型的交叉类型.

在获取一个包含了混合构造函数类型的交叉类型的构造函数签名时,混合构造函数签名会被丢弃,而它们的实例类型会被混合到交叉类型中其他构造函数签名的返回类型中. 比如,交叉类型{ new(...args: any[]) => A } & { new(s: string) => B }仅有一个构造函数签名new(s: string) => A & B

将以上规则放到一个例子中

class Point {
  constructor(public x: number, public y: number) {}
}

class Person {
  constructor(public name: string) {}
}

type Constructor<T> = new (...args: any[]) => T;

function Tagged<T extends Constructor<{}>>(Base: T) {
  return class extends Base {
    _tag: string;
    constructor(...args: any[]) {
      super(...args);
      this._tag = '';
    }
  };
}

const TaggedPoint = Tagged(Point);

let point = new TaggedPoint(10, 20);
point._tag = 'hello';

class Customer extends Tagged(Person) {
  accountBalance: number;
}

let customer = new Customer('Joe');
customer._tag = 'test';
customer.accountBalance = 0;

混合类可以通过在类型参数中限定构造函数签名的返回值类型来限制它们可以被混入的类的类型。举例来说,下面的WithLocation函数实现了一个为满足Point接口 (也就是有类型为numberxy属性)的类添加getLocation方法的子类工厂。

interface Point {
  x: number;
  y: number;
}

const WithLocation = <T extends Constructor<Point>>(Base: T) =>
  class extends Base {
    getLocation(): [number, number] {
      return [this.x, this.y];
    }
  };

object类型

TypeScript 没有表示非基本类型的类型,即不是number | string | boolean | symbol | null | undefined的类型。一个新的object类型登场。

使用object类型,可以更好地表示类似Object.create这样的 API。例如:

declare function create(o: object | null): void;

create({ prop: 0 }); // OK
create(null); // OK

create(42); // Error
create('string'); // Error
create(false); // Error
create(undefined); // Error

支持new.target

new.target元属性是 ES2015 引入的新语法。当通过new构造函数创建实例时,new.target的值被设置为对最初用于分配实例的构造函数的引用。如果一个函数不是通过new构造而是直接被调用,那么new.target的值被设置为undefined

当在类的构造函数中需要设置Object.setPrototypeOf__proto__时,new.target就派上用场了。在 NodeJS v4 及更高版本中继承Error类就是这样的使用案例。

示例

class CustomError extends Error {
  constructor(message?: string) {
    super(message); // 'Error' breaks prototype chain here
    Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain
  }
}

生成 JS 代码:

var CustomError = (function (_super) {
  __extends(CustomError, _super);
  function CustomError() {
    var _newTarget = this.constructor;
    var _this = _super.apply(this, arguments); // 'Error' breaks prototype chain here
    _this.__proto__ = _newTarget.prototype; // restore prototype chain
    return _this;
  }
  return CustomError;
})(Error);

new.target 也适用于编写可构造的函数,例如:

function f() {
  if (new.target) {
    /* called via 'new' */
  }
}

编译为:

function f() {
  var _newTarget = this && this instanceof f ? this.constructor : void 0;
  if (_newTarget) {
    /* called via 'new' */
  }
}

更好地检查表达式的操作数中的null / undefined

TypeScript 2.2 改进了对表达式中可空操作数的检查。具体来说,这些现在被标记为错误:

  • 如果+运算符的任何一个操作数是可空的,并且两个操作数都不是anystring类型。
  • 如果-***/<<>>>>>, &, |^运算符的任何一个操作数是可空的。
  • 如果<><=>=in运算符的任何一个操作数是可空的。
  • 如果instanceof运算符的右操作数是可空的。
  • 如果一元运算符+-~++或者--的操作数是可空的。

如果操作数的类型是nullundefined或者包含nullundefined的联合类型,则操作数视为可空的。注意:包含nullundefined的联合类型只会出现在--strictNullChecks模式中,因为常规类型检查模式下nullundefined在联合类型中是不存在的。

字符串索引签名类型的点属性

具有字符串索引签名的类型可以使用[]符号访问,但不允许使用.符号访问。从 TypeScript 2.2 开始两种方式都允许使用。

interface StringMap<T> {
  [x: string]: T;
}

const map: StringMap<number>;

map['prop1'] = 1;
map.prop2 = 2;

这仅适用于具有显式字符串索引签名的类型。在类型使用上使用.符号访问未知属性仍然是一个错误。

支持在 JSX 子元素上使用扩展运算符

TypeScript 2.2 增加了对在 JSX 子元素上使用扩展运算符的支持。更多详情请看facebook/jsx#57

示例

function Todo(prop: { key: number; todo: string }) {
  return <div>{prop.key.toString() + prop.todo}</div>;
}

function TodoList({ todos }: TodoListProps) {
  return (
    <div>{...todos.map(todo => <Todo key={todo.id} todo={todo.todo} />)}</div>
  );
}

let x: TodoListProps;

<TodoList {...x} />;

新的jsx: react-native

React-native 构建管道期望所有文件都具有.js 扩展名,即使该文件包含 JSX 语法。新的--jsx编译参数值react-native将在输出文件中坚持 JSX 语法,但是给它一个.js扩展名。

TypeScript 2.1

keyof和查找类型

在 JavaScript 中属性名称作为参数的 API 是相当普遍的,但是到目前为止还没有表达在那些 API 中出现的类型关系。

输入索引类型查询或keyof,索引类型查询keyof T产生的类型是T的属性名称。keyof T的类型被认为是string的子类型。

示例

interface Person {
  name: string;
  age: number;
  location: string;
}

type K1 = keyof Person; // "name" | "age" | "location"
type K2 = keyof Person[]; // "length" | "push" | "pop" | "concat" | ...
type K3 = keyof { [x: string]: Person }; // string

与之相对应的是索引访问类型,也称为查找类型。在语法上,它们看起来像元素访问,但是写成类型:

示例

type P1 = Person['name']; // string
type P2 = Person['name' | 'age']; // string | number
type P3 = string['charAt']; // (pos: number) => string
type P4 = string[]['push']; // (...items: string[]) => number
type P5 = string[][0]; // string

你可以将这种模式和类型系统的其它部分一起使用,以获取类型安全的查找。

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key]; // 推断类型是T[K]
}

function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]) {
  obj[key] = value;
}

let x = { foo: 10, bar: 'hello!' };

let foo = getProperty(x, 'foo'); // number
let bar = getProperty(x, 'bar'); // string

let oops = getProperty(x, 'wargarbl'); // 错误!"wargarbl"不存在"foo" | "bar"中

setProperty(x, 'foo', 'string'); // 错误!, 类型是number而非string

映射类型

一个常见的任务是使用现有类型并使其每个属性完全可选。假设我们有一个Person

interface Person {
  name: string;
  age: number;
  location: string;
}

Person的可选属性类型将是这样:

interface PartialPerson {
  name?: string;
  age?: number;
  location?: string;
}

使用映射类型,PartialPerson可以写成是Person类型的广义变换:

type Partial<T> = {
  [P in keyof T]?: T[P];
};

type PartialPerson = Partial<Person>;

映射类型是通过使用字面量类型的集合而生成的,并为新对象类型计算一组属性。它们就像Python 中的列表推导式,但不是在列表中产生新的元素,而是在类型中产生新的属性。

Partial外,映射类型可以表示许多有用的类型转换:

// 保持类型相同,但每个属性是只读的。
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

// 相同的属性名称,但使值是一个Promise,而不是一个具体的值
type Deferred<T> = {
  [P in keyof T]: Promise<T[P]>;
};

// 为T的属性添加代理
type Proxify<T> = {
  [P in keyof T]: { get(): T[P]; set(v: T[P]): void };
};

Partial,Readonly,RecordPick

PartialReadonly,如前所述,是非常有用的结构。你可以使用它们来描述像一些常见的 JS 程序:

function assign<T>(obj: T, props: Partial<T>): void;
function freeze<T>(obj: T): Readonly<T>;

因此,它们现在默认包含在标准库中。

我们还包括两个其他实用程序类型:RecordPick

// 从T中选取一组属性K
declare function pick<T, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K>;

const nameAndAgeOnly = pick(person, 'name', 'age'); // { name: string, age: number }
// 对于类型T的每个属性K,将其转换为U
function mapObject<K extends string | number, T, U>(
  obj: Record<K, T>,
  f: (x: T) => U
): Record<K, U>;

const names = { foo: 'hello', bar: 'world', baz: 'bye' };
const lengths = mapObject(names, s => s.length); // { foo: number, bar: number, baz: number }

对象扩展运算符和 rest 运算符

TypeScript 2.1 带来了ESnext 扩展运算符和 rest 运算符的支持。

类似于数组扩展,展开对象可以方便得到浅拷贝:

let copy = { ...original };

同样,您可以合并几个不同的对象。在以下示例中,合并将具有来自foobarbaz的属性。

let merged = { ...foo, ...bar, ...baz };

还可以重写现有属性并添加新属性.:

let obj = { x: 1, y: 'string' };
var newObj = { ...obj, z: 3, y: 4 }; // { x: number, y: number, z: number }

指定展开操作的顺序确定哪些属性在最终的结果对象中。相同的属性,后面的属性会“覆盖”前面的属性。

与对象扩展运算符相对的是对象 rest 运算符,因为它可以提取解构元素中剩余的元素:

let obj = { x: 1, y: 1, z: 1 };
let { z, ...obj1 } = obj;
obj1; // {x: number, y: number};

低版本异步函数

该特性在 TypeScript 2.1 之前就已经支持了,但是只能编译为 ES6 或者 ES2015。TypeScript 2.1 使其该特性可以在 ES3 和 ES5 运行时上使用,这意味着无论您使用什么环境,都可以使用它。

注:首先,我们需要确保我们的运行时提供全局的 ECMAScript 兼容性Promise。这可能需要获取Promisepolyfill,或者依赖运行时的版本。我们还需要通过设置lib编译参数,比如"dom","es2015""dom","es2015.promise","es5"来确保 TypeScript 知道Promise可用。

示例

tsconfig.json

{
    "compilerOptions": {
        "lib": ["dom", "es2015.promise", "es5"]
    }
}

dramaticWelcome.ts

function delay(milliseconds: number) {
  return new Promise<void>(resolve => {
    setTimeout(resolve, milliseconds);
  });
}

async function dramaticWelcome() {
  console.log('Hello');

  for (let i = 0; i < 3; i++) {
    await delay(500);
    console.log('.');
  }

  console.log('World!');
}

dramaticWelcome();

编译和运行输出应该会在 ES3/ES5 引擎上产生正确的行为。

支持外部辅助库(tslib

TypeScript 注入了一些辅助函数,如继承_extends、JSX 中的展开运算符__assign和异步函数__awaiter

以前有两个选择:

  1. 每一个需要辅助库的文件都注入辅助库或者
  2. 使用--noEmitHelpers编译参数完全不使用辅助库。

这两项还有待改进。将帮助文件捆绑在每个文件中对于试图保持其包尺寸小的客户而言是一个痛点。不使用辅助库,那么客户就必须自己维护辅助库。

TypeScript 2.1 允许这些辅助库作为单独的模块一次性添加到项目中,并且编译器根据需求导入它们。

首先,安装tslib

npm install tslib

然后,使用--importHelpers编译你的文件:

tsc --module commonjs --importHelpers a.ts

因此下面的输入,生成的.js文件将包含tslib的导入和使用__assign辅助函数替代内联操作。

export const o = { a: 1, name: 'o' };
export const copy = { ...o };
'use strict';
var tslib_1 = require('tslib');
exports.o = { a: 1, name: 'o' };
exports.copy = tslib_1.__assign({}, exports.o);

无类型导入

TypeScript 历来对于如何导入模块过于严格。这是为了避免输入错误,并防止用户错误地使用模块。

但是,很多时候你可能只想导入的现有模块,但是这些模块可能没有.d.ts文件。以前这是错误的。从 TypeScript 2.1 开始,这更容易了。

使用 TypeScript 2.1,您可以导入 JavaScript 模块,而不需要类型声明。如果类型声明(如declare module "foo" { ... }node_modules/@types/foo)存在,则仍然优先。

对于没有声明文件的模块的导入,在使用了--noImplicitAny编译参数后仍将被标记为错误。

// Succeeds if `node_modules/asdf/index.js` exists
import { x } from 'asdf';

支持--target ES2016,--target ES2017--target ESNext

TypeScript 2.1 支持三个新的编译版本值--target ES2016,--target ES2017--target ESNext

使用 target--target ES2016将指示编译器不要编译 ES2016 特有的特性,比如**操作符。

同样,--target ES2017将指示编译器不要编译 ES2017 特有的特性像async/await

--target ESNext则对应最新的ES 提议特性支持.

改进any类型推断

以前,如果 TypeScript 无法确定变量的类型,它将选择any类型。

let x; // 隐式 'any'
let y = []; // 隐式 'any[]'

let z: any; // 显式 'any'.

使用 TypeScript 2.1,TypeScript 不是仅仅选择any类型,而是基于你后面的赋值来推断类型。

仅当设置了--noImplicitAny编译参数时,才会启用此选项。

示例

let x;

// 你仍然可以给'x'赋值任何你需要的任何值。
x = () => 42;

// 在刚赋值后,TypeScript 2.1 知道'x'的类型是'() => number'。
let y = x();

// 感谢,现在它会告诉你,你不能添加一个数字到一个函数!
console.log(x + y);
//          ~~~~~
// 错误!运算符 '+' 不能应用于类型`() => number`和'number'。

// TypeScript仍然允许你给'x'赋值你需要的任何值。
x = 'Hello world!';

// 并且现在它也知道'x'是'string'类型的!
x.toLowerCase();

现在对空数组也进行同样的跟踪。

没有类型注解并且初始值为[]的变量被认为是一个隐式的any[]变量。变量会根据下面这些操作x.push(value)x.unshift(value)x[n] = value向其中添加的元素来不断改变自身的类型。

function f1() {
  let x = [];
  x.push(5);
  x[1] = 'hello';
  x.unshift(true);
  return x; // (string | number | boolean)[]
}

function f2() {
  let x = null;
  if (cond()) {
    x = [];
    while (cond()) {
      x.push('hello');
    }
  }
  return x; // string[] | null
}

隐式 any 错误

这样做的一个很大的好处是,当使用--noImplicitAny运行时,你将看到较少的隐式any错误。隐式any错误只会在编译器无法知道一个没有类型注解的变量的类型时才会报告。

示例

function f3() {
  let x = []; // 错误:当变量'x'类型无法确定时,它隐式具有'any[]'类型。
  x.push(5);
  function g() {
    x; // 错误:变量'x'隐式具有'any【】'类型。
  }
}

更好的字面量类型推断

字符串、数字和布尔字面量类型(如:"abc"1true)之前仅在存在显式类型注释时才被推断。从 TypeScript 2.1 开始,字面量类型总是推断为默认值。

不带类型注解的const变量或readonly属性的类型推断为字面量初始化的类型。已经初始化且不带类型注解的let变量、var变量、形参或非readonly属性的类型推断为初始值的扩展字面量类型。字符串字面量扩展类型是string,数字字面量扩展类型是number,truefalse的字面量类型是boolean,还有枚举字面量扩展类型是枚举。

示例

const c1 = 1; // Type 1
const c2 = c1; // Type 1
const c3 = 'abc'; // Type "abc"
const c4 = true; // Type true
const c5 = cond ? 1 : 'abc'; // Type 1 | "abc"

let v1 = 1; // Type number
let v2 = c2; // Type number
let v3 = c3; // Type string
let v4 = c4; // Type boolean
let v5 = c5; // Type number | string

字面量类型扩展可以通过显式类型注解来控制。具体来说,当为不带类型注解的const局部变量推断字面量类型的表达式时,var变量获得扩展字面量类型推断。但是当const局部变量有显式字面量类型注解时,var变量获得非扩展字面量类型。

示例

const c1 = 'hello'; // Widening type "hello"
let v1 = c1; // Type string

const c2: 'hello' = 'hello'; // Type "hello"
let v2 = c2; // Type "hello"

将基类构造函数的返回值作为'this'

在 ES2015 中,构造函数的返回值(它是一个对象)隐式地将this的值替换为super()的任何调用者。因此,有必要捕获任何潜在的super()的返回值并替换为this。此更改允许使用自定义元素,利用此元素可以使用用户编写的构造函数初始化浏览器分配的元素。

示例

class Base {
  x: number;
  constructor() {
    // 返回一个除“this”之外的新对象
    return {
      x: 1,
    };
  }
}

class Derived extends Base {
  constructor() {
    super();
    this.x = 2;
  }
}

生成:

var Derived = (function (_super) {
  __extends(Derived, _super);
  function Derived() {
    var _this = _super.call(this) || this;
    _this.x = 2;
    return _this;
  }
  return Derived;
})(Base);

这在继承内置类如ErrorArrayMap等的行为上有了破坏性的改变。请阅读extending built-ins breaking change documentation

配置继承

通常一个项目有多个输出版本,比如ES5ES2015,调试和生产或CommonjsSystem。只有几个配置选项在这两个版本之间改变,并且维护多个tsconfig.json文件是麻烦的。

TypeScript 2.1 支持使用extends来继承配置,其中:

  • extendstsconfig.json是新的顶级属性(与compilerOptionsfilesincludeexclude一起)。
  • extends的值是包含继承自其它tsconfig.json路径的字符串。
  • 首先加载基本文件中的配置,然后由继承配置文件重写。
  • 如果遇到循环,我们报告错误。
  • 继承配置文件中的filesincludeexclude会重写基本配置文件中相应的值。
  • 在配置文件中找到的所有相对路径将相对于它们来源的配置文件来解析。

示例

configs/base.json:

{
  "compilerOptions": {
    "allowJs": true,
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}

configs/tests.json:

{
  "compilerOptions": {
    "preserveConstEnums": true,
    "stripComments": false,
    "sourceMaps": true
  },
  "exclude": [
    "../tests/baselines",
    "../tests/scenarios"
  ],
  "include": [
    "../tests/**/*.ts"
  ]
}

tsconfig.json:

{
  "extends": "./configs/base",
  "files": [
    "main.ts",
    "supplemental.ts"
  ]
}

tsconfig.nostrictnull.json:

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "strictNullChecks": false
  }
}

新编译参数--alwaysStrict

使用--alwaysStrict调用编译器原因:1.在严格模式下解析的所有代码。2.在每一个生成文件上输出"use strict";指令;

模块会自动使用严格模式解析。对于非模块代码,建议使用该编译参数。

TypeScript 2.0

Null 和 undefined 类型

TypeScript 现在有两个特殊的类型:Null 和 Undefined, 它们的值分别是nullundefined。 以前这是不可能明确地命名这些类型的,但是现在nullundefined不管在什么类型检查模式下都可以作为类型名称使用。

以前类型检查器认为nullundefined赋值给一切。实际上,nullundefined是每一个类型的有效值, 并且不能明确排除它们(因此不可能检测到错误)。

--strictNullChecks

--strictNullChecks可以切换到新的严格空检查模式中。

在严格空检查模式中,nullundefined不再属于任何类型的值,仅仅属于它们自己类型和any类型的值 (还有一个例外,undefined也能赋值给void)。因此,尽管在常规类型检查模式下TT | undefined被认为是相同的 (因为undefined被认为是任何T的子类型),但是在严格类型检查模式下它们是不同的, 并且仅仅T | undefined允许有undefined值,TT | null的关系同样如此。

示例

// 使用--strictNullChecks参数进行编译的
let x: number;
let y: number | undefined;
let z: number | null | undefined;
x = 1; // 正确
y = 1; // 正确
z = 1; // 正确
x = undefined; // 错误
y = undefined; // 正确
z = undefined; // 正确
x = null; // 错误
y = null; // 错误
z = null; // 正确
x = y; // 错误
x = z; // 错误
y = x; // 正确
y = z; // 错误
z = x; // 正确
z = y; // 正确

使用前赋值检查

在严格空检查模式中,编译器要求未包含undefined类型的局部变量在使用之前必须先赋值。

示例

// 使用--strictNullChecks参数进行编译
let x: number;
let y: number | null;
let z: number | undefined;
x; // 错误,使用前未赋值
y; // 错误,使用前未赋值
z; // 正确
x = 1;
y = null;
x; // 正确
y; // 正确

编译器通过执行基于控制流的类型分析检查变量明确被赋过值。在本篇文章后面会有进一步的细节。

可选参数和属性

可选参数和属性会自动把undefined添加到他们的类型中,即使他们的类型注解明确不包含undefined。例如,下面两个类型是完全相同的:

// 使用--strictNullChecks参数进行编译
type T1 = (x?: number) => string; // x的类型是 number | undefined
type T2 = (x?: number | undefined) => string; // x的类型是 number | undefined

非 null 和非 undefined 类型保护

如果对象或者函数的类型包含nullundefined,那么访问属性或调用函数时就会产生编译错误。因此,对类型保护进行了扩展,以支持对非 null 和非 undefined 的检查。

示例

// 使用--strictNullChecks参数进行编译
declare function f(x: number): string;
let x: number | null | undefined;
if (x) {
  f(x); // 正确,这里的x类型是number
} else {
  f(x); // 错误,这里的x类型是number?
}
let a = x != null ? f(x) : ''; // a的类型是string
let b = x && f(x); // b的类型是 string | 0 | null | undefined

非 null 和非 undefined 类型保护可以使用==!====!==操作符和nullundefined进行比较,如x != nullx === undefined。对被试变量类型的影响准确地反映了 JavaScript 的语义(比如,双等号运算符检查两个值无论你指定的是 null 还是 undefined,然而三等于号运算符仅仅检查指定的那一个值)。

类型保护中的点名称

类型保护以前仅仅支持对局部变量和参数的检查。现在类型保护支持检查由变量或参数名称后跟一个或多个访问属性组成的“点名称”。

示例

interface Options {
  location?: {
    x?: number;
    y?: number;
  };
}

function foo(options?: Options) {
  if (options && options.location && options.location.x) {
    const x = options.location.x; // x的类型是number
  }
}

点名称的类型保护和用户定义的类型保护函数,还有typeofinstanceof操作符一起工作,并且不依赖--strictNullChecks编译参数。

对点名称进行类型保护后给点名称任一部分赋值都会导致类型保护无效。例如,对x.y.z进行了类型保护后给xx.yx.y.z赋值,都会导致x.y.z类型保护无效。

表达式操作符

表达式操作符允许运算对象的类型包含null和/或undefined,但是总是产生非 null 和非 undefined 类型的结果值。

// 使用--strictNullChecks参数进行编译
function sum(a: number | null, b: number | null) {
  return a + b; // 计算的结果值类型是number
}

&&操作符添加null和/或undefined到右边操作对象的类型中取决于当前左边操作对象的类型,||操作符从左边联合类型的操作对象的类型中将nullundefined同时删除。

// 使用--strictNullChecks参数进行编译
interface Entity {
  name: string;
}
let x: Entity | null;
let s = x && x.name; // s的类型是string | null
let y = x || { name: 'test' }; // y的类型是Entity

类型扩展

在严格空检查模式中,nullundefined类型是不会扩展到any类型中的。

let z = null; // z的类型是null

在常规类型检查模式中,由于扩展,会推断z的类型是any,但是在严格空检查模式中,推断znull类型(因此,如果没有类型注释,nullz的唯一值)。

非空断言操作符

在上下文中当类型检查器无法断定类型时,一个新的后缀表达式操作符!可以用于断言操作对象是非 null 和非 undefined 类型的。具体而言,运算x!产生一个不包含nullundefinedx的值。断言的形式类似于<T>xx as T!非空断言操作符会从编译成的 JavaScript 代码中移除。

// 使用--strictNullChecks参数进行编译
function validateEntity(e?: Entity) {
  // 如果e是null或者无效的实体,就会抛出异常
}

function processEntity(e?: Entity) {
  validateEntity(e);
  let s = e!.name; // 断言e是非空并访问name属性
}

兼容性

这些新特性是经过设计的,使得它们能够在严格空检查模式和常规类型检查模式下都能够使用。尤其是在常规类型检查模式中,nullundefined类型会自动从联合类型中删除(因为它们是其它所有类型的子类型),!非空断言表达式操作符也被允许使用但是没有任何作用。因此,声明文件使用 null 和 undefined 敏感类型更新后,在常规类型模式中仍然是可以向后兼容使用的。

在实际应用中,严格空检查模式要求编译的所有文件都是 null 和 undefined 敏感类型。

基于控制流的类型分析

TypeScript 2.0 实现了对局部变量和参数的控制流类型分析。以前,对类型保护进行类型分析仅限于if语句和?:条件表达式,并且不包括赋值和控制流结构的影响,例如returnbreak语句。使用 TypeScript 2.0,类型检查器会分析语句和表达式所有可能的控制流,在任何指定的位置对声明为联合类型的局部变量或参数产生最可能的具体类型(缩小范围的类型)。

示例

function foo(x: string | number | boolean) {
  if (typeof x === 'string') {
    x; // 这里x的类型是string
    x = 1;
    x; // 这里x的类型是number
  }
  x; // 这里x的类型是number | boolean
}

function bar(x: string | number) {
  if (typeof x === 'number') {
    return;
  }
  x; // 这里x的类型是string
}

基于控制流的类型分析在--strictNullChecks模式中尤为重要,因为可空类型使用联合类型来表示:

function test(x: string | null) {
  if (x === null) {
    return;
  }
  x; // 在函数的剩余部分中,x类型是string
}

而且,在--strictNullChecks模式中,基于控制流的分析包括,对类型不允许为undefined的局部变量有明确赋值的分析。

function mumble(check: boolean) {
  let x: number; // 类型不允许为undefined
  x; // 错误,x是undefined
  if (check) {
    x = 1;
    x; // 正确
  }
  x; // 错误,x可能是undefi
  x = 2;
  x; // 正确
}

标记联合类型

TypeScript 2.0 实现了标记(或区分)联合类型。具体而言,TS 编译器现在支持类型保护,基于判别属性的检查来缩小联合类型的范围,并且switch语句也支持此特性。

示例

interface Square {
  kind: 'square';
  size: number;
}

interface Rectangle {
  kind: 'rectangle';
  width: number;
  height: number;
}

interface Circle {
  kind: 'circle';
  radius: number;
}

type Shape = Square | Rectangle | Circle;

function area(s: Shape) {
  // 在下面的switch语句中,s的类型在每一个case中都被缩小
  // 根据判别属性的值,变量的其它属性不使用类型断言就可以被访问
  switch (s.kind) {
    case 'square':
      return s.size * s.size;
    case 'rectangle':
      return s.width * s.height;
    case 'circle':
      return Math.PI * s.radius * s.radius;
  }
}

function test1(s: Shape) {
  if (s.kind === 'square') {
    s; // Square
  } else {
    s; // Rectangle | Circle
  }
}

function test2(s: Shape) {
  if (s.kind === 'square' || s.kind === 'rectangle') {
    return;
  }
  s; // Circle
}

判别属性类型保护x.p == vx.p === vx.p != v或者x.p !== v其中的一种表达式,pv是一个属性和字符串字面量类型或字符串字面量联合类型的表达式。判别属性类型保护缩小x的类型到由判别属性pv的可能值之一组成的类型。

请注意,我们目前只支持字符串字面值类型的判别属性。我们打算以后添加对布尔值和数字字面量类型的支持。

never类型

TypeScript 2.0 引入了一个新原始类型nevernever类型表示值的类型从不出现。具体而言,never是永不返回函数的返回类型,也是变量在类型保护中永不为 true 的类型。

never类型具有以下特征:

  • never是所有类型的子类型并且可以赋值给所有类型。
  • 没有类型是never的子类型或能赋值给nevernever类型本身除外)。
  • 在函数表达式或箭头函数没有返回类型注解时,如果函数没有return语句,或者只有never类型表达式的return语句,并且如果函数是不可执行到终点的(例如通过控制流分析决定的),则推断函数的返回类型是never
  • 在有明确never返回类型注解的函数中,所有return语句(如果有的话)必须有never类型的表达式并且函数的终点必须是不可执行的。

因为never是每一个类型的子类型,所以它总是在联合类型中被省略,并且在函数中只要其它类型被返回,类型推断就会忽略never类型。

一些返回never函数的示例:

// 函数返回never必须无法执行到终点
function error(message: string): never {
  throw new Error(message);
}

// 推断返回类型是never
function fail() {
  return error('Something failed');
}

// 函数返回never必须无法执行到终点
function infiniteLoop(): never {
  while (true) {}
}

一些函数返回never的使用示例:

// 推断返回类型是number
function move1(direction: 'up' | 'down') {
  switch (direction) {
    case 'up':
      return 1;
    case 'down':
      return -1;
  }
  return error('Should never get here');
}

// 推断返回类型是number
function move2(direction: 'up' | 'down') {
  return direction === 'up'
    ? 1
    : direction === 'down'
    ? -1
    : error('Should never get here');
}

// 推断返回类型是T
function check<T>(x: T | undefined) {
  return x || error('Undefined value');
}

因为never可以赋值给每一个类型,当需要回调函数返回一个更加具体的类型时,函数返回never类型可以用于检测返回类型是否正确:

function test(cb: () => string) {
  let s = cb();
  return s;
}

test(() => 'hello');
test(() => fail());
test(() => {
  throw new Error();
});

只读属性和索引签名

属性或索引签名现在可以使用readonly修饰符声明为只读的。

只读属性可以初始化和在同一个类的构造函数中被赋值,但是在其它情况下对只读属性的赋值是不允许的。

此外,有几种情况下实体隐式只读的:

  • 属性声明只使用get访问器而没有使用set访问器被视为只读的。
  • 在枚举类型中,枚举成员被视为只读属性。
  • 在模块类型中,导出的const变量被视为只读属性。
  • import语句中声明的实体被视为只读的。
  • 通过 ES2015 命名空间导入访问的实体被视为只读的(例如,当foo当作import * as foo from "foo"声明时,foo.x是只读的)。

示例

interface Point {
  readonly x: number;
  readonly y: number;
}

var p1: Point = { x: 10, y: 20 };
p1.x = 5; // 错误,p1.x是只读的

var p2 = { x: 1, y: 1 };
var p3: Point = p2; // 正确,p2的只读别名
p3.x = 5; // 错误,p3.x是只读的
p2.x = 5; // 正确,但是因为别名使用,同时也改变了p3.x
class Foo {
  readonly a = 1;
  readonly b: string;
  constructor() {
    this.b = 'hello'; // 在构造函数中允许赋值
  }
}
let a: Array<number> = [0, 1, 2, 3, 4];
let b: ReadonlyArray<number> = a;
b[5] = 5; // 错误,元素是只读的
b.push(5); // 错误,没有push方法(因为这会修改数组)
b.length = 3; // 错误,length是只读的
a = b; // 错误,缺少修改数组的方法

指定函数中this类型

紧跟着类和接口,现在函数和方法也可以声明this的类型了。

函数中this的默认类型是any。从 TypeScript 2.0 开始,你可以提供一个明确的this参数。this参数是伪参数,它位于函数参数列表的第一位:

function f(this: void) {
  // 确保`this`在这个独立的函数中无法使用
}

回调函数中的this参数

库也可以使用this参数声明回调函数如何被调用。

示例

interface UIElement {
  addClickListener(onclick: (this: void, e: Event) => void): void;
}

this:void意味着addClickListener预计onclick是一个this参数不需要类型的函数。

现在如果你在调用代码中对this进行了类型注释:

class Handler {
  info: string;
  onClickBad(this: Handler, e: Event) {
    // 哎哟,在这里使用this.在运行中使用这个回调函数将会崩溃。
    this.info = e.message;
  }
}
let h = new Handler();
uiElement.addClickListener(h.onClickBad); // 错误!

--noImplicitThis

TypeScript 2.0 还增加了一个新的编译选项用来标记函数中所有没有明确类型注释的this的使用。

tsconfig.json支持文件通配符

文件通配符来啦!!支持文件通配符一直是最需要的特性之一

类似文件通配符的文件模式支持两个属性"include""exclude"

示例

{
    "compilerOptions": {
        "module": "commonjs",
        "noImplicitAny": true,
        "removeComments": true,
        "preserveConstEnums": true,
        "outFile": "../../built/local/tsc.js",
        "sourceMap": true
    },
    "include": [
        "src/**/*"
    ],
    "exclude": [
        "node_modules",
        "**/*.spec.ts"
    ]
}

支持文件通配符的符号有:

  • *匹配零个或多个字符(不包括目录)
  • ?匹配任意一个字符(不包括目录)
  • **/递归匹配所有子目录

如果文件通配符模式语句中只包含*.*,那么只匹配带有扩展名的文件(例如默认是.ts.tsx.d.ts,如果allowJs设置为true.js.jsx也属于默认)。

如果"files""include"都没有指定,编译器默认包含所有目录中的 TypeScript 文件(.ts.d.ts.tsx),除了那些使用exclude属性排除的文件外。如果allowJs设置为 true,JS 文件(.js.jsx)也会被包含进去。

如果"files""include"都指定了,编译器将包含这两个属性指定文件的并集。使用ourDir编译选项指定的目录文件总是被排除,即使"exclude"属性指定的文件也会被删除,但是files属性指定的文件不会排除。

"exclude"属性指定的文件会对"include"属性指定的文件过滤。但是对"files"指定的文件没有任何作用。当没有明确指定时,"exclude"属性默认会排除node_modulesbower_componentsjspm_packages目录。

模块解析增加:BaseUrl、路径映射、rootDirs 和追踪

TypeScript 2.0 提供了一系列额外的模块解析属性告诉编译器去哪里可以找到给定模块的声明。

更多详情,请参阅模块解析文档。

Base URL

使用了 AMD 模块加载器并且模块在运行时”部署“到单文件夹的应用程序中使用baseUrl是一种常用的做法。所有非相对名称的模块导入被认为是相对于baseUrl的。

示例

{
  "compilerOptions": {
    "baseUrl": "./modules"
  }
}

现在导入moduleA将会在./modules/moduleA中查找。

import A from 'moduleA';

路径映射

有时模块没有直接位于baseUrl中。加载器使用映射配置在运行时去映射模块名称和文件,请参阅RequireJs 文档SystemJS 文档

TypeScript 编译器支持tsconfig文件中使用"paths"属性映射的声明。

示例

例如,导入"jquery"模块在运行时会被转换为"node_modules/jquery/dist/jquery.slim.min.js"

{
    "compilerOptions": {
        "baseUrl": "./node_modules",
        "paths": {
        "jquery": ["jquery/dist/jquery.slim.min"]
        }
    }
}

使用"paths"也允许更复杂的映射,包括多次后退的位置。考虑一个只有一个地方的模块是可用的,其它的模块都在另一个地方的项目配置。

rootDirs和虚拟目录

使用rootDirs,你可以告知编译器的根目录组合这些“虚拟”目录。因此编译器在这些“虚拟”目录中解析相对导入模块,仿佛是合并到一个目录中一样。

示例

给定的项目结构

 src
 └── views
     └── view1.ts (imports './template1')
     └── view2.ts

 generated
 └── templates
         └── views
             └── template1.ts (imports './view2')

构建步骤将复制/src/views/generated/templates/views目录下的文件输出到同一个目录中。在运行时,视图期望它的模板和它存在同一目录中,因此应该使用相对名称"./template"导入。

"rootDir"指定的一组根目录的内容将会在运行时合并。因此在我们的例子,tsconfig.json文件应该类似于:

{
  "compilerOptions": {
    "rootDirs": [
      "src/views",
      "generated/templates/views"
    ]
  }
}

追踪模块解析

--traceResolution提供了一种方便的方法,以了解模块如何被编译器解析的。

tsc --traceResolution

快捷外部模块声明

当你使用一个新模块时,如果不想要花费时间书写一个声明时,现在你可以使用快捷声明以便以快速开始。

declarations.d.ts

declare module 'hot-new-module';

所有从快捷模块的导入都具有任意类型。

import x, { y } from 'hot-new-module';
x(y);

模块名称中的通配符

以前使用模块加载器(例如AMDSystemJS)导入没有代码的资源是不容易的。之前,必须为每个资源定义一个外部模块声明。

TypeScript 2.0 支持使用通配符符号(*)定义一类模块名称。这种方式,一个声明只需要一次扩展名,而不再是每一个资源。

示例

declare module '*!text' {
  const content: string;
  export default content;
}
// Some do it the other way around.
declare module 'json!*' {
  const value: any;
  export default value;
}

现在你可以导入匹配"*!text""json!*"的东西了。

import fileContent from './xyz.txt!text';
import data from 'json!http://example.com/data.json';
console.log(data, fileContent);

当从一个基于非类型化的代码迁移时,通配符模块的名称可能更加有用。结合快捷外部模块声明,一组模块可以很容易地声明为any

示例

declare module 'myLibrary/*';

所有位于myLibrary目录之下的模块的导入都被编译器认为是any类型,因此这些模块的任何类型检查都会被关闭。

import { readFile } from "myLibrary/fileSystem/readFile`;

readFile(); // readFile是'any'类型

支持 UMD 模块定义

一些库被设计为可以使用多种模块加载器或者不是使用模块加载器(全局变量)来使用,这被称为UMD同构模块。这些库可以通过导入或全局变量访问。

举例:

math-lib.d.ts

export const isPrime(x: number): boolean;
export as namespace mathLib;

然后,该库可作为模块导入使用:

import { isPrime } from 'math-lib';
isPrime(2);
mathLib.isPrime(2); // 错误:无法在模块内部使用全局定义

它也可以被用来作为一个全局变量,只限于没有importexport脚本文件中。

mathLib.isPrime(2);

可选类属性

现在可以在类中声明可选属性和方法,与接口类似。

示例

class Bar {
  a: number;
  b?: number;
  f() {
    return 1;
  }
  g?(): number; // 可选方法的方法体可以省略
  h?() {
    return 2;
  }
}

--strictNullChecks模式下编译时,可选属性和方法会自动添加undefined到它们的类型中。因此,上面的b属性类型是number | undefined,上面g方法的类型是(()=> number) | undefined。使用类型保护可以去除undefined

私有的和受保护的构造函数

类的构造函数可以被标记为privateprotected。私有构造函数的类不能在类的外部实例化,并且也不能被继承。受保护构造函数的类不能再类的外部实例化,但是可以被继承。

示例

class Singleton {
  private static instance: Singleton;

  private constructor() {}

  static getInstance() {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton();
    }
    return Singleton.instance;
  }
}

let e = new Singleton(); // 错误:Singleton的构造函数是私有的。
let v = Singleton.getInstance();

抽象属性和访问器

抽象类可以声明抽象属性和、或访问器。所有子类将需要声明抽象属性或者被标记为抽象的。抽象属性不能初始化。抽象访问器不能有具体代码块。

示例

abstract class Base {
  abstract name: string;
  abstract get value();
  abstract set value(v: number);
}

class Derived extends Base {
  name = 'derived';

  value = 1;
}

隐式索引签名

如果对象字面量中所有已知的属性是赋值给索引签名,那么现在对象字面量类型可以赋值给索引签名类型。这使得一个使用对象字面量初始化的变量作为参数传递给期望参数是 map 或 dictionary 的函数成为可能:

function httpService(path: string, headers: { [x: string]: string }) {}

const headers = {
  'Content-Type': 'application/x-www-form-urlencoded',
};

httpService('', { 'Content-Type': 'application/x-www-form-urlencoded' }); // 可以
httpService('', headers); // 现在可以,以前不可以。

使用--lib编译参数包含内置类型声明

获取 ES6/ES2015 内置 API 声明仅限于target: ES6。输入--lib,你可以使用--lib指定一组项目所需要的内置 API。比如说,如果你希望项目运行时支持MapSetPromise(例如现在静默更新浏览器),直接写--lib es2015.collection,es2015.promise就好了。同样,你也可以排除项目中不需要的声明,例如在 node 项目中使用--lib es5,es6排除 DOM。

下面是列出了可用的 API:

  • dom
  • webworker
  • es5
  • es6 / es2015
  • es2015.core
  • es2015.collection
  • es2015.iterable
  • es2015.promise
  • es2015.proxy
  • es2015.reflect
  • es2015.generator
  • es2015.symbol
  • es2015.symbol.wellknown
  • es2016
  • es2016.array.include
  • es2017
  • es2017.object
  • es2017.sharedmemory
  • scripthost

示例

tsc --target es5 --lib es5,es2015.promise
"compilerOptions": {
    "lib": ["es5", "es2015.promise"]
}

使用--noUnusedParameters--noUnusedLocals标记未使用的声明

TypeScript 2.0 有两个新的编译参数来帮助你保持一个干净的代码库。-noUnusedParameters编译参数标记所有未使用的函数或方法的参数错误。--noUnusedLocals标记所有未使用的局部(未导出)声明像变量、函数、类和导入等等,另外未使用的私有类成员在--noUnusedLocals作用下也会标记为错误。

示例

import B, { readFile } from './b';
//     ^ 错误:`B`声明了,但是没有使用。
readFile();

export function write(message: string, args: string[]) {
  //                                 ^^^^  错误:'arg'声明了,但是没有使用。
  console.log(message);
}

使用以_开头命名的参数声明不会被未使用参数检查。例如:

function returnNull(_a) {
  // 正确
  return null;
}

模块名称允许.js扩展名

TypeScript 2.0 之前,模块名称总是被认为是没有扩展名的。例如,导入一个模块import d from "./moduleA.js",则编译器在./moduleA.js.ts./moduleA.js.d.ts中查找"moduleA.js"的定义。这使得像SystemJS这种期望模块名称是 URI 的打包或加载工具很难使用。

使用 TypeScript 2.0,编译器将在./moduleA.ts./moduleA.d.ts中查找"moduleA.js"的定义。

支持编译参数target : es5module: es6同时使用

之前编译参数target : es5module: es6同时使用被认为是无效的,但是现在是有效的。这将有助于使用基于 ES2015 的 tree-shaking(将无用代码移除)比如rollup

函数形参和实参列表末尾支持逗号

现在函数形参和实参列表末尾允许有逗号。这是对第三阶段的 ECMAScript 提案的实现, 并且会编译为可用的 ES3/ES5/ES6。

示例

function foo(
  bar: Bar,
  baz: Baz // 形参列表末尾添加逗号是没有问题的。
) {
  // 具体实现……
}

foo(
  bar,
  baz // 实参列表末尾添加逗号同样没有问题
);

新编译参数--skipLibCheck

TypeScript 2.0 添加了一个新的编译参数--skipLibCheck,该参数可以跳过声明文件(以.d.ts为扩展名的文件)的类型检查。当一个程序包含有大量的声明文件时,编译器需要花费大量时间对已知不包含错误的声明进行类型检查,通过跳过声明文件的类型检查,编译时间可能会大大缩短。

由于一个文件中的声明可以影响其他文件中的类型检查,当指定--skipLibCheck时,一些错误可能检测不到。比如说, 如果一个非声明文件中的类型被声明文件用到, 可能仅在声明文件被检查时能发现错误. 不过这种情况在实际使用中并不常见。

允许在声明中重复标识符

这是重复定义错误的一个常见来源。多个声明文件定义相同的接口成员。

TypeScript 2.0 放宽了这一约束,并允许可以不同代码块中出现重复的标识符, 只要它们有完全相同的类型。

在同一代码块重复定义仍不允许。

示例

interface Error {
  stack?: string;
}

interface Error {
  code?: string;
  path?: string;
  stack?: string; // OK
}

新编译参数--declarationDir

--declarationDir可以使生成的声明文件和 JavaScript 文件不在同一个位置中。

TypeScript 1.8

类型参数约束

在 TypeScript 1.8 中, 类型参数的限制可以引用自同一个类型参数列表中的类型参数. 在此之前这种做法会报错. 这种特性通常被叫做 F-Bounded Polymorphism.

例子

function assign<T extends U, U>(target: T, source: U): T {
  for (let id in source) {
    target[id] = source[id];
  }
  return target;
}

let x = { a: 1, b: 2, c: 3, d: 4 };
assign(x, { b: 10, d: 20 });
assign(x, { e: 0 }); // 错误

控制流错误分析

TypeScript 1.8 中引入了控制流分析来捕获开发者通常会遇到的一些错误.

详情见接下来的内容, 可以上手尝试:

cfa

不可及的代码

一定无法在运行时被执行的语句现在会被标记上代码不可及错误. 举个例子, 在无条件限制的 return, throw, break 或者 continue 后的语句被认为是不可及的. 使用 --allowUnreachableCode 来禁用不可及代码的检测和报错.

例子

这里是一个简单的不可及错误的例子:

function f(x) {
  if (x) {
    return true;
  } else {
    return false;
  }

  x = 0; // 错误: 检测到不可及的代码.
}

这个特性能捕获的一个更常见的错误是在 return 语句后添加换行:

function f() {
  return; // 换行导致自动插入的分号
  {
    x: 'string'; // 错误: 检测到不可及的代码.
  }
}

因为 JavaScript 会自动在行末结束 return 语句, 下面的对象字面量变成了一个代码块.

未使用的标签

未使用的标签也会被标记. 和不可及代码检查一样, 被使用的标签检查也是默认开启的. 使用 --allowUnusedLabels 来禁用未使用标签的报错.

例子

loop: while (x > 0) {
  // 错误: 未使用的标签.
  x++;
}

隐式返回

JS 中没有返回值的代码分支会隐式地返回 undefined. 现在编译器可以将这种方式标记为隐式返回. 对于隐式返回的检查默认是被禁用的, 可以使用 --noImplicitReturns 来启用.

例子

function f(x) {
  // 错误: 不是所有分支都返回了值.
  if (x) {
    return false;
  }

  // 隐式返回了 `undefined`
}

Case 语句贯穿

TypeScript 现在可以在 switch 语句中出现贯穿的几个非空 case 时报错. 这个检测默认是关闭的, 可以使用 --noFallthroughCasesInSwitch 启用.

例子

switch (x % 2) {
  case 0: // 错误: switch 中出现了贯穿的 case.
    console.log('even');

  case 1:
    console.log('odd');
    break;
}

然而, 在下面的例子中, 由于贯穿的 case 是空的, 并不会报错:

switch (x % 3) {
  case 0:
  case 1:
    console.log('Acceptable');
    break;

  case 2:
    console.log('This is *two much*!');
    break;
}

React 里的函数组件

TypeScript 现在支持函数组件. 它是可以组合其他组件的轻量级组件.

// 使用参数解构和默认值轻松地定义 'props' 的类型
const Greeter = ({ name = 'world' }) => <div>Hello, {name}!</div>;

// 参数可以被检验
let example = <Greeter name="TypeScript 1.8" />;

如果需要使用这一特性及简化的 props, 请确认使用的是最新的 react.d.ts.

简化的 React props 类型管理

在 TypeScript 1.8 配合最新的 react.d.ts (见上方) 大幅简化了 props 的类型声明.

具体的:

  • 你不再需要显式的声明 refkey 或者 extend React.Props
  • refkey 属性会在所有组件上拥有正确的类型.
  • ref 属性在无状态函数组件上会被正确地禁用.

在模块中扩充全局或者模块作用域

用户现在可以为任何模块进行他们想要, 或者其他人已经对其作出的扩充. 模块扩充的形式和过去的包模块一致 (例如 declare module "foo" { } 这样的语法), 并且可以直接嵌在你自己的模块内, 或者在另外的顶级外部包模块中.

除此之外, TypeScript 还以 declare global { } 的形式提供了对于全局声明的扩充. 这能使模块对像 Array 这样的全局类型在必要的时候进行扩充.

模块扩充的名称解析规则与 importexport 声明中的一致. 扩充的模块声明合并方式与在同一个文件中声明是相同的.

不论是模块扩充还是全局声明扩充都不能向顶级作用域添加新的项目 - 它们只能为已经存在的声明添加 "补丁".

例子

这里的 map.ts 可以声明它会在内部修改在 observable.ts 中声明的 Observable 类型, 添加 map 方法.

// observable.ts
export class Observable<T> {
  // ...
}
// map.ts
import { Observable } from "./observable";

// 扩充 "./observable"
declare module "./observable" {

    // 使用接口合并扩充 'Observable' 类的定义
    interface Observable<T> {
        map<U>(proj: (el: T) => U): Observable<U>;
    }

}

Observable.prototype.map = /*...*/;
// consumer.ts
import { Observable } from './observable';
import './map';

let o: Observable<number>;
o.map(x => x.toFixed());

相似的, 在模块中全局作用域可以使用 declare global 声明被增强:

例子

// 确保当前文件被当做一个模块.
export {};

declare global {
  interface Array<T> {
    mapToNumbers(): number[];
  }
}

Array.prototype.mapToNumbers = function () {
  /* ... */
};

字符串字面量类型

接受一个特定字符串集合作为某个值的 API 并不少见. 举例来说, 考虑一个可以通过控制动画的渐变让元素在屏幕中滑动的 UI 库:

declare class UIElement {
  animate(options: AnimationOptions): void;
}

interface AnimationOptions {
  deltaX: number;
  deltaY: number;
  easing: string; // 可以是 "ease-in", "ease-out", "ease-in-out"
}

然而, 这容易产生错误 - 当用户错误不小心错误拼写了一个合法的值时, 并没有任何提示:

// 没有报错
new UIElement().animate({ deltaX: 100, deltaY: 100, easing: 'ease-inout' });

在 TypeScript 1.8 中, 我们新增了字符串字面量类型. 这些类型和字符串字面量的写法一致, 只是写在类型的位置.

用户现在可以确保类型系统会捕获这样的错误. 这里是我们使用了字符串字面量类型的新的 AnimationOptions:

interface AnimationOptions {
  deltaX: number;
  deltaY: number;
  easing: 'ease-in' | 'ease-out' | 'ease-in-out';
}

// 错误: 类型 '"ease-inout"' 不能复制给类型 '"ease-in" | "ease-out" | "ease-in-out"'
new UIElement().animate({ deltaX: 100, deltaY: 100, easing: 'ease-inout' });

更好的联合/交叉类型接口

TypeScript 1.8 优化了源类型和目标类型都是联合或者交叉类型的情况下的类型推导. 举例来说, 当从 string | string[] 推导到 string | T 时, 我们将类型拆解为 string[]T, 这样就可以将 string[] 推导为 T.

例子

type Maybe<T> = T | void;

function isDefined<T>(x: Maybe<T>): x is T {
  return x !== undefined && x !== null;
}

function isUndefined<T>(x: Maybe<T>): x is void {
  return x === undefined || x === null;
}

function getOrElse<T>(x: Maybe<T>, defaultValue: T): T {
  return isDefined(x) ? x : defaultValue;
}

function test1(x: Maybe<string>) {
  let x1 = getOrElse(x, 'Undefined'); // string
  let x2 = isDefined(x) ? x : 'Undefined'; // string
  let x3 = isUndefined(x) ? 'Undefined' : x; // string
}

function test2(x: Maybe<number>) {
  let x1 = getOrElse(x, -1); // number
  let x2 = isDefined(x) ? x : -1; // number
  let x3 = isUndefined(x) ? -1 : x; // number
}

使用 --outFile 合并 AMDSystem 模块

在使用 --module amd 或者 --module system 的同时制定 --outFile 将会把所有参与编译的模块合并为单个包括了多个模块闭包的输出文件.

每一个模块都会根据其相对于 rootDir 的位置被计算出自己的模块名称.

例子

// 文件 src/a.ts
import * as B from './lib/b';
export function createA() {
  return B.createB();
}
// 文件 src/lib/b.ts
export function createB() {
  return {};
}

结果为:

define('lib/b', ['require', 'exports'], function (require, exports) {
  'use strict';
  function createB() {
    return {};
  }
  exports.createB = createB;
});
define('a', ['require', 'exports', 'lib/b'], function (require, exports, B) {
  'use strict';
  function createA() {
    return B.createB();
  }
  exports.createA = createA;
});

支持 SystemJS 使用 default 导入

像 SystemJS 这样的模块加载器将 CommonJS 模块做了包装并暴露为 default ES6 导入项. 这使得在 SystemJS 和 CommonJS 的实现由于不同加载器不同的模块导出方式不能共享定义.

设置新的编译选项 --allowSyntheticDefaultImports 指明模块加载器会进行导入的 .ts.d.ts 中未指定的某种类型的默认导入项构建. 编译器会由此推断存在一个 default 导出项和整个模块自己一致.

此选项在 System 模块默认开启.

允许循环中被引用的 let/const

之前这样会报错, 现在由 TypeScript 1.8 支持. 循环中被函数引用的 let/const 声明现在会被输出为与 let/const 更新语义相符的代码.

例子

let list = [];
for (let i = 0; i < 5; i++) {
  list.push(() => i);
}

list.forEach(f => console.log(f()));

被编译为:

var list = [];
var _loop_1 = function (i) {
  list.push(function () {
    return i;
  });
};
for (var i = 0; i < 5; i++) {
  _loop_1(i);
}
list.forEach(function (f) {
  return console.log(f());
});

然后结果是:

0
1
2
3
4

改进的 for..in 语句检查

过去 for..in 变量的类型被推断为 any, 这使得编译器忽略了 for..in 语句内的一些不合法的使用.

从 TypeScript 1.8 开始:

  • for..in 语句中的变量隐含类型为 string.
  • 当一个有数字索引签名对应类型 T (比如一个数组) 的对象被一个 for..in 索引数字索引签名并且没有字符串索引签名 (比如还是数组) 的对象的变量索引, 产生的值的类型为 T.

例子

var a: MyObject[];
for (var x in a) {
  // x 的隐含类型为 string
  var obj = a[x]; // obj 的类型为 MyObject
}

模块现在输出时会加上 "use strict;"

对于 ES6 来说模块始终以严格模式被解析, 但这一点过去对于非 ES6 目标在生成的代码中并没有遵循. 从 TypeScript 1.8 开始, 输出的模块总会为严格模式. 由于多数严格模式下的错误也是 TS 编译时的错误, 多数代码并不会有可见的改动, 但是这也意味着有一些东西可能在运行时没有征兆地失败, 比如赋值给 NaN 现在会有运行时错误. 你可以参考这篇 MDN 上的文章 查看详细的严格模式与非严格模式的区别列表.

使用 --allowJs 加入 .js 文件

经常在项目中会有外部的非 TypeScript 编写的源文件. 一种方式是将 JS 代码转换为 TS 代码, 但这时又希望将所有 JS 代码和新的 TS 代码的输出一起打包为一个文件.

.js 文件现在允许作为 tsc 的输入文件. TypeScript 编译器会检查 .js 输入文件的语法错误, 并根据 --target--module 选项输出对应的代码. 输出也会和其他 .ts 文件一起. .js 文件的 source maps 也会像 .ts 文件一样被生成.

使用 --reactNamespace 自定义 JSX 工厂

在使用 --jsx react 的同时使用 --reactNamespace <JSX 工厂名称> 可以允许使用一个不同的 JSX 工厂代替默认的 React.

新的工厂名称会被用来调用 createElement__spread 方法.

例子

import { jsxFactory } from 'jsxFactory';

var div = <div>Hello JSX!</div>;

编译参数:

tsc --jsx react --reactNamespace jsxFactory --m commonJS

结果:

'use strict';
var jsxFactory_1 = require('jsxFactory');
var div = jsxFactory_1.jsxFactory.createElement('div', null, 'Hello JSX!');

基于 this 的类型收窄

TypeScript 1.8 为类和接口方法扩展了用户定义的类型收窄函数.

this is T 现在是类或接口方法的合法的返回值类型标注. 当在类型收窄的位置使用时 (比如 if 语句), 函数调用表达式的目标对象的类型会被收窄为 T.

例子

class FileSystemObject {
  isFile(): this is File {
    return this instanceof File;
  }
  isDirectory(): this is Directory {
    return this instanceof Directory;
  }
  isNetworked(): this is Networked & this {
    return this.networked;
  }
  constructor(public path: string, private networked: boolean) {}
}

class File extends FileSystemObject {
  constructor(path: string, public content: string) {
    super(path, false);
  }
}
class Directory extends FileSystemObject {
  children: FileSystemObject[];
}
interface Networked {
  host: string;
}

let fso: FileSystemObject = new File('foo/bar.txt', 'foo');
if (fso.isFile()) {
  fso.content; // fso 是 File
} else if (fso.isDirectory()) {
  fso.children; // fso 是 Directory
} else if (fso.isNetworked()) {
  fso.host; // fso 是 networked
}

官方的 TypeScript NuGet 包

从 TypeScript 1.8 开始, 将为 TypeScript 编译器 (tsc.exe) 和 MSBuild 整合 (Microsoft.TypeScript.targetsMicrosoft.TypeScript.Tasks.dll) 提供官方的 NuGet 包.

稳定版本可以在这里下载:

与此同时, 和每日 npm 包对应的每日 NuGet 包可以在https://myget.org下载:

tsc 错误信息更美观

我们理解大量单色的输出并不直观. 颜色可以帮助识别信息的始末, 这些视觉上的线索在处理复杂的错误信息时非常重要.

通过传递 --pretty 命令行选项, TypeScript 会给出更丰富的输出, 包含错误发生的上下文.

展示在 ConEmu 中美化之后的错误信息

高亮 VS 2015 中的 JSX 代码

在 TypeScript 1.8 中, JSX 标签现在可以在 Visual Studio 2015 中被分别和高亮.

jsx

通过 工具->选项->环境->字体与颜色 页面在 VB XML 颜色和字体设置中还可以进一步改变字体和颜色来自定义.

--project (-p) 选项现在接受任意文件路径

--project 命令行选项过去只接受包含了 tsconfig.json 文件的文件夹. 考虑到不同的构建场景, 应该允许 --project 指向任何兼容的 JSON 文件. 比如说, 一个用户可能会希望为 Node 5 编译 CommonJS 的 ES 2015, 为浏览器编译 AMD 的 ES5. 现在少了这项限制, 用户可以更容易地直接使用 tsc 管理不同的构建目标, 无需再通过一些奇怪的方式, 比如将多个 tsconfig.json 文件放在不同的目录中.

如果参数是一个路径, 行为保持不变 - 编译器会尝试在该目录下寻找名为 tsconfig.json 的文件.

允许 tsconfig.json 中的注释

为配置添加文档是很棒的! tsconfig.json 现在支持单行和多行注释.

{
    "compilerOptions": {
        "target": "ES2015", // 跑在 node v5 上, 呀!
        "sourceMap": true   // 让调试轻松一些
    },
    /*
     * 排除的文件
     */
    "exclude": [
        "file.d.ts"
    ]
}

支持输出到 IPC 驱动的文件

TypeScript 1.8 允许用户将 --outFile 参数和一些特殊的文件系统对象一起使用, 比如命名的管道 (pipe), 设备 (devices) 等.

举个例子, 在很多与 Unix 相似的系统上, 标准输出流可以通过文件 /dev/stdout 访问.

tsc foo.ts --outFile /dev/stdout

这一特性也允许输出给其他命令.

比如说, 我们可以输出生成的 JavaScript 给一个像 pretty-js 这样的格式美化工具:

tsc foo.ts --outFile /dev/stdout | pretty-js

改进了 Visual Studio 2015 中对 tsconfig.json 的支持

TypeScript 1.8 允许在任何种类的项目中使用 tsconfig.json 文件. 包括 ASP.NET v4 项目, 控制台应用, 以及 用 TypeScript 开发的 HTML 应用. 与此同时, 你可以添加不止一个 tsconfig.json 文件, 其中每一个都会作为项目的一部分被构建. 这使得你可以在不使用多个不同项目的情况下为应用的不同部分使用不同的配置.

展示 Visual Studio 中的 tsconfig.json

当项目中添加了 tsconfig.json 文件时, 我们还禁用了项目属性页面. 也就是说所有配置的改变必须在 tsconfig.json 文件中进行.

一些限制

  • 如果你添加了一个 tsconfig.json 文件, 不在其上下文中的 TypeScript 文件不会被编译.
  • Apache Cordova 应用依然有单个 tsconfig.json 文件的限制, 而这个文件必须在根目录或者 scripts 文件夹.
  • 多数项目类型中都没有 tsconfig.json 的模板.

TypeScript 1.7

支持 async/await 编译到 ES6 (Node v4+)

TypeScript 目前在已经原生支持 ES6 generator 的引擎 (比如 Node v4 及以上版本) 上支持异步函数. 异步函数前置 async 关键字; await 会暂停执行, 直到一个异步函数执行后返回的 promise 被 fulfill 后获得它的值.

例子

在下面的例子中, 输入的内容将会延时 400 毫秒逐个打印:

'use strict';

// printDelayed 返回值是一个 'Promise<void>'
async function printDelayed(elements: string[]) {
  for (const element of elements) {
    await delay(400);
    console.log(element);
  }
}

async function delay(milliseconds: number) {
  return new Promise<void>(resolve => {
    setTimeout(resolve, milliseconds);
  });
}

printDelayed(['Hello', 'beautiful', 'asynchronous', 'world']).then(() => {
  console.log();
  console.log('打印每一个内容!');
});

查看 Async Functions 一文了解更多.

支持同时使用 --target ES6--module

TypeScript 1.7 将 ES6 添加到了 --module 选项支持的选项的列表, 当编译到 ES6 时允许指定模块类型. 这让使用具体运行时中你需要的特性更加灵活.

例子

{
    "compilerOptions": {
        "module": "amd",
        "target": "es6"
    }
}

this 类型

在方法中返回当前对象 (也就是 this) 是一种创建链式 API 的常见方式. 比如, 考虑下面的 BasicCalculator 模块:

export default class BasicCalculator {
  public constructor(protected value: number = 0) {}

  public currentValue(): number {
    return this.value;
  }

  public add(operand: number) {
    this.value += operand;
    return this;
  }

  public subtract(operand: number) {
    this.value -= operand;
    return this;
  }

  public multiply(operand: number) {
    this.value *= operand;
    return this;
  }

  public divide(operand: number) {
    this.value /= operand;
    return this;
  }
}

使用者可以这样表述 2 * 5 + 1:

import calc from './BasicCalculator';

let v = new calc(2).multiply(5).add(1).currentValue();

这使得这么一种优雅的编码方式成为可能; 然而, 对于想要去继承 BasicCalculator 的类来说有一个问题. 想象使用者可能需要编写一个 ScientificCalculator:

import BasicCalculator from './BasicCalculator';

export default class ScientificCalculator extends BasicCalculator {
  public constructor(value = 0) {
    super(value);
  }

  public square() {
    this.value = this.value ** 2;
    return this;
  }

  public sin() {
    this.value = Math.sin(this.value);
    return this;
  }
}

因为 BasicCalculator 的方法返回了 this, TypeScript 过去推断的类型是 BasicCalculator, 如果在 ScientificCalculator 的实例上调用属于 BasicCalculator 的方法, 类型系统不能很好地处理.

举例来说:

import calc from './ScientificCalculator';

let v = new calc(0.5)
  .square()
  .divide(2)
  .sin() // Error: 'BasicCalculator' 没有 'sin' 方法.
  .currentValue();

这已经不再是问题 - TypeScript 现在在类的实例方法中, 会将 this 推断为一个特殊的叫做 this 的类型. this 类型也就写作 this, 可以大致理解为 "方法调用时点左边的类型".

this 类型在描述一些使用了 mixin 风格继承的库 (比如 Ember.js) 的交叉类型:

interface MyType {
  extend<T>(other: T): this & T;
}

ES7 幂运算符

TypeScript 1.7 支持将在 ES7/ES2016 中增加的幂运算符: ****=. 这些运算符会被转换为 ES3/ES5 中的 Math.pow.

举例

var x = 2 ** 3;
var y = 10;
y **= 2;
var z = -(4 ** 3);

会生成下面的 JavaScript:

var x = Math.pow(2, 3);
var y = 10;
y = Math.pow(y, 2);
var z = -Math.pow(4, 3);

改进对象字面量解构的检查

TypeScript 1.7 使对象和数组字面量解构初始值的检查更加直观和自然.

当一个对象字面量通过与之对应的对象解构绑定推断类型时:

  • 对象解构绑定中有默认值的属性对于对象字面量来说可选.
  • 对象解构绑定中的属性如果在对象字面量中没有匹配的值, 则该属性必须有默认值, 并且会被添加到对象字面量的类型中.
  • 对象字面量中的属性必须在对象解构绑定中存在.

当一个数组字面量通过与之对应的数组解构绑定推断类型时:

  • 数组解构绑定中的元素如果在数组字面量中没有匹配的值, 则该元素必须有默认值, 并且会被添加到数组字面量的类型中.

举例

// f1 的类型为 (arg?: { x?: number, y?: number }) => void
function f1({ x = 0, y = 0 } = {}) {}

// And can be called as:
f1();
f1({});
f1({ x: 1 });
f1({ y: 1 });
f1({ x: 1, y: 1 });

// f2 的类型为 (arg?: (x: number, y?: number) => void
function f2({ x, y = 0 } = { x: 0 }) {}

f2();
f2({}); // 错误, x 非可选
f2({ x: 1 });
f2({ y: 1 }); // 错误, x 非可选
f2({ x: 1, y: 1 });

装饰器 (decorators) 支持的编译目标版本增加 ES3

装饰器现在可以编译到 ES3. TypeScript 1.7 在 __decorate 函数中移除了 ES5 中增加的 reduceRight. 相关改动也内联了对 Object.getOwnPropertyDescriptorObject.defineProperty 的调用, 并向后兼容, 使 ES5 的输出可以消除前面提到的 Object 方法的重复[1].

TypeScript 1.6

JSX 支持

JSX 是一种可嵌入的类似 XML 的语法. 它将最终被转换为合法的 JavaScript, 但转换的语义和具体实现有关. JSX 随着 React 流行起来, 也出现在其他应用中. TypeScript 1.6 支持 JavaScript 文件中 JSX 的嵌入, 类型检查, 以及直接编译为 JavaScript 的选项.

新的 .tsx 文件扩展名和 as 运算符

TypeScript 1.6 引入了新的 .tsx 文件扩展名. 这一扩展名一方面允许 TypeScript 文件中的 JSX 语法, 一方面将 as 运算符作为默认的类型转换方式 (避免 JSX 表达式和 TypeScript 前置类型转换运算符之间的歧义). 比如:

var x = <any>foo;
// 与如下等价:
var x = foo as any;

使用 React

使用 React 及 JSX 支持, 你需要使用 React 类型声明. 这些类型定义了 JSX 命名空间, 以便 TypeScript 能正确地检查 React 的 JSX 表达式. 比如:

/// <reference path="react.d.ts" />

interface Props {
  name: string;
}

class MyComponent extends React.Component<Props, {}> {
  render() {
    return <span>{this.props.foo}</span>;
  }
}

<MyComponent name="bar" />; // 没问题
<MyComponent name={0} />; // 错误, `name` 不是一个字符串

使用其他 JSX 框架

JSX 元素的名称和属性是根据 JSX 命名空间来检验的. 请查看 JSX 页面了解如何为自己的框架定义 JSX 命名空间.

编译输出

TypeScript 支持两种 JSX 模式: preserve (保留) 和 react.

  • preserve 模式将会在输出中保留 JSX 表达式, 使之后的转换步骤可以处理. 并且输出的文件扩展名为 .jsx.
  • react 模式将会生成 React.createElement, 不再需要再通过 JSX 转换即可运行, 输出的文件扩展名为 .js.

查看 JSX 页面了解更多 JSX 在 TypeScript 中的使用.

交叉类型 (intersection types)

TypeScript 1.6 引入了交叉类型作为联合类型 (union types) 逻辑上的补充. 联合类型 A | B 表示一个类型为 AB 的实体, 而交叉类型 A & B 表示一个类型同时为 AB 的实体.

例子

function extend<T, U>(first: T, second: U): T & U {
  let result = <T & U>{};
  for (let id in first) {
    result[id] = first[id];
  }
  for (let id in second) {
    if (!result.hasOwnProperty(id)) {
      result[id] = second[id];
    }
  }
  return result;
}

var x = extend({ a: 'hello' }, { b: 42 });
var s = x.a;
var n = x.b;
type LinkedList<T> = T & { next: LinkedList<T> };

interface Person {
  name: string;
}

var people: LinkedList<Person>;
var s = people.name;
var s = people.next.name;
var s = people.next.next.name;
var s = people.next.next.next.name;
interface A {
  a: string;
}
interface B {
  b: string;
}
interface C {
  c: string;
}

var abc: A & B & C;
abc.a = 'hello';
abc.b = 'hello';
abc.c = 'hello';

查看 issue #1256 了解更多.

本地类型声明

本地的类, 接口, 枚举和类型别名现在可以在函数声明中出现. 本地类型为块级作用域, 与 letconst 声明的变量类似. 比如说:

function f() {
  if (true) {
    interface T {
      x: number;
    }
    let v: T;
    v.x = 5;
  } else {
    interface T {
      x: string;
    }
    let v: T;
    v.x = 'hello';
  }
}

推导出的函数返回值类型可能在函数内部声明的. 调用函数的地方无法引用到这样的本地类型, 但是它当然能从类型结构上匹配. 比如:

interface Point {
  x: number;
  y: number;
}

function getPointFactory(x: number, y: number) {
  class P {
    x = x;
    y = y;
  }
  return P;
}

var PointZero = getPointFactory(0, 0);
var PointOne = getPointFactory(1, 1);
var p1 = new PointZero();
var p2 = new PointZero();
var p3 = new PointOne();

本地的类型可以引用类型参数, 本地的类和接口本身即可能是泛型. 比如:

function f3() {
  function f<X, Y>(x: X, y: Y) {
    class C {
      public x = x;
      public y = y;
    }
    return C;
  }
  let C = f(10, 'hello');
  let v = new C();
  let x = v.x; // number
  let y = v.y; // string
}

类表达式

TypeScript 1.6 增加了对 ES6 类表达式的支持. 在一个类表达式中, 类的名称是可选的, 如果指明, 作用域仅限于类表达式本身. 这和函数表达式可选的名称类似. 在类表达式外无法引用其实例类型, 但是自然也能够从类型结构上匹配. 比如:

let Point = class {
  constructor(public x: number, public y: number) {}
  public length() {
    return Math.sqrt(this.x * this.x + this.y * this.y);
  }
};
var p = new Point(3, 4); // p has anonymous class type
console.log(p.length());

继承表达式

TypeScript 1.6 增加了对类继承任意值为一个构造函数的表达式的支持. 这样一来内建的类型也可以在类的声明中被继承.

extends 语句过去需要指定一个类型引用, 现在接受一个可选类型参数的表达式. 表达式的类型必须为有至少一个构造函数签名的构造函数, 并且需要和 extends 语句中类型参数数量一致. 匹配的构造函数签名的返回值类型是类实例类型继承的基类型. 如此一来, 这使得普通的类和与类相似的表达式可以在 extends 语句中使用.

一些例子:

// 继承内建类

class MyArray extends Array<number> {}
class MyError extends Error {}

// 继承表达式类

class ThingA {
  getGreeting() {
    return 'Hello from A';
  }
}

class ThingB {
  getGreeting() {
    return 'Hello from B';
  }
}

interface Greeter {
  getGreeting(): string;
}

interface GreeterConstructor {
  new (): Greeter;
}

function getGreeterBase(): GreeterConstructor {
  return Math.random() >= 0.5 ? ThingA : ThingB;
}

class Test extends getGreeterBase() {
  sayHello() {
    console.log(this.getGreeting());
  }
}

abstract (抽象的) 类和方法

TypeScript 1.6 为类和它们的方法增加了 abstract 关键字. 一个抽象类允许没有被实现的方法, 并且不能被构造.

例子

abstract class Base {
  abstract getThing(): string;
  getOtherThing() {
    return 'hello';
  }
}

let x = new Base(); // 错误, 'Base' 是抽象的

// 错误, 必须也为抽象类, 或者实现 'getThing' 方法
class Derived1 extends Base {}

class Derived2 extends Base {
  getThing() {
    return 'hello';
  }
  foo() {
    super.getThing(); // 错误: 不能调用 'super' 的抽象方法
  }
}

var x = new Derived2(); // 正确
var y: Base = new Derived2(); // 同样正确
y.getThing(); // 正确
y.getOtherThing(); // 正确

泛型别名

TypeScript 1.6 中, 类型别名支持泛型. 比如:

type Lazy<T> = T | (() => T);

var s: Lazy<string>;
s = 'eager';
s = () => 'lazy';

interface Tuple<A, B> {
  a: A;
  b: B;
}

type Pair<T> = Tuple<T, T>;

更严格的对象字面量赋值检查

为了能发现多余或者错误拼写的属性, TypeScript 1.6 使用了更严格的对象字面量检查. 确切地说, 在将一个新的对象字面量赋值给一个变量, 或者传递给类型非空的参数时, 如果对象字面量的属性在目标类型中不存在, 则会视为错误.

例子

var x: { foo: number };
x = { foo: 1, baz: 2 }; // 错误, 多余的属性 `baz`

var y: { foo: number; bar?: number };
y = { foo: 1, baz: 2 }; // 错误, 多余或者拼错的属性 `baz`

一个类型可以通过包含一个索引签名来显示指明未出现在类型中的属性是被允许的.

var x: { foo: number; [x: string]: any };
x = { foo: 1, baz: 2 }; // 现在 `baz` 匹配了索引签名

ES6 生成器 (generators)

TypeScript 1.6 添加了对于 ES6 输出的生成器支持.

一个生成器函数可以有返回值类型标注, 就像普通的函数. 标注表示生成器函数返回的生成器的类型. 这里有个例子:

function* g(): Iterable<string> {
  for (var i = 0; i < 100; i++) {
    yield ''; // string 可以赋值给 string
  }
  yield* otherStringGenerator(); // otherStringGenerator 必须可遍历, 并且元素类型需要可赋值给 string
}

没有标注类型的生成器函数会有自动推演的类型. 在下面的例子中, 类型会由 yield 语句推演出来:

function* g() {
  for (var i = 0; i < 100; i++) {
    yield ''; // 推导出 string
  }
  yield* otherStringGenerator(); // 推导出 otherStringGenerator 的元素类型
}

async (异步) 函数的试验性支持

TypeScript 1.6 增加了编译到 ES6 时对 async 函数试验性的支持. 异步函数会执行一个异步的操作, 在等待的同时不会阻塞程序的正常运行. 这是通过与 ES6 兼容的 Promise 实现完成的, 并且会将函数体转换为支持在等待的异步操作完成时继续的形式.

async 标记的函数或方法被称作异步函数. 这个标记告诉了编译器该函数体需要被转换, 关键字 await 则应该被当做一个一元运算符, 而不是标示符. 一个异步函数必须返回类型与 Promise 兼容的值. 返回值类型的推断只能在有一个全局的, 与 ES6 兼容的 Promise 类型时使用.

例子

var p: Promise<number> = /* ... */;
async function fn(): Promise<number> {
  var i = await p; // 暂停执行直到 'p' 得到结果. 'i' 的类型为 "number"
  return 1 + i;
}

var a = async (): Promise<number> => 1 + await p; // 暂停执行.
var a = async () => 1 + await p; // 暂停执行. 使用 --target ES6 选项编译时返回值类型被推断为 "Promise<number>"
var fe = async function(): Promise<number> {
  var i = await p; // 暂停执行知道 'p' 得到结果. 'i' 的类型为 "number"
  return 1 + i;
}

class C {
  async m(): Promise<number> {
    var i = await p; // 暂停执行知道 'p' 得到结果. 'i' 的类型为 "number"
    return 1 + i;
  }

  async get p(): Promise<number> {
    var i = await p; // 暂停执行知道 'p' 得到结果. 'i' 的类型为 "number"
    return 1 + i;
  }
}

每天发布新版本

由于并不算严格意义上的语言变化[2], 每天的新版本可以使用如下命令安装获得:

npm install -g typescript@next

对模块解析逻辑的调整

从 1.6 开始, TypeScript 编译器对于 "commonjs" 的模块解析会使用一套不同的规则. 这些规则 尝试模仿 Node 查找模块的过程. 这就意味着 node 模块可以包含它的类型信息, 并且 TypeScript 编译器可以找到这些信息. 不过用户可以通过使用 --moduleResolution 命令行选项覆盖模块解析规则. 支持的值有:

  • 'classic' - TypeScript 1.6 以前的编译器使用的模块解析规则
  • 'node' - 与 node 相似的模块解析

合并外围类和接口的声明

外围类的实例类型可以通过接口声明来扩展. 类构造函数对象不会被修改. 比如说:

declare class Foo {
  public x: number;
}

interface Foo {
  y: string;
}

function bar(foo: Foo) {
  foo.x = 1; // 没问题, 在类 Foo 中有声明
  foo.y = '1'; // 没问题, 在接口 Foo 中有声明
}

用户定义的类型收窄函数

TypeScript 1.6 增加了一个新的在 if 语句中收窄变量类型的方式, 作为对 typeofinstanceof 的补充. 用户定义的类型收窄函数的返回值类型标注形式为 x is T, 这里 x 是函数声明中的形参, T 是任何类型. 当一个用户定义的类型收窄函数在 if 语句中被传入某个变量执行时, 该变量的类型会被收窄到 T.

例子

function isCat(a: any): a is Cat {
  return a.name === 'kitty';
}

var x: Cat | Dog;
if (isCat(x)) {
  x.meow(); // 那么, x 在这个代码块内是 Cat 类型
}

tsconfig.jsonexclude 属性的支持

一个没有写明 files 属性的 tsconfig.json 文件 (默认会引用所有子目录下的 *.ts 文件) 现在可以包含一个 exclude 属性, 指定需要在编译中排除的文件或者目录列表. exclude 属性必须是一个字符串数组, 其中每一个元素指定对应的一个文件或者文件夹名称对于 tsconfig.json 文件所在位置的相对路径. 举例来说:

{
    "compilerOptions": {
        "out": "test.js"
    },
    "exclude": [
        "node_modules",
        "test.ts",
        "utils/t2.ts"
    ]
}

exclude 列表不支持通配符. 仅仅可以是文件或者目录的列表.

--init 命令行选项

在一个目录中执行 tsc --init 可以在该目录中创建一个包含了默认值的 tsconfig.json. 可以通过一并传递其他选项来生成初始的 tsconfig.json.

TypeScript 1.5

ES6 模块

TypeScript 1.5 支持 ECMAScript 6 (ES6) 模块. ES6 模块可以看做之前 TypeScript 的外部模块换上了新的语法: ES6 模块是分开加载的源文件, 这些文件还可能引入其他模块, 并且导出部分供外部可访问. ES6 模块新增了几种导入和导出声明. 我们建议使用 TypeScript 开发的库和应用能够更新到新的语法, 但不做强制要求. 新的 ES6 模块语法和 TypeScript 原来的内部和外部模块结构同时被支持, 如果需要也可以混合使用.

导出声明

作为 TypeScript 已有的 export 前缀支持, 模块成员也可以使用单独导出的声明导出, 如果需要, as 语句可以指定不同的导出名称.

interface Stream { ... }
function writeToStream(stream: Stream, data: string) { ... }
export { Stream, writeToStream as write };  // writeToStream 导出为 write

引入声明也可以使用 as 语句来指定一个不同的导入名称. 比如:

import { read, write, standardOutput as stdout } from './inout';
var s = read(stdout);
write(stdout, s);

作为单独导入的候选项, 命名空间导入可以导入整个模块:

import * as io from './inout';
var s = io.read(io.standardOutput);
io.write(io.standardOutput, s);

重新导出

使用 from 语句一个模块可以复制指定模块的导出项到当前模块, 而无需创建本地名称.

export { read, write, standardOutput as stdout } from './inout';

export * 可以用来重新导出另一个模块的所有导出项. 在创建一个聚合了其他几个模块导出项的模块时很方便.

export function transform(s: string): string { ... }
export * from "./mod1";
export * from "./mod2";

默认导出项

一个 export default 声明表示一个表达式是这个模块的默认导出项.

export default class Greeter {
  sayHello() {
    console.log('Greetings!');
  }
}

对应的可以使用默认导入:

import Greeter from './greeter';
var g = new Greeter();
g.sayHello();

无导入加载

"无导入加载" 可以被用来加载某些只需要其副作用的模块.

import './polyfills';

了解更多关于模块的信息, 请参见 ES6 模块支持规范.

声明与赋值的解构

TypeScript 1.5 添加了对 ES6 解构声明与赋值的支持.

解构

解构声明会引入一个或多个命名变量, 并且初始化它们的值为对象的属性或者数组的元素对应的值.

比如说, 下面的例子声明了变量 x, yz, 并且分别将它们的值初始化为 getSomeObject().x, getSomeObject().ygetSomeObject().z:

var { x, y, z } = getSomeObject();

解构声明也可以用于从数组中得到值.

var [x, y, z = 10] = getSomeArray();

相似的, 解构可以用在函数的参数声明中:

function drawText({ text = '', location: [x, y] = [0, 0], bold = false }) {
  // 画出文本
}

// 以一个对象字面量为参数调用 drawText
var item = { text: 'someText', location: [1, 2, 3], style: 'italics' };
drawText(item);

赋值

解构也可以被用于普通的赋值表达式. 举例来讲, 交换两个变量的值可以被写作一个解构赋值:

var x = 1;
var y = 2;
[x, y] = [y, x];

namespace (命名空间) 关键字

过去 TypeScript 中 module 关键字既可以定义 "内部模块", 也可以定义 "外部模块"; 这让刚刚接触 TypeScript 的开发者有些困惑. "内部模块" 的概念更接近于大部分人眼中的命名空间; 而 "外部模块" 对于 JS 来讲, 现在也就是模块了.

注意: 之前定义内部模块的语法依然被支持.

之前:

module Math {
    export function add(x, y) { ... }
}

之后:

namespace Math {
    export function add(x, y) { ... }
}

letconst 的支持

ES6 的 letconst 声明现在支持编译到 ES3 和 ES5.

Const

const MAX = 100;

++MAX; // 错误: 自增/减运算符不能用于一个常量

块级作用域

if (true) {
  let a = 4;
  // 使用变量 a
} else {
  let a = 'string';
  // 使用变量 a
}

alert(a); // 错误: 变量 a 在当前作用域未定义

for...of 的支持

TypeScript 1.5 增加了 ES6 for...of 循环编译到 ES3/ES5 时对数组的支持, 以及编译到 ES6 时对满足 Iterator 接口的全面支持.

例子

TypeScript 编译器会转译 for...of 数组到具有语义的 ES3/ES5 JavaScript (如果被设置为编译到这些版本).

for (var v of expr) {
}

会输出为:

for (var _i = 0, _a = expr; _i < _a.length; _i++) {
  var v = _a[_i];
}

装饰器

TypeScript 装饰器是局域 ES7 装饰器 提案的.

一个装饰器是:

  • 一个表达式
  • 并且值为一个函数
  • 接受 target, name, 以及属性描述对象作为参数
  • 可选返回一个会被应用到目标对象的属性描述对象

了解更多, 请参见 装饰器 提案.

例子

装饰器 readonlyenumerable(false) 会在属性 method 添加到类 C 上之前被应用. 这使得装饰器可以修改其实现, 具体到这个例子, 设置了 descriptorwritable: false 以及 enumerable: false.

class C {
  @readonly
  @enumerable(false)
  method() {}
}

function readonly(target, key, descriptor) {
  descriptor.writable = false;
}

function enumerable(value) {
  return function (target, key, descriptor) {
    descriptor.enumerable = value;
  };
}

计算属性

使用动态的属性初始化一个对象可能会很麻烦. 参考下面的例子:

type NeighborMap = { [name: string]: Node };
type Node = { name: string; neighbors: NeighborMap };

function makeNode(name: string, initialNeighbor: Node): Node {
  var neighbors: NeighborMap = {};
  neighbors[initialNeighbor.name] = initialNeighbor;
  return { name: name, neighbors: neighbors };
}

这里我们需要创建一个包含了 neighbor-map 的变量, 便于我们初始化它. 使用 TypeScript 1.5, 我们可以让编译器来干重活:

function makeNode(name: string, initialNeighbor: Node): Node {
  return {
    name: name,
    neighbors: {
      [initialNeighbor.name]: initialNeighbor,
    },
  };
}

指出 UMDSystem 模块输出

作为 AMDCommonJS 模块加载器的补充, TypeScript 现在支持输出为 UMD (Universal Module Definition) 和 System 模块的格式.

用法:

tsc --module umd

以及

tsc --module system

Unicode 字符串码位转义

ES6 中允许用户使用单个转义表示一个 Unicode 码位.

举个例子, 考虑我们需要转义一个包含了字符 '𠮷' 的字符串. 在 UTF-16/USC2 中, '𠮷' 被表示为一个代理对, 意思就是它被编码为一对 16 位值的代码单元, 具体来说是 0xD8420xDFB7. 之前这意味着你必须将该码位转义为 "\uD842\uDFB7". 这样做有一个重要的问题, 就事很难讲两个独立的字符同一个代理对区分开来.

通过 ES6 的码位转义, 你可以在字符串或模板字符串中清晰地通过一个转义表示一个确切的字符: "\u{20bb7}". TypeScript 在编译到 ES3/ES5 时会将该字符串输出为 "\uD842\uDFB7".

标签模板字符串编译到 ES3/ES5

TypeScript 1.4 中, 我们添加了模板字符串编译到所有 ES 版本的支持, 并且支持标签模板字符串编译到 ES6. 得益于 @ivogabe 的大量付出, 我们填补了标签模板字符串对编译到 ES3/ES5 的支持.

当编译到 ES3/ES5 时, 下面的代码:

function oddRawStrings(strs: TemplateStringsArray, n1, n2) {
  return strs.raw.filter((raw, index) => index % 2 === 1);
}

oddRawStrings`Hello \n${123} \t ${456}\n world`;

会被输出为:

function oddRawStrings(strs, n1, n2) {
  return strs.raw.filter(function (raw, index) {
    return index % 2 === 1;
  });
}
(_a = ['Hello \n', ' \t ', '\n world']),
  (_a.raw = ['Hello \\n', ' \\t ', '\\n world']),
  oddRawStrings(_a, 123, 456);
var _a;

AMD 可选依赖名称

/// <amd-dependency path="x" /> 会告诉编译器需要被注入到模块 require 方法中的非 TS 模块依赖; 然而在 TS 代码中无法使用这个模块.

新的 amd-dependency name 属性允许为 AMD 依赖传递一个可选的名称.

/// <amd-dependency path="legacy/moduleA" name="moduleA"/>
declare var moduleA: MyType;
moduleA.callStuff();

生成的 JS 代码:

define(['require', 'exports', 'legacy/moduleA'], function (
  require,
  exports,
  moduleA
) {
  moduleA.callStuff();
});

通过 tsconfig.json 指示一个项目

通过添加 tsconfig.json 到一个目录指明这是一个 TypeScript 项目的根目录. tsconfig.json 文件指定了根文件以及编译项目需要的编译器选项. 一个项目可以由以下方式编译:

  • 调用 tsc 并不指定输入文件, 此时编译器会从当前目录开始往上级目录寻找 tsconfig.json 文件.
  • 调用 tsc 并不指定输入文件, 使用 -project (或者 -p) 命令行选项指定包含了 tsconfig.json 文件的目录.

例子

{
    "compilerOptions": {
        "module": "commonjs",
        "noImplicitAny": true,
        "sourceMap": true,
    }
}

参见 tsconfig.json wiki 页面 查看更多信息.

--rootDir 命令行选项

选项 --outDir 在输出中会保留输入的层级关系. 编译器将所有输入文件共有的最长路径作为根路径; 并且在输出中应用对应的子层级关系.

有的时候这并不是期望的结果, 比如输入 FolderA\FolderB\1.tsFolderA\FolderB\2.ts, 输出结构会是 FolderA\FolderB\ 对应的结构. 如果输入中新增 FolderA\3.ts 文件, 输出的结构将突然变为 FolderA\ 对应的结构.

--rootDir 指定了会输出对应结构的输入目录, 不再通过计算获得.

--noEmitHelpers 命令行选项

TypeScript 编译器在需要的时候会输出一些像 __extends 这样的工具函数. 这些函数会在使用它们的所有文件中输出. 如果你想要聚合所有的工具函数到同一个位置, 或者覆盖默认的行为, 使用 --noEmitHelpers 来告知编译器不要输出它们.

--newLine 命令行选项

默认输出的换行符在 Windows 上是 \r\n, 在 *nix 上是 \n. --newLine 命令行标记可以覆盖这个行为, 并指定输出文件中使用的换行符.

--inlineSourceMap and inlineSources 命令行选项

--inlineSourceMap 将内嵌源文件映射到 .js 文件, 而不是在单独的 .js.map 文件中. --inlineSources 允许进一步将 .ts 文件内容包含到输出文件中.

TypeScript 1.4

联合类型

概述

联合类型有助于表示一个值的类型可以是多种类型之一的情况。比如,有一个 API 接命令行传入string类型,string[]类型或者是一个返回string的函数。你就可以这样写:

interface RunOptions {
  program: string;
  commandline: string[] | string | (() => string);
}

给联合类型赋值也很直观 -- 只要这个值能满足联合类型中任意一个类型那么就可以赋值给这个联合类型:

var opts: RunOptions = /* ... */;
opts.commandline = '-hello world'; // OK
opts.commandline = ['-hello', 'world']; // OK
opts.commandline = [42]; // Error, 数字不是字符串或字符串数组

当读取联合类型时,你可以访问类型共有的属性:

if (opts.length === 0) {
  // OK, string和string[]都有'length'属性
  console.log("it's empty");
}

使用类型保护,你可以轻松地使用联合类型:

function formatCommandline(c: string | string[]) {
  if (typeof c === 'string') {
    return c.trim();
  } else {
    return c.join(' ');
  }
}

严格的泛型

随着联合类型可以表示有很多类型的场景,我们决定去改进泛型调用的规范性。之前,这段代码编译不会报错(出乎意料):

function equal<T>(lhs: T, rhs: T): boolean {
  return lhs === rhs;
}

// 之前没有错误
// 现在会报错:在string和number之前没有最佳的基本类型
var e = equal(42, 'hello');

通过联合类型,你可以指定你想要的行为,在函数定义时或在调用的时候:

// 'choose' function where types must match
function choose1<T>(a: T, b: T): T {
  return Math.random() > 0.5 ? a : b;
}
var a = choose1('hello', 42); // Error
var b = choose1<string | number>('hello', 42); // OK

// 'choose' function where types need not match
function choose2<T, U>(a: T, b: U): T | U {
  return Math.random() > 0.5 ? a : b;
}
var c = choose2('bar', 'foo'); // OK, c: string
var d = choose2('hello', 42); // OK, d: string|number

更好的类型推断

当一个集合里有多种类型的值时,联合类型会为数组或其它地方提供更好的类型推断:

var x = [1, 'hello']; // x: Array<string|number>
x[0] = 'world'; // OK
x[0] = false; // Error, boolean is not string or number

let 声明

在 JavaScript 里,var声明会被“提升”到所在作用域的顶端。这可能会引发一些让人不解的 bugs:

console.log(x); // meant to write 'y' here
/* later in the same block */
var x = 'hello';

TypeScript 已经支持新的 ES6 的关键字let,声明一个块级作用域的变量。一个let变量只能在声明之后的位置被引用,并且作用域为声明它的块里:

if (foo) {
  console.log(x); // Error, cannot refer to x before its declaration
  let x = 'hello';
} else {
  console.log(x); // Error, x is not declared in this block
}

let只在设置目标为 ECMAScript 6 (--target ES6)时生效。

const 声明

另一个 TypeScript 支持的 ES6 里新出现的声明类型是const。不能给一个const类型变量赋值,只能在声明的时候初始化。这对于那些在初始化之后就不想去改变它的值的情况下是很有帮助的:

const halfPi = Math.PI / 2;
halfPi = 2; // Error, can't assign to a `const`

const只在设置目标为 ECMAScript 6 (--target ES6)时生效。

模版字符串

TypeScript 现已支持 ES6 模块字符串。通过它可以方便地在字符串中嵌入任何表达式:

var name = 'TypeScript';
var greeting = `Hello, ${name}! Your name has ${name.length} characters`;

当编译目标为 ES6 之前的版本时,这个字符串被分解为:

var name = 'TypeScript!';
var greeting =
  'Hello, ' + name + '! Your name has ' + name.length + ' characters';

类型守护

JavaScript 常用模式之一是在运行时使用typeofinstanceof检查表达式的类型。 在if语句里使用它们的时候,TypeScript 可以识别出这些条件并且随之改变类型推断的结果。

使用typeof来检查一个变量:

var x: any = /* ... */;
if(typeof x === 'string') {
    console.log(x.subtr(1)); // Error, 'subtr' does not exist on 'string'
}
// x is still any here
x.unknown(); // OK

结合联合类型使用typeofelse

var x: string|HTMLElement = /* ... */;
if(typeof x === 'string') {
    // x is string here, as shown above
} else {
    // x is HTMLElement here
    console.log(x.innerHTML);
}

结合类和联合类型使用instanceof

class Dog { woof() { } }
class Cat { meow() { } }
var pet: Dog|Cat = /* ... */;
if(pet instanceof Dog) {
    pet.woof(); // OK
} else {
    pet.woof(); // Error
}

类型别名

你现在可以使用type关键字来为类型定义一个“别名”:

type PrimitiveArray = Array<string | number | boolean>;
type MyNumber = number;
type NgScope = ng.IScope;
type Callback = () => void;

类型别名与其原始的类型完全一致;它们只是简单的替代名。

const enum(完全嵌入的枚举)

枚举很有帮助,但是有些程序实际上并不需要它生成的代码并且想要将枚举变量所代码的数字值直接替换到对应位置上。新的const enum声明与正常的enum在类型安全方面具有同样的作用,只是在编译时会清除掉。

const enum Suit {
  Clubs,
  Diamonds,
  Hearts,
  Spades,
}
var d = Suit.Diamonds;

Compiles to exactly:

var d = 1;

TypeScript 也会在可能的情况下计算枚举值:

enum MyFlags {
  None = 0,
  Neat = 1,
  Cool = 2,
  Awesome = 4,
  Best = Neat | Cool | Awesome,
}
var b = MyFlags.Best; // emits var b = 7;

-noEmitOnError 命令行选项

TypeScript 编译器的默认行为是当存在类型错误(比如,将string类型赋值给number类型)时仍会生成.js 文件。这在构建服务器上或是其它场景里可能会是不想看到的情况,因为希望得到的是一次“纯净”的构建。新的noEmitOnError标记可以阻止在编译时遇到错误的情况下继续生成.js 代码。

它现在是 MSBuild 工程的默认行为;这允许 MSBuild 持续构建以我们想要的行为进行,输出永远是来自纯净的构建。

AMD 模块名

默认情况下 AMD 模块以匿名形式生成。这在使用其它工具(比如,r.js)处理生成的模块的时可能会带来麻烦。

新的amd-module name标签允许给编译器传入一个可选的模块名:

//// [amdModule.ts]
///<amd-module name='NamedModule'/>
export class C {}

结果会把NamedModule赋值成模块名,做为调用 AMDdefine的一部分:

//// [amdModule.js]
define('NamedModule', ['require', 'exports'], function (require, exports) {
  var C = (function () {
    function C() {}
    return C;
  })();
  exports.C = C;
});

TypeScript 1.3

受保护的

类里面新的protected修饰符作用与其它语言如 C++,C#和 Java 中的一样。一个类的protected成员只在这个类的子类中可见:

class Thing {
  protected doSomething() {
    /* ... */
  }
}

class MyThing extends Thing {
  public myMethod() {
    // OK,可以在子类里访问受保护的成员
    this.doSomething();
  }
}
var t = new MyThing();
t.doSomething(); // Error,不能在类外部访问受保护成员

元组类型

元组类型表示一个数组,其中元素的类型都是已知的,但是不一样是同样的类型。比如,你可能想要表示一个第一个元素是string类型第二个元素是number类型的数组:

// Declare a tuple type
var x: [string, number];
// 初始化
x = ['hello', 10]; // OK
// 错误的初始化
x = [10, 'hello']; // Error

但是访问一个已知的索引,会得到正确的类型:

console.log(x[0].substr(1)); // OK
console.log(x[1].substr(1)); // Error, 'number'没有'substr'方法

注意在 TypeScript1.4 里,当访问超出已知索引的元素时,会返回联合类型:

x[3] = 'world'; // OK
console.log(x[5].toString()); // OK, 'string'和'number'都有toString
x[6] = true; // Error, boolean不是number或string

TypeScript 1.1

改进性能

1.1 版本的编译器速度比所有之前发布的版本快 4 倍。阅读这篇博客里的有关图表

更好的模块可见性规则

TypeScript 现在只在使用--declaration标记时才严格强制模块里类型的可见性。这在 Angular 里很有用,例如:

module MyControllers {
  interface ZooScope extends ng.IScope {
    animals: Animal[];
  }
  export class ZooController {
    // Used to be an error (cannot expose ZooScope), but now is only
    // an error when trying to generate .d.ts files
    constructor(public $scope: ZooScope) {}
    /* more code */
  }
}

Breaking Changes

TypeScript 3.6

类成员的 constructor 现在被叫做 Constructors

根据 ECMAScript 规范,使用名为 constructor 的方法的类声明现在是构造函数,无论它们是使用标识符名称还是字符串名称声明。

class C {
  constructor() {
    console.log('现在我是构造函数了。');
  }
}

一个值得注意的例外,以及此改变的解决方法是使用名称计算结果为 constructor 的计算属性。

class D {
  ['constructor']() {
    console.log('我只是一个纯粹的方法,不是构造函数!');
  }
}

DOM 定义更新

lib.dom.d.ts 中移除或者修改了大量的定义。其中包括(但不仅限于)以下这些:

  • 全局的 window 不再定义为 Window,它被更明确的定义 type Window & typeof globalThis 替代。在某些情况下,将它作为 typeof window 更好。
  • GlobalFetch 已经被移除。使用 WindowOrWorkerGlobalScrope 替代。
  • Navigator 上明确的非标准的属性已经被移除了。
  • experimental-webgl 上下文已经被移除了。使用 webglwebgl2 替代。

如果你认为其中的改变已经制造了错误,请提交一个 issue

JSDoc 注释不再合并

在 JavaScript 文件中,TypeScript 只会在 JSDoc 注释之前立即查询以确定声明的类型。

/**
 * @param {string} arg
 */
/**
 * 你的其他注释信息
 */
function whoWritesFunctionsLikeThis(arg) {
  // 'arg' 是 'any' 类型
}

关键字不能包含转义字符

之前的版本允许关键字包含转义字符。TypeScript 3.6 不允许。

while (true) {
  \u0063ontinue;
//  ~~~~~~~~~~~~~
// 错误!关键字不能包含转义字符
}

参考

TypeScript 3.5

lib.d.ts 包含了 Omit 辅助类型

TypeScript 3.5 包含一个 Omit 辅助类型。

因此, 你项目中任何全局定义的 Omit 将产生以下错误信息:

Duplicate identifier 'Omit'.

两个变通的方法可以在这里使用:

  1. 删除重复定义的并使用 lib.d.ts 提供的。
  2. 从模块中导出定义避免全局冲突。现有的用法可以使用 import 直接引用项目的旧 Omit 类型。

TypeScript 3.4

顶级 this 现在有类型了

顶级 this 的类型现在被分配为 typeof globalThis 而不是 any

因此, 在 noImplicitAny 下访问 this 上的未知值,你可能收到错误提示。

// 在 `noImplicitAny` 下,以前可以,现在不行
this.whargarbl = 10;

请注意,在 noImplicitThis 下编译的代码不会在此处遇到任何更改。

泛型参数的传递

在某些情况下,TypeScript 3.4 的推断改进可能会产生泛型的函数,而不是那些接收并返回其约束的函数(通常是 {})。

declare function compose<T, U, V>(
  f: (arg: T) => U,
  g: (arg: U) => V
): (arg: T) => V;

function list<T>(x: T) {
  return [x];
}
function box<T>(value: T) {
  return { value };
}

let f = compose(list, box);
let x = f(100);

// 在 TypeScript 3.4 中, 'x.value' 的类型为
//
//   number[]
//
// 但是在之前的版本中类型为
//
//   {}[]
//
// 因此,插入一个 `string` 类型是错误的
x.value.push('hello');

x 上的显式类型注释可以清除这个错误。

上下文返回类型作为上下文参数类型传入

TypeScript 现在使用函数调用时传入的类型(如下例中的 then)作为函数上下文参数类型(如下例中的箭头函数)。

function isEven(prom: Promise<number>): Promise<{ success: boolean }> {
  return prom.then<{ success: boolean }>(x => {
    return x % 2 === 0
      ? { success: true }
      : Promise.resolve({ success: false });
  });
}

这通常是一种改进,但在上面的例子中,它导致 truefalse 获取不合需要的字面量类型。

Argument of type '(x: number) => Promise<{ success: false; }> | { success: true; }' is not assignable to parameter of type '(value: number) => { success: false; } | PromiseLike<{ success: false; }>'.
  Type 'Promise<{ success: false; }> | { success: true; }' is not assignable to type '{ success: false; } | PromiseLike<{ success: false; }>'.
    Type '{ success: true; }' is not assignable to type '{ success: false; } | PromiseLike<{ success: false; }>'.
      Type '{ success: true; }' is not assignable to type '{ success: false; }'.
        Types of property 'success' are incompatible.

合适的解决方法是将类型参数添加到适当的调用——本例中的 then 方法调用。

function isEven(prom: Promise<number>): Promise<{ success: boolean }> {
  //               vvvvvvvvvvvvvvvvvv
  return prom.then<{ success: boolean }>(x => {
    return x % 2 === 0
      ? { success: true }
      : Promise.resolve({ success: false });
  });
}

strictFunctionTypes 之外一致性推断优先

在 TypeScript 3.3 中,关闭 --strictFunctionTypes 选项时,假定使用 interface 声明的泛型类型在其类型参数方面始终是协变的。对于函数类型,通常无法观察到此行为。

但是,对于带有 keyof 状态的类型参数的泛型 interface 类型——逆变用法——这些类型表现不正确。

在 TypeScript 3.4 中,现在可以在所有情况下正确探测使用 interface 声明的类型的变动。

这导致一个可见的重大变更,只要有类型参数的接口使用了 keyof(包括诸如 Record<K, T> 之类的地方,这是涉及 keyof K 的类型别名)。下例就是这样一个可能的变更。

interface HasX {
  x: any;
}
interface HasY {
  y: any;
}

declare const source: HasX | HasY;
declare const properties: KeyContainer<HasX>;

interface KeyContainer<T> {
  key: keyof T;
}

function readKey<T>(source: T, prop: KeyContainer<T>) {
  console.log(source[prop.key]);
}

// 这个调用应该被拒绝,因为我们可能会这样做
// 错误地从 'HasY' 中读取 'x'。它现在恰当的提示错误。
readKey(source, properties);

此错误很可能表明原代码存在问题。

参考

TypeScript 3.2

lib.d.ts 更新

wheelDelta 和它的小伙伴们被移除了。

wheelDeltaXwheelDeltawheelDeltaZ 全都被移除了,因为他们在 WheelEvents 上是废弃的属性。

解决办法:使用 deltaXdeltaYdeltaZ 代替。

更具体的类型

根据 DOM 规范的描述,某些参数现在接受更具体的类型,不再接受 null

参考

TypeScript 3.1

一些浏览器厂商特定的类型从lib.d.ts中被移除

TypeScript 内置的.d.ts库(lib.d.ts等)现在会部分地从 DOM 规范的 Web IDL 文件中生成。 因此有一些浏览器厂商特定的类型被移除了。

点击这里查看被移除类型的完整列表:

  • CanvasRenderingContext2D.mozImageSmoothingEnabled
  • CanvasRenderingContext2D.msFillRule
  • CanvasRenderingContext2D.oImageSmoothingEnabled
  • CanvasRenderingContext2D.webkitImageSmoothingEnabled
  • Document.caretRangeFromPoint
  • Document.createExpression
  • Document.createNSResolver
  • Document.execCommandShowHelp
  • Document.exitFullscreen
  • Document.exitPointerLock
  • Document.focus
  • Document.fullscreenElement
  • Document.fullscreenEnabled
  • Document.getSelection
  • Document.msCapsLockWarningOff
  • Document.msCSSOMElementFloatMetrics
  • Document.msElementsFromRect
  • Document.msElementsFromPoint
  • Document.onvisibilitychange
  • Document.onwebkitfullscreenchange
  • Document.onwebkitfullscreenerror
  • Document.pointerLockElement
  • Document.queryCommandIndeterm
  • Document.URLUnencoded
  • Document.webkitCurrentFullScreenElement
  • Document.webkitFullscreenElement
  • Document.webkitFullscreenEnabled
  • Document.webkitIsFullScreen
  • Document.xmlEncoding
  • Document.xmlStandalone
  • Document.xmlVersion
  • DocumentType.entities
  • DocumentType.internalSubset
  • DocumentType.notations
  • DOML2DeprecatedSizeProperty
  • Element.msContentZoomFactor
  • Element.msGetUntransformedBounds
  • Element.msMatchesSelector
  • Element.msRegionOverflow
  • Element.msReleasePointerCapture
  • Element.msSetPointerCapture
  • Element.msZoomTo
  • Element.onwebkitfullscreenchange
  • Element.onwebkitfullscreenerror
  • Element.webkitRequestFullScreen
  • Element.webkitRequestFullscreen
  • ElementCSSInlineStyle
  • ExtendableEventInit
  • ExtendableMessageEventInit
  • FetchEventInit
  • GenerateAssertionCallback
  • HTMLAnchorElement.Methods
  • HTMLAnchorElement.mimeType
  • HTMLAnchorElement.nameProp
  • HTMLAnchorElement.protocolLong
  • HTMLAnchorElement.urn
  • HTMLAreasCollection
  • HTMLHeadElement.profile
  • HTMLImageElement.msGetAsCastingSource
  • HTMLImageElement.msGetAsCastingSource
  • HTMLImageElement.msKeySystem
  • HTMLImageElement.msPlayToDisabled
  • HTMLImageElement.msPlayToDisabled
  • HTMLImageElement.msPlayToPreferredSourceUri
  • HTMLImageElement.msPlayToPreferredSourceUri
  • HTMLImageElement.msPlayToPrimary
  • HTMLImageElement.msPlayToPrimary
  • HTMLImageElement.msPlayToSource
  • HTMLImageElement.msPlayToSource
  • HTMLImageElement.x
  • HTMLImageElement.y
  • HTMLInputElement.webkitdirectory
  • HTMLLinkElement.import
  • HTMLMetaElement.charset
  • HTMLMetaElement.url
  • HTMLSourceElement.msKeySystem
  • HTMLStyleElement.disabled
  • HTMLSummaryElement
  • MediaQueryListListener
  • MSAccountInfo
  • MSAudioLocalClientEvent
  • MSAudioLocalClientEvent
  • MSAudioRecvPayload
  • MSAudioRecvSignal
  • MSAudioSendPayload
  • MSAudioSendSignal
  • MSConnectivity
  • MSCredentialFilter
  • MSCredentialParameters
  • MSCredentials
  • MSCredentialSpec
  • MSDCCEvent
  • MSDCCEventInit
  • MSDelay
  • MSDescription
  • MSDSHEvent
  • MSDSHEventInit
  • MSFIDOCredentialParameters
  • MSIceAddrType
  • MSIceType
  • MSIceWarningFlags
  • MSInboundPayload
  • MSIPAddressInfo
  • MSJitter
  • MSLocalClientEvent
  • MSLocalClientEventBase
  • MSNetwork
  • MSNetworkConnectivityInfo
  • MSNetworkInterfaceType
  • MSOutboundNetwork
  • MSOutboundPayload
  • MSPacketLoss
  • MSPayloadBase
  • MSPortRange
  • MSRelayAddress
  • MSSignatureParameters
  • MSStatsType
  • MSStreamReader
  • MSTransportDiagnosticsStats
  • MSUtilization
  • MSVideoPayload
  • MSVideoRecvPayload
  • MSVideoResolutionDistribution
  • MSVideoSendPayload
  • NotificationEventInit
  • PushEventInit
  • PushSubscriptionChangeInit
  • RTCIdentityAssertionResult
  • RTCIdentityProvider
  • RTCIdentityProviderDetails
  • RTCIdentityValidationResult
  • Screen.deviceXDPI
  • Screen.logicalXDPI
  • SVGElement.xmlbase
  • SVGGraphicsElement.farthestViewportElement
  • SVGGraphicsElement.getTransformToElement
  • SVGGraphicsElement.nearestViewportElement
  • SVGStylable
  • SVGTests.hasExtension
  • SVGTests.requiredFeatures
  • SyncEventInit
  • ValidateAssertionCallback
  • WebKitDirectoryEntry
  • WebKitDirectoryReader
  • WebKitEntriesCallback
  • WebKitEntry
  • WebKitErrorCallback
  • WebKitFileCallback
  • WebKitFileEntry
  • WebKitFileSystem
  • Window.clearImmediate
  • Window.msSetImmediate
  • Window.setImmediate

推荐:

如果你的运行时能够保证这些名称是可用的(比如一个仅针对 IE 的应用),那么可以在本地添加那些声明,例如:

对于Element.msMatchesSelector,在本地的dom.ie.d.ts文件里添加如下代码:

interface Element {
  msMatchesSelector(selectors: string): boolean;
}

相似地,若要添加clearImmediatesetImmediate,你可以在本地的dom.ie.d.ts里添加Window声明:

interface Window {
  clearImmediate(handle: number): void;
  setImmediate(handler: (...args: any[]) => void): number;
  setImmediate(handler: any, ...args: any[]): number;
}

细化的函数现在会使用{}Object和未约束的泛型参数的交叉类型

下面的代码如今会提示x不能被调用:

function foo<T>(x: T | (() => string)) {
  if (typeof x === 'function') {
    x();
    //      ~~~
    // Cannot invoke an expression whose type lacks a call signature. Type '(() => string) | (T & Function)' has no compatible call signatures.
  }
}

这是因为,不同于以前的T会被细化掉,如今T会被扩展成T & Function。 然而,因为这个类型没有声明调用签名,类型系统无法找到通用的调用签名可以适用于T & Function() => string

因此,考虑使用一个更确切的类型,而不是{}Object,并且考虑给T添加额外的约束条件。

TypeScript 3.0

保留关键字 unknown

unknown 现在是一个保留类型名称,因为它现在是一个内置类型。为了支持新引入的 unknown 类型,取决于你对 unknown 的使用方式,你可能需要完全移除变量申明,或者将其重命名。

未开启 strictNullChecks 时,与 null/undefined 交叉的类型会简化到 null/undefined

关闭 strictNullChecks 时,下例中 A 的类型为 null,而 B 的类型为 undefined

type A = { a: number } & null; // null
type B = { a: number } & undefined; // undefined

这是因为 TypeScript 3.0 更适合分别简化交叉类型和联合类型中的子类型和超类型。但是,因为当 strictNullChecks 关闭时,nullundefined 都被认为是所有其他类型的子类型,与某种对象类型的交集将始终简化为 nullundefined

建议

如果你在类型交叉的情况下依赖 nullundefined 作为单位元,你应该寻找一种方法来使用 unknown 而不是无论它们在哪里都是 nullundefined

参考

TypeScript 2.9

keyof 现在包括 stringnumbersymbol 键名

TypeScript 2.9 将索引类型泛化为包括 numbersymbol 命名属性。以前,keyof 运算符和映射类型仅支持 string 命名属性。

function useKey<T, K extends keyof T>(o: T, k: K) {
  var name: string = k; // 错误: keyof T 不能分配给 `string`
}

建议

  • 如果你的函数只能处理名字符串属性的键,请在声明中使用 Extract<keyof T,string>

    function useKey<T, K extends Extract<keyof T, string>>(o: T, k: K) {
      var name: string = k; // OK
    }
    
  • 如果你的函数可以处理所有属性键,那么更改应该是顺畅的:

    function useKey<T, K extends keyof T>(o: T, k: K) {
      var name: string | number | symbol = k;
    }
    
  • 除此之外,还可以使用 --keyofStringsOnly 编译器选项禁用新行为。

剩余参数后面不允许尾后逗号

以下代码是一个自 #22262 开始的编译器错误:

function f(a: number, ...b: number[]) {
  // 违规的尾随逗号
}

剩余参数上的尾随逗号不是有效的 JavaScript,并且,这个语法现在在 TypeScript 中也是一个错误。

strictNullChecks 中,无类型约束参数不再分配给 object

以下代码是自24013起在 strickNullChecks 下出现的编译器错误:

function f<T>(x: T) {
  const y: object | null | undefined = x;
}

它可以用任意类型(例如,stringnumber )来实现,因此允许它是不正确的。 如果您遇到此问题,请将您的类型参数约束为 object 以仅允许对象类型。如果想允许任何类型,使用 {} 进行比较而不是 object

参考

TypeScript 2.8

--noUnusedParameters下检查未使用的类型参数

根据 #20568,未使用的类型参数之前在--noUnusedLocals下报告,但现在报告在--noUnusedParameters下。

lib.d.ts中删除了一些 Microsoft 专用的类型

从 DOM 定义中删除一些 Microsoft 专用的类型以更好地与标准对齐。 删除的类型包括:

  • MSApp
  • MSAppAsyncOperation
  • MSAppAsyncOperationEventMap
  • MSBaseReader
  • MSBaseReaderEventMap
  • MSExecAtPriorityFunctionCallback
  • MSHTMLWebViewElement
  • MSManipulationEvent
  • MSRangeCollection
  • MSSiteModeEvent
  • MSUnsafeFunctionCallback
  • MSWebViewAsyncOperation
  • MSWebViewAsyncOperationEventMap
  • MSWebViewSettings

HTMLObjectElement不再具有alt属性

根据 #21386,DOM 库已更新以反映 WHATWG 标准。

如果需要继续使用alt属性,请考虑通过全局范围中的接口合并重新打开HTMLObjectElement

// Must be in a global .ts file or a 'declare global' block.
interface HTMLObjectElement {
  alt: string;
}

TypeScript 2.7

完整的破坏性改动列表请到这里查看:breaking change issues.

元组现在具有固定长度的属性

以下代码用于没有编译错误:

var pair: [number, number] = [1, 2];
var triple: [number, number, number] = [1, 2, 3];
pair = triple;

但是,这一个错误:

triple = pair;

现在,相互赋值是一个错误。 这是因为元组现在有一个长度属性,其类型是它们的长度。 所以pair.length: 2,但是triple.length: 3

请注意,之前允许某些非元组模式,但现在不再允许:

const struct: [string, number] = ['key'];
for (const n of numbers) {
  struct.push(n);
}

对此最好的解决方法是创建扩展 Array 的自己的类型:

interface Struct extends Array<string | number> {
  '0': string;
  '1'?: number;
}
const struct: Struct = ['key'];
for (const n of numbers) {
  struct.push(n);
}

allowSyntheticDefaultImports下,对于 TS 和 JS 文件来说默认导入的类型合成不常见

在过去,我们在类型系统中合成一个默认导入,用于 TS 或 JS 文件,如下所示:

export const foo = 12;

意味着模块的类型为{foo: number, default: {foo: number}}。 这是错误的,因为文件将使用__esModule标记发出,因此在加载文件时没有流行的模块加载器会为它创建合成默认值,并且类型系统推断的default成员永远不会在运行时存在。现在我们在ESModuleInterop标志下的发出中模拟了这个合成默认行为,我们收紧了类型检查器的行为,以匹配你期望在运行时所看到的内容。如果运行时没有其他工具的介入,此更改应仅指出错误的错误默认导入用法,应将其更改为命名空间导入。

更严格地检查索引访问泛型类型约束

以前,仅当类型具有索引签名时才计算索引访问类型的约束,否则它是any。这样就可以取消选中无效赋值。在 TS 2.7.1 中,编译器在这里有点聪明,并且会将约束计算为此处所有可能属性的并集。

interface O {
  foo?: string;
}

function fails<K extends keyof O>(o: O, k: K) {
  var s: string = o[k]; // Previously allowed, now an error
  // string | undefined is not assignable to a string
}

in表达式被视为类型保护

对于n in x表达式,其中n是字符串文字或字符串文字类型而x是联合类型,"true"分支缩小为具有可选或必需属性n的类型,并且 "false"分支缩小为具有可选或缺少属性n的类型。 如果声明类型始终具有属性n,则可能导致在 false 分支中将变量的类型缩小为never的情况。

var x: { foo: number };

if ('foo' in x) {
  x; // { foo: number }
} else {
  x; // never
}

在条件运算符中不减少结构上相同的类

以前在结构上相同的类在条件或||运算符中被简化为最佳公共类型。现在这些类以联合类型维护,以便更准确地检查instanceof运算符。

class Animal {}

class Dog {
  park() {}
}

var a = Math.random() ? new Animal() : new Dog();
// typeof a now Animal | Dog, previously Animal

CustomEvent现在是一个泛型类型

CustomEvent现在有一个details属性类型的类型参数。如果要从中扩展,则需要指定其他类型参数。

class MyCustomEvent extends CustomEvent {}

应该成为

class MyCustomEvent extends CustomEvent<any> {}

TypeScript 2.6

完整的破坏性改动列表请到这里查看:breaking change issues.

只写引用未使用

以下代码用于没有编译错误:

function f(n: number) {
  n = 0;
}

class C {
  private m: number;
  constructor() {
    this.m = 0;
  }
}

现在,当启用--noUnusedLocals--noUnusedParameters编译器选项时,nm都将被标记为未使用,因为它们的值永远不会被 。以前 TypeScript 只会检查它们的值是否被引用

此外,仅在其自己的实体中调用的递归函数被视为未使用。

function f() {
  f(); // Error: 'f' is declared but its value is never read
}

环境上下文中的导出赋值中禁止使用任意表达式

以前,像这样的结构

declare module 'foo' {
  export default 'some' + 'string';
}

在环境上下文中未被标记为错误。声明文件和环境模块中通常禁止使用表达式,因为typeof之类的意图不明确,因此这与我们在这些上下文中的其他地方处理可执行代码不一致。现在,任何不是标识符或限定名称的内容都会被标记为错误。为具有上述值形状的模块制作 DTS 的正确方法如下:

declare module 'foo' {
  const _default: string;
  export default _default;
}

编译器已经生成了这样的定义,因此这只应该是手工编写的定义的问题。

TypeScript 2.4

完整的破坏性改动列表请到这里查看:breaking change issues

弱类型检测

TypeScript 2.4 引入了“弱类型(weak type)”的概念。 若一个类型只包含可选的属性,那么它就被认为是*弱(weak)*的。 例如,下面的Options类型就是一个弱类型:

interface Options {
  data?: string;
  timeout?: number;
  maxRetries?: number;
}

TypeScript 2.4,当给一个弱类型赋值,但是它们之前没有共同的属性,那么就会报错。 例如:

function sendMessage(options: Options) {
  // ...
}

const opts = {
  payload: 'hello world!',
  retryOnFail: true,
};

// 错误!
sendMessage(opts);
// 'opts'与'Options'之间没有共同的属性
// 你是否想用'data'/'maxRetries'来替换'payload'/'retryOnFail'

推荐做法

  1. 仅声明那些确定存在的属性。
  2. 给弱类型添加索引签名(如:[propName: string]: {}
  3. 使用类型断言(如:opts as Options

推断返回值的类型

TypeScript 现在可从上下文类型中推断出一个调用的返回值类型。 这意味着一些代码现在会适当地报错。 下面是一个例子:

let x: Promise<string> = new Promise(resolve => {
  resolve(10);
  //      ~~ 错误! 'number'类型不能赋值给'string'类型
});

更严格的回调函数参数变化

TypeScript 对回调函数参数的检测将与立即签名检测协变。 之前是双变的,这会导致有时候错误的类型也能通过检测。 根本上讲,这意味着回调函数参数和包含回调的类会被更细致地检查,因此 Typescript 会要求更严格的类型。 这在 Promises 和 Observables 上是十分明显的。

Promises

下面是改进后的 Promise 检查的例子:

let p = new Promise((c, e) => { c(12) });
let u: Promise<number> = p;
    ~
    类型 'Promise<{}>' 不能赋值给 'Promise<number>'

TypeScript 无法在调用new Promise时推断类型参数T的值。 因此,它仅推断为Promise<{}>。 不幸的是,它会允许你这样写c(12)c('foo'),就算p的声明明确指出它应该是Promise<number>

在新的规则下,Promise<{}>不能够赋值给Promise<number>,因为它破坏了 Promise 的回调函数。 TypeScript 仍无法推断类型参数,所以你只能通过传递类型参数来解决这个问题:

let p: Promise<number> = new Promise<number>((c, e) => {
  c(12);
});
//                                  ^^^^^^^^ 明确的类型参数

它能够帮助从 promise 代码体里发现错误。 现在,如果你错误地调用c('foo'),你就会得到一个错误提示:

let p: Promise<number> = new Promise<number>((c, e) => {
  c('foo');
});
//                                                         ~~~~~
//  参数类型 '"foo"' 不能赋值给 'number'

(嵌套)回调

其它类型的回调也会被这个改进所影响,其中主要是嵌套的回调。 下面是一个接收回调函数的函数,回调函数又接收嵌套的回调。 嵌套的回调现在会以协变的方式检查。

declare function f(
  callback: (nested: (error: number, result: any) => void, index: number) => void
): void;

f((nested: (error: number) => void) => { log(error) });
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
'(error: number) => void' 不能赋值给 '(error: number, result: any) => void'

修复这个问题很容易。给嵌套的回调传入缺失的参数:

f((nested: (error: number, result: any) => void) => {});

更严格的泛型函数检查

TypeScript 在比较两个单一签名的类型时会尝试统一类型参数。 结果就是,当关系到两个泛型签名时检查变得更严格了,但同时也会捕获一些 bug。

type A = <T, U>(x: T, y: U) => [T, U];
type B = <S>(x: S, y: S) => [S, S];

function f(a: A, b: B) {
  a = b; // Error
  b = a; // Ok
}

推荐做法

或者修改定义或者使用--noStrictGenericChecks

从上下文类型中推荐类型参数

在 TypeScript 之前,下面例子中

let f: <T>(x: T) => T = y => y;

y的类型将是any。 这意味着,程序虽会进行类型检查,但是你可以在y上做任何事,比如:

let f: <T>(x: T) => T = y => y() + y.foo.bar;

推荐做法:

适当地重新审视你的泛型是否为正确的约束。实在不行,就为参数加上any注解。

TypeScript 2.3

完整的破坏性改动列表请到这里查看:breaking change issues.

空的泛型列表会被标记为错误

示例

class X<> {} // Error: Type parameter list cannot be empty.
function f<>() {} // Error: Type parameter list cannot be empty.
const x: X<> = new X<>(); // Error: Type parameter list cannot be empty.

TypeScript 2.2

完整的破坏性改动列表请到这里查看:breaking change issues.

标准库里的 DOM API 变动

  • 现在标准库里有Window.fetch的声明;仍依赖于@types\whatwg-fetch会产生声明冲突错误,需要被移除。
  • 现在标准库里有ServiceWorker的声明;仍依赖于@types\service_worker_api会产生声明冲突错误,需要被移除。

TypeScript 2.1

完整的破坏性改动列表请到这里查看:breaking change issues.

生成的构造函数代码将this的值替换为super(...)调用的返回值

在 ES2015 中,如果构造函数返回一个对象,那么对于任何super(...)的调用者将隐式地替换掉this的值。 因此,有必要获取任何可能的super(...)的返回值并用this进行替换。

示例

定义一个类C

class C extends B {
  public a: number;
  constructor() {
    super();
    this.a = 0;
  }
}

将生成如下代码:

var C = (function (_super) {
  __extends(C, _super);
  function C() {
    var _this = _super.call(this) || this;
    _this.a = 0;
    return _this;
  }
  return C;
})(B);

注意:

  • _super.call(this)存入局部变量_this
  • 构造函数体里所有使用this的地方都被替换为super调用的返回值(例如_this
  • 每个构造函数将明确地返回它的this,以确保正确的继承

值得注意的是在super(...)调用前就使用thisTypeScript 1.8开始将会引发错误。

继承内置类型如ErrorArrayMap将是无效的

做为将this的值替换为super(...)调用返回值的一部分,子类化ErrorArray等的结果可以是非预料的。 这是因为ErrorArray等的构造函数会使用 ECMAScript 6 的new.target来调整它们的原型链; 然而,在 ECMAScript 5 中调用构造函数时却没有有效的方法来确保new.target的值。 在默认情况下,其它低级别的编译器也普遍存在这个限制。

示例

针对如下的子类:

class FooError extends Error {
  constructor(m: string) {
    super(m);
  }
  sayHello() {
    return 'hello ' + this.message;
  }
}

你会发现:

  • 由这个子类构造出来的对象上的方法可能为undefined,因此调用sayHello会引发错误。
  • instanceof应用于子类与其实例之前会失效,因此(new FooError()) instanceof FooError会返回false

推荐

做为一个推荐,你可以在任何super(...)调用后立即手动地调整原型。

class FooError extends Error {
  constructor(m: string) {
    super(m);

    // Set the prototype explicitly.
    Object.setPrototypeOf(this, FooError.prototype);
  }

  sayHello() {
    return 'hello ' + this.message;
  }
}

但是,任何FooError的子类也必须要手动地设置原型。 对于那些不支持Object.setPrototypeOf的运行时环境,你可以使用__proto__

不幸的是,[这些变通方法在 IE10 及其之前的版本](https://msdn.microsoft.com/en-us/library/s4esdbwz(v=vs.94).aspx) 你可以手动地将方法从原型上拷贝到实例上(比如从FooError.prototypethis),但是原型链却是无法修复的。

const变量和readonly属性会默认地推断成字面类型

默认情况下,const声明和readonly属性不会被推断成字符串,数字,布尔和枚举字面量类型。这意味着你的变量/属性可能具有比之前更细的类型。这将体现在使用===!==的时候。

示例

const DEBUG = true; // 现在为`true`类型,之前为`boolean`类型

if (DEBUG === false) { // 错误: 操作符'==='不能应用于'true'和'false'
    ...
}

推荐

针对故意要求更加宽泛类型的情况下,将类型转换成基础类型:

const DEBUG = <boolean>true; // `boolean`类型

不对函数和类表达式里捕获的变量进行类型细化

当泛型类型参数具有stringnumberboolean约束时,会被推断为字符串,数字和布尔字面量类型。此外,如果字面量类型有相同的基础类型(如string),当没有字面量类型做为推断的最佳超类型时这个规则会失效。

示例

declare function push<T extends string>(...args: T[]): T;

var x = push('A', 'B', 'C'); // 推断成 "A" | "B" | "C" 在TS 2.1, 在TS 2.0里为 string

推荐

在调用处明确指定参数类型:

var x = push<string>('A', 'B', 'C'); // x是string

没有注解的 callback 参数如果没有与之匹配的重载参数会触发 implicit-any 错误

在之前编译器默默地赋予 callback(下面的c)的参数一个any类型。原因关乎到编译器如何解析重载的函数表达式。从 TypeScript 2.1 开始,在使用--noImplicitAny时,这会触发一个错误。

示例

declare function func(callback: () => void): any;
declare function func(callback: (arg: number) => void): any;

func(c => {});

推荐

删除第一个重载,因为它实在没什么意义;上面的函数可以使用 1 个或 0 个必须参数调用,因为函数可以安全地忽略额外的参数。

declare function func(callback: (arg: number) => void): any;

func(c => {});
func(() => {});

或者,你可以给 callback 的参数指定一个明确的类型:

func((c: number) => {});

逗号操作符使用在无副作用的表达式里时会被标记成错误

大多数情况下,这种在之前是有效的逗号表达式现在是错误。

示例

let x = Math.pow((3, 5)); // x = NaN, was meant to be `Math.pow(3, 5)`

// This code does not do what it appears to!
let arr = [];
switch (arr.length) {
  case (0, 1):
    return 'zero or one';
  default:
    return 'more than one';
}

推荐

--allowUnreachableCode会禁用产生警告在整个编译过程中。或者,你可以使用void操作符来镇压这个逗号表达式错误:

let a = 0;
let y = (void a, 1); // no warning for `a`

标准库里的 DOM API 变动

  • Node.firstChildNode.lastChildNode.nextSiblingNode.previousSiblingNode.parentElementNode.parentNode现在是Node | null而非Node

    查看#11113了解详细信息。

    推荐明确检查null或使用!断言操作符(比如node.lastChild!)。

TypeScript 2.0

完整的破坏性改动列表请到这里查看:breaking change issues.

对函数或类表达式的捕获变量不进行类型细化(narrowing)

类型细化不会在函数,类和 lambda 表达式上进行。

例子

var x: number | string;

if (typeof x === 'number') {
  function inner(): number {
    return x; // Error, type of x is not narrowed, c is number | string
  }
  var y: number = x; // OK, x is number
}

编译器不知道回调函数什么时候被执行。考虑下面的情况:

var x: number | string = 'a';
if (typeof x === 'string') {
  setTimeout(() => console.log(x.charAt(0)), 0);
}
x = 5;

x.charAt()被调用的时候把x的类型当作string是错误的,事实上它确实不是string类型。

推荐

使用常量代替:

const x: number | string = 'a';
if (typeof x === 'string') {
  setTimeout(() => console.log(x.charAt(0)), 0);
}

泛型参数会进行类型细化

例子

function g<T>(obj: T) {
  var t: T;
  if (obj instanceof RegExp) {
    t = obj; // RegExp is not assignable to T
  }
}

推荐 可以把局部变量声明为特定类型而不是泛型参数或者使用类型断言。

只有 get 而没有 set 的存取器会被自动推断为readonly属性

例子

class C {
  get x() {
    return 0;
  }
}

var c = new C();
c.x = 1; // Error Left-hand side is a readonly property

推荐

定义一个不对属性写值的 setter。

在严格模式下函数声明不允许出现在块(block)里

在严格模式下这已经是一个运行时错误。从 TypeScript 2.0 开始,它会被标记为编译时错误。

例子

if (true) {
  function foo() {}
}

export = foo;

推荐

使用函数表达式代替:

if (true) {
  const foo = function () {};
}

TemplateStringsArray现是是不可变的

ES2015 模版字符串总是将它们的标签以不可变的类数组对象进行传递,这个对象带有一个raw属性(同样是不可变的)。 TypeScript 把这个对象命名为TemplateStringsArray

便利的是,TemplateStringsArray可以赋值给Array<string>,因此你可以利用这个较短的类型来使用标签参数:

function myTemplateTag(strs: string[]) {
  // ...
}

然而,在 TypeScript 2.0,支持用readonly修饰符表示这些对象是不可变的。 这样的话,TemplateStringsArray 就变成了不可变的,并且不再可以赋值给string[]

推荐

直接使用TemplateStringsArray(或者使用ReadonlyArray<string>)。

TypeScript 1.8

完整的破坏性改动列表请到这里查看:breaking change issues

现在生成模块代码时会带有"use strict";

在ES6模式下模块总是在严格模式下解析,对于生成目标为非ES6的却不是这样。从TypeScript 1.8开始,生成的模块将总为严格模式。这应该不会对现有的大部分代码产生影响,因为TypeScript把大多数因为严格模式而产生的错误当做编译时错误,但还是有一些在运行时才发生错误的TypeScript代码,比如赋值给NaN,现在将会直接报错。你可以参考MDN Article学习关于严格模式与非严格模式的区别。

若想禁用这个行为,在命令行里传--noImplicitUseStrict选项或在tsconfig.json文件里指定。

从模块里导出非局部名称

依据ES6/ES2015规范,从模块里导出非局部名称将会报错。

例子

export { Promise }; // Error

推荐

在导出之前,使用局部变量声明捕获那个全局名称。

const localPromise = Promise;
export { localPromise as Promise };

默认启用代码可达性(Reachability)检查

TypeScript 1.8里,我们添加了一些可达性检查来阻止一些种类的错误。特别是:

  1. 检查代码的可达性(默认启用,可以通过allowUnreachableCode编译器选项禁用)

       function test1() {
           return 1;
           return 2; // error here
       }
    
       function test2(x) {
           if (x) {
               return 1;
           }
           else {
               throw new Error("NYI")
           }
           var y = 1; // error here
       }
    
  2. 检查标签是否被使用(默认启用,可以通过allowUnusedLabels编译器选项禁用)

    l: // error will be reported - label `l` is unused
    while (true) {
    }
    
    (x) => { x:x } // error will be reported - label `x` is unused
    
  3. 检查是否函数里所有带有返回值类型注解的代码路径都返回了值(默认启用,可以通过noImplicitReturns编译器选项禁用)

    // error will be reported since function does not return anything explicitly when `x` is falsy.
    function test(x): number {
       if (x) return 10;
    }
    
  4. 检查控制流是否能进到switch语句的case里(默认禁用,可以通过noFallthroughCasesInSwitch编译器选项启用)。注意没有语句的case不会被检查。

    switch(x) {
       // OK
       case 1:
       case 2:
           return 1;
    }
    switch(x) {
       case 1:
           if (y) return 1;
       case 2:
           return 2;
    }
    

如果你看到了这些错误,但是你认为这时的代码是合理的话,你可以通过编译选项来阻止报错。

--module不允许与--outFile一起出现,除非 --module被指定为amdsystem

之前使用模块指定这两个的时候,会生成空的out文件且不会报错。

标准库里的DOM API变动

  • ImageData.data现在的类型为Uint8ClampedArray而不是number[]。查看#949
  • HTMLSelectElement .options现在的类型为HTMLCollection而不是HTMLSelectElement。查看#1558
  • HTMLTableElement.createCaptionHTMLTableElement.createTBodyHTMLTableElement.createTFootHTMLTableElement.createTHeadHTMLTableElement.insertRowHTMLTableSectionElement.insertRowHTMLTableElement.insertRow现在返回HTMLTableRowElement而不是HTMLElement。查看#3583
  • HTMLTableRowElement.insertCell现在返回HTMLTableCellElement而不是HTMLElement查看#3583
  • IDBObjectStore.createIndexIDBDatabase.createIndex第二个参数类型为IDBObjectStoreParameters而不是any。查看#5932
  • DataTransferItemList.Item返回值类型变为DataTransferItem而不是File。查看#6106
  • Window.open返回值类型变为Window而不是any。查看#6418
  • WeakMap.clear被移除。查看#6500

在super-call之前不允许使用this

ES6不允许在构造函数声明里访问this

比如:

class B {
    constructor(that?: any) {}
}

class C extends B {
    constructor() {
        super(this);  // error;
    }
}

class D extends B {
    private _prop1: number;
    constructor() {
        this._prop1 = 10;  // error
        super();
    }
}

TypeScript 1.7

完整的破坏性改动列表请到这里查看:breaking change issues

this中推断类型发生了变化

在类里,this值的类型将被推断成this类型。 这意味着随后使用原始类型赋值时可能会发生错误。

例子:

class Fighter {
  /** @returns the winner of the fight. */
  fight(opponent: Fighter) {
    let theVeryBest = this;
    if (Math.rand() < 0.5) {
      theVeryBest = opponent; // error
    }
    return theVeryBest;
  }
}

推荐:

添加类型注解:

class Fighter {
  /** @returns the winner of the fight. */
  fight(opponent: Fighter) {
    let theVeryBest: Fighter = this;
    if (Math.rand() < 0.5) {
      theVeryBest = opponent; // no error
    }
    return theVeryBest;
  }
}

类成员修饰符后面会自动插入分号

关键字abstract,public,protectedprivate是 ECMAScript 3 里的保留关键字并适用于自动插入分号机制。 之前,在这些关键字出现的行尾,TypeScript 是不会插入分号的。 现在,这已经被改正了,在上例中abstract class D不再能够正确地继承C了,而是声明了一个m方法和一个额外的属性abstract

注意,asyncdeclare已经能够正确自动插入分号了。

例子:

abstract class C {
  abstract m(): number;
}
abstract class D extends C {
  abstract;
  m(): number;
}

推荐:

在定义类成员时删除关键字后面的换行。通常来讲,要避免依赖于自动插入分号机制。

TypeScript 1.6

完整的破坏性改动列表请到这里查看:breaking change issues

严格的对象字面量赋值检查

当在给变量赋值或给非空类型的参数赋值时,如果对象字面量里指定的某属性不存在于目标类型中时会得到一个错误。

你可以通过使用--suppressExcessPropertyErrors编译器选项来禁用这个新的严格检查。

例子:

var x: { foo: number };
x = { foo: 1, baz: 2 }; // Error, excess property `baz`

var y: { foo: number; bar?: number };
y = { foo: 1, baz: 2 }; // Error, excess or misspelled property `baz`

推荐:

为了避免此错误,不同情况下有不同的补救方法:

如果目标类型接收额外的属性,可以增加一个索引:

var x: { foo: number; [x: string]: any };
x = { foo: 1, baz: 2 }; // OK, `baz` matched by index signature

如果原始类型是一组相关联的类型,使用联合类型明确指定它们的类型而不是仅指定一个基本类型。

let animalList: (Dog | Cat | Turkey)[] = [
  // use union type instead of Animal
  { name: 'Milo', meow: true },
  { name: 'Pepper', bark: true },
  { name: 'koko', gobble: true },
];

还有可以明确地转换到目标类型以避免此错误:

interface Foo {
  foo: number;
}
interface FooBar {
  foo: number;
  bar: number;
}
var y: Foo;
y = <FooBar>{ foo: 1, bar: 2 };

CommonJS 的模块解析不再假设路径为相对的

之前,对于one.tstwo.ts文件,如果它们在相同目录里,那么在two.ts里面导入"one"时是相对于one.ts的路径的。

TypeScript 1.6 在编译 CommonJS 时,"one"不再等同于"./one"。取而代之的是会相对于合适的node_modules文件夹进行查找,与 Node.js 在运行时解析模块相似。更多详情,阅读the issue that describes the resolution algorithm

例子:

./one.ts

export function f() {
  return 10;
}

./two.ts

import { f as g } from 'one';

推荐:

修改所有计划之外的非相对的导入。

./one.ts

export function f() {
  return 10;
}

./two.ts

import { f as g } from './one';

--moduleResolution编译器选项设置为classic

函数和类声明为默认导出时不再能够与在意义上有交叉的同名实体进行合并

在同一空间内默认导出声明的名字与空间内一实体名相同时会得到一个错误;比如,

export default function foo() {}

namespace foo {
  var x = 100;
}

export default class Foo {
  a: number;
}

interface Foo {
  b: string;
}

两者都会报错。

然而,在下面的例子里合并是被允许的,因为命名空间并不具备做为值的意义:

export default class Foo {}

namespace Foo {}

推荐:

为默认导出声明本地变量并使用单独的export default语句:

class Foo {
  a: number;
}

interface foo {
  b: string;
}

export default Foo;

更多详情,请阅读the originating issue

模块体以严格模式解析

按照ES6 规范,模块体现在以严格模式进行解析。行为将相当于在模块作用域顶端定义了"use strict";它包括限制了把argumentseval做为变量名或参数名的使用,把未来保留字做为变量或参数使用,八进制数字字面量的使用等。

标准库里 DOM API 的改动

  • MessageEventProgressEvent构造函数希望传入参数;查看issue #4295
  • ImageData构造函数希望传入参数;查看issue #4220
  • File构造函数希望传入参数;查看issue #3999

系统模块输出使用批量导出

编译器以系统模块的格式使用新的_export函数批量导出的变体,它接收任何包含键值对的对象做为参数而不是 key, value。

模块加载器需要升级到v0.17.1或更高。

npm 包的.js 内容从'bin'移到了'lib'

TypeScript 的 npm 包入口位置从bin移动到了lib,以防‘node_modules/typescript/bin/typescript.js’通过 IIS 访问的时候造成阻塞(bin默认是隐藏段因此 IIS 会阻止访问这个文件夹)。

TypeScript 的 npm 包不会默认全局安装

TypeScript 1.6 从 package.json 里移除了preferGlobal标记。如果你依赖于这种行为,请使用npm install -g typescript

装饰器做为调用表达式进行检查

从 1.6 开始,装饰器类型检查更准确了;编译器会将装饰器表达式做为以被装饰的实体做为参数的调用表达式来进行检查。这可能会造成以前的代码报错。

TypeScript 1.5

完整的破坏性改动列表请到这里查看:breaking change issues

不允许在箭头函数里引用arguments

这是为了遵循 ES6 箭头函数的语义。之前箭头函数里的arguments会绑定到箭头函数的参数。参照ES6 规范草稿 9.2.12,箭头函数不存在arguments对象。 从 TypeScript 1.5 开始,在箭头函数里使用arguments会被标记成错误以确保你的代码转成 ES6 时没语义上的错误。

例子:

function f() {
  return () => arguments; // Error: The 'arguments' object cannot be referenced in an arrow function.
}

推荐:

// 1. 使用带名字的剩余参数
function f() {
  return (...args) => {
    args;
  };
}

// 2. 使用函数表达式
function f() {
  return function () {
    arguments;
  };
}

内联枚举引用的改动

对于正常的枚举,在 1.5 之前,编译器仅会内联常量成员,且成员仅在使用字面量初始化时才被当做是常量。这在判断检举值是使用字面量初始化还是表达式时会行为不一致。从 TypeScript 1.5 开始,所有非 const 枚举成员都不会被内联。

例子:

var x = E.a; // previously inlined as "var x = 1; /*E.a*/"

enum E {
  a = 1,
}

推荐: 在枚举声明里添加const修饰符来确保它总是被内联。 更多信息,查看#2183

上下文的类型将作用于super和括号表达式

在 1.5 之前,上下文的类型不会作用于括号表达式内部。这就要求做显示的类型转换,尤其是在必须使用括号来进行表达式转换的场合。

在下面的例子里,m具有上下文的类型,它在之前的版本里是没有的。

var x: SomeType = n => m => q;
var y: SomeType = t ? m => m.length : undefined;

class C extends CBase<string> {
  constructor() {
    super({
      method(m) {
        return m.length;
      },
    });
  }
}

更多信息,查看#1425#920

DOM 接口的改动

TypeScript 1.5 改进了lib.d.ts库里的 DOM 类型。这是自 TypeScript 1.0 以来第一次大的改动;为了拥抱标准 DOM 规范,很多特定于 IE 的定义被移除了,同时添加了新的类型如 Web Audio 和触摸事件。

变通方案:

你可以使用旧的lib.d.ts配合新版本的编译器。你需要在你的工程里引入之前版本的一个拷贝。这里是本次改动之前的 lib.d.ts 文件(TypeScript 1.5-alpha)

变动列表:

  • 属性selectionDocument类型上移除
  • 属性clipboardDataWindow类型上移除
  • 删除接口MSEventAttachmentTarget
  • 属性onresizedisableduniqueIDremoveNodefireEventcurrentStyleruntimeStyleHTMLElement类型上移除
  • 属性urlEvent类型上移除
  • 属性execScriptnavigateitemWindow类型上移除
  • 属性documentModeparentWindowcreateEventObjectDocument类型上移除
  • 属性parentWindowHTMLDocument类型上移除
  • 属性setCapture被完全移除
  • 属性releaseCapture被完全移除
  • 属性setAttributestyleFloatpixelLeftCSSStyleDeclaration类型上移除
  • 属性selectorTextCSSRule类型上移除
  • CSSStyleSheet.rules现在是CSSRuleList类型,而非MSCSSRuleList
  • documentElement现在是Element类型,而非HTMLElement
  • Event具有一个新的必需属性returnValue
  • Node具有一个新的必需属性baseURI
  • Element具有一个新的必需属性classList
  • Location具有一个新的必需属性origin
  • 属性MSPOINTER_TYPE_MOUSEMSPOINTER_TYPE_TOUCHMSPointerEvent类型上移除
  • CSSStyleRule具有一个新的必需属性readonly
  • 属性execUnsafeLocalFunctionMSApp类型上移除
  • 全局方法toStaticHTML被移除
  • HTMLCanvasElement.getContext现在返回CanvasRenderingContext2D | WebGLRenderingContex
  • 移除扩展类型DataviewWeakmapMapSet
  • XMLHttpRequest.send具有两个重载send(data?: Document): void;send(data?: String): void;
  • window.orientation现在是string类型,而非number
  • 特定于 IE 的attachEventdetachEventWindow上移除

以下是被新加的 DOM 类型所部分或全部取代的代码库的代表:

  • DefinitelyTyped/auth0/auth0.d.ts
  • DefinitelyTyped/gamepad/gamepad.d.ts
  • DefinitelyTyped/interactjs/interact.d.ts
  • DefinitelyTyped/webaudioapi/waa.d.ts
  • DefinitelyTyped/webcrypto/WebCrypto.d.ts

更多信息,查看完整改动

类代码体将以严格格式解析

按照ES6 规范,类代码体现在以严格模式进行解析。行为将相当于在类作用域顶端定义了"use strict";它包括限制了把argumentseval做为变量名或参数名的使用,把未来保留字做为变量或参数使用,八进制数字字面量的使用等。

TypeScript 1.4

完整的破坏性改动列表请到这里查看:breaking change issues

阅读issue #868以了解更多关于联合类型的破坏性改动。

多个最佳通用类型候选

当有多个最佳通用类型可用时,现在编译器会做出选择(依据编译器的具体实现)而不是直接使用第一个。

var a: { x: number; y?: number };
var b: { x: number; z?: number };

// 之前 { x: number; z?: number; }[]
// 现在 { x: number; y?: number; }[]
var bs = [b, a];

这会在多种情况下发生。具有一组共享的必需属性和一组其它互斥的(可选或其它)属性,空类型,兼容的签名类型(包括泛型和非泛型签名,当类型参数上应用了any时)。

推荐 使用类型注解指定你要使用的类型。

var bs: { x: number; y?: number; z?: number }[] = [b, a];

泛型接口

当在多个 T 类型的参数上使用了不同的类型时会得到一个错误,就算是添加约束也不行:

declare function foo<T>(x: T, y: T): T;
var r = foo(1, ''); // r used to be {}, now this is an error

添加约束:

interface Animal {
  x;
}
interface Giraffe extends Animal {
  y;
}
interface Elephant extends Animal {
  z;
}
function f<T extends Animal>(x: T, y: T): T {
  return undefined;
}
var g: Giraffe;
var e: Elephant;
f(g, e);

在这里查看详细解释

推荐 如果这种不匹配的行为是故意为之,那么明确指定类型参数:

var r = foo<{}>(1, ''); // Emulates 1.0 behavior
var r = foo<string | number>(1, ''); // Most useful
var r = foo<any>(1, ''); // Easiest
f<Animal>(g, e);

重写函数定义指明就算不匹配也没问题:

declare function foo<T, U>(x: T, y: U): T | U;
function f<T extends Animal, U extends Animal>(x: T, y: U): T | U {
  return undefined;
}

泛型剩余参数

不能再使用混杂的参数类型:

function makeArray<T>(...items: T[]): T[] {
  return items;
}
var r = makeArray(1, ''); // used to return {}[], now an error

new Array(...)也一样

推荐 声明向后兼容的签名,如果 1.0 的行为是你想要的:

function makeArray<T>(...items: T[]): T[];
function makeArray(...items: {}[]): {}[];
function makeArray<T>(...items: T[]): T[] {
  return items;
}

带类型参数接口的重载解析

var f10: <T>(x: T, b: () => (a: T) => void, y: T) => T;
var r9 = f10('', () => a => a.foo, 1); // r9 was any, now this is an error

推荐 手动指定一个类型参数

var r9 = f10<any>('', () => a => a.foo, 1);

类声明与类型表达式以严格模式解析

ECMAScript 2015 语言规范(ECMA-262 6th Edition)指明ClassDeclarationClassExpression使用严格模式。 因此,在解析类声明或类表达式时将使用额外的限制。

例如:

class implements {} // Invalid: implements is a reserved word in strict mode
class C {
  foo(arguments: any) {
    // Invalid: "arguments" is not allow as a function argument
    var eval = 10; // Invalid: "eval" is not allowed as the left-hand-side expression
    arguments = []; // Invalid: arguments object is immutable
  }
}

关于严格模式限制的完整列表,请阅读 Annex C - The Strict Mode of ECMAScript of ECMA-262 6th Edition。