前言

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];
  newMember: boolean;
}

下面来看看最简单的映射类型和它的组成部分:

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

当多个装饰器应用于一个声明上,它们求值方式与复合函数相似。在这个模型下,当复合_f_和_g_时,复合的结果(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. 成员的_属性描述符_。

注意  如果代码输出目标版本小于ES5,_Property 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的未来版本中考虑实现。

手册 v2

模版字面量类型

从 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;
}

声明文件一章的目的是教你如何编写高质量的 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.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 配置:

也可以用 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", { assert: { "resolution-mode": "require" } }).TypeFromRequire;

export type TypeFromImport =
    import("pkg", { assert: { "resolution-mode": "import" } }).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类型更加安全,因为它要求在使用之前必须进行类型检