TypeScript学习指南-V1.0

〇、课程介绍

基础篇

一、TypeScript介绍

1.1 什么是TypeScript

在引入编程社区 20 多年后,JavaScript 现在已成为有史以来应用最广泛的跨平台语言之一。JavaScript 最初是一种用于向网页添加微不足道的交互性的小型脚本语言,现已发展成为各种规模的前端和后端应用程序的首选语言。虽然用 JavaScript 编写的程序的大小、范围和复杂性呈指数级增长,但 JavaScript 语言表达不同代码单元之间关系的能力却没有。结合 JavaScript 相当奇特的运行时语义,语言和程序复杂性之间的这种不匹配使得 JavaScript 开发成为一项难以大规模管理的任务。

程序员编写的最常见的错误类型可以描述为类型错误:在预期不同类型的值的地方使用了某种类型的值。这可能是由于简单的拼写错误、无法理解库的 API 表面、对运行时行为的错误假设或其他错误。TypeScript 的目标是成为 JavaScript 程序的静态类型检查器——换句话说,是一个在代码运行之前运行的工具(静态)并确保程序的类型正确(类型检查)。

TypeScript 是一种由微软开发的自由和开源的编程语言。它是 JavaScript 的一个超集,而且本质上向这个语言添加了可选的静态类型和基于类的面向对象编程。

TypeScript 是一种非常受欢迎的 JavaScript 语言扩展。它在现有的 JavaScript 语法之上加入了一层类型层,而这一层即使被删除,也丝毫不会影响运行时的原有表现。许多人认为 TypeScript “只是一个编译器”,但更好的理解其实是把 TypeScript 看作两个独立的系统:编译器(即处理语法的部分)和语言工具(即处理与编辑器集成的部分)。通过独立看待这两个系统,就可以得到能够解释我们之前所做决策的两个重要视角。

npm[3] 上,TypeScript 的下载量每年都在翻倍。截止2021 年 12 月 1 日,它的每周下载量超过为 2200 万次。而在去年 12 月,这一数字约为 1200 万次。它仍保持着高速增长的趋势,没有任何放缓的迹象。

从 2.0 版本开始,TypeScript 每两月定期发布一个 release。但是现在放缓了发布的节奏,改为每三个月发布一次。其中会花一个月编写新 features 并发布 beta 版本,剩下两个月对 beta 版进行测试和 bug 修复,这使得后续的发布更加稳定。

1.2 JS,ES,TS的关系

  • 1995年:JavaScript

当时的网景公司正凭借其Navigator浏览器成为Web时代开启时最著名的第一代互联网公司。

由于网景公司希望能在静态HTML页面上添加一些动态效果,于是 Brendan Eich 在两周之内设计出了JavaScript语言。

为什么起名叫JavaScript?原因是当时Java语言非常红火,所以网景公司希望借Java的名气来推广,但事实上JavaScript除了语法上有点像Java,其他部分基本上没啥关系。

  • 1997年:ECMAScript

因为网景开发了JavaScript,一年后微软又模仿JavaScript开发了JScript,为了让JavaScript成为全球标准,几个公司联合ECMA(European Computer Manufacturers Association)(欧洲计算机制造商协会)组织制定了JavaScript 语言的标准,被称为ECMAScript标准。

版本 发布时间 一般称呼 简称
第1版 1997年6月 ECMAScript 1 ES1
第2版 1998年4月 ECMAScript 2 ES2
第3版 1999年12月 ECMAScript 3 ES3
第4版 2007年10月草案 ECMAScript 4 ES4
第5版 2009年12月 ECMAScript 5 ES5
第6版 2015年6月 ECMAScript 2015 ES6
第7版 2016年6月 ECMAScript 2016 ES7
第8版 2017年6月 ECMAScript 2017 ES8
第9版 2018年6月 ECMAScript 2018 ES9
第10版 2019年6月 ECMAScript 2019 ES10
第11版 2020年6月 ECMAScript 2020 ES11
第12版 2021年6月 ECMAScript 2021 ES12
  • 2015年:TypeScript

TypeScript 是 JavaScript 的超集,即包含JavaScript 的所有元素,能运行JavaScript 的代码,并扩展了JavaScript 的语法。相比于JavaScript ,它还增加了静态类型、类、模块、接口和类型注解方面的功能,更易于大项目的开发。

TypeScript 提供最新的和不断发展的 JavaScript 特性,包括那些来自 2015 年的 ECMAScript 和未来的提案中的特性,比如异步功能和 Decorators,以帮助建立健壮的组件。下图显示了 TypeScript 与 ES5、ES2015+ 之间的关系:

1.3 TypeScript 与 JavaScript 的区别

TypeScript JavaScript
JavaScript 的超集用于解决大型项目的代码复杂性 一种脚本语言,用于创建动态网页
可以在编译期间发现并纠正错误 作为一种解释型语言,只能在运行时发现错误
强类型,支持静态和动态类型 弱类型,没有静态类型选项
最终被编译成 JavaScript 代码,使浏览器可以理解 可以直接在浏览器中使用
支持模块、泛型和接口 不支持模块、泛型或接口
支持 ES3,ES4,ES5 和 ES6+功能 不支持编译其他 ES3,ES4,ES5 或 ES6+ 功能
社区的支持仍在增长,而且还不是很大 大量的社区支持以及大量文档和解决问题的支持

1.4 TypeScript 的竞争者有哪些?

TypeScript 的目标是为人们提供编写大型 JavaScript 项目并对后期维护有信心的工具。JavaScript 本身没有的语法支持表示每个标识符的类型,除非运行 JavaScript 并在运行时进行检测。为了解决这个问题,TypeScript 添加了额外的语法。

所以,如果说我们的目标是作为工具提供支持,那么在这个领域有少数几个竞争者是 TypeScript 无法与之竞争的:

  • ESLint 和 TSLint:与 TypeScript 的定位相同,它们都是用来突出代码中可能出现的错误,只是没有为检查过程添加新的语法。两者都不打算作为 IDE 集成的工具运行,而且 TS 和 TS/ESLint 经常会说那些对项目没有意义的特性“是对方的领域”。在现代代码中,TS/ESLint 的存在使得 TypeScript 可以做更少的检查,这些检查并不适用于所有代码库。虽然有一些功能重叠了,但可以把它们作为很好的补充工具。
  • CoffeeScript:嘿,TypeScript 是 2012 年发布的!CoffeeScript 和 TypeScript 的区别在于 CoffeeScript 想要改进 JavaScript 语言,比如给 JavaScript 添加一些特性。这意味着要了解 CoffeeScript 与其输出的 JavaScript 的区别。随着时间推移,CoffeeScript 的最佳理念反而将其变成了另一个 JavaScript,人们为几乎成为了 JavaScript 的 CoffeeScript 感到困扰。
  • Flow:这是 Facebook 的 JavaScript 类型检查工具和 IDE 工具语言。就像 TypeScript 一样,Flow 为 JavaScript 添加了一些额外的语法支持,让你拥有了一个更加丰富的类型系统,然后在编译时再将其删除。当我刚开始写 JavaScript 时,Flow 是我最先使用的工具,因为它更接近标准的 JavaScript。Flow 是一个很棒的类型系统,它与 TypeScript 有着不同的目标。任何看不见的类型层系统都必须不断做出“正确”或者“感觉足够正确”的决定,Flow 的目标是“正确”(译者注:Flow 偏向于 soundness[6],在类型判断中更加悲观),而 TypeScript 的目标是“感觉上大部分情况都是正确的”(译者注:而 TS 官方声称 TS 不是类型完备的[7],允许 unsound 行为,偏向于 completeness,在类型判断中更加乐观)。鱼和熊掌不可兼得,完备的类型推导、良好的开发体验和完美的 JS 协同(Perfect JavaScript Interop)只能取其二。

那么,为什么大多数开源 Flow 代码库最终都迁移到了 TypeScript 呢?在我看来,很大程度上是由两个团队不同的侧重点决定。Flow 是为了维护 Facebook 的代码库而建立的,而 TypeScript 是作为一种独立的语言建立的。这里有两个证据可以证明:

  1. Facebook 的代码库是一个不能被分割的巨大的 monorepo,而 Flow 团队为了使类型运行在这样的大代码库[8]下做了大量令人难以置信的工作[9]。另一方面,TypeScript 可以说是“为构建小代码库服务(use projects to make sets of smaller codebases)”,因为这符合人们在开源社区中编写 JavaScript 模块的方式。我认为这么说很合理,TypeScript 不能像 Flow 一样运行在 Facebook 的代码库上,它要么需要大量重写 Facebook 的代码来构建项目,要么需要对 TypeScript 进行大量修改,这可能会影响到 TypeScript 整体开发者的体验。
  2. 对比 DefinitelyTyped 和 Flow 对类型的做法,TypeScript 团队会轮值一名编译器工程师为 DefinitelyTyped 支持我们的构建工具,并帮助管理社区。而 Flow,它几乎完全由社区维护。DT 现在规模更大了,因为它们一直致力于非 Facebook 代码的开发,这将很难获得 Flow 团队的资金支持。

微软给 TypeScript 在内部创造的独立环境让它可以自由专注于工具开发和整个生态系统的维护,而不是只专注于解决某个特别困难的问题。这让 TypeScript 团队能够与许多人合作,不断发布社区想要的功能。随着时间的推移,我猜想因为外部的需求增长放缓,Flow 团队越来越难为社区工作分配时间。这就形成了一个恶性循环。这使得 Flow 今天不再是 TypeScript 的直接“竞争者”,而是一个关于如何从不同的角度,使用不同的约束去解决类似的问题的有趣视角。

1.5 TypeScript的未来

1.5.1 对 TypeScript 的未来怎么看?

目前阻碍人们使用 TypeScript 的最大障碍是它需要构建工具。我认为类型语法不太可能被加入 JavaScript 中,但是在 JavaScript 中“用注释的方式定义类型”的可能性非常大。

这个想法是为 TypeScript 这样的类型系统创建一套语法,但是不定义 JS 运行时会发生什么。

const a: string = "1234"

// 将会变成这样
const a/*: string */ = "1234"

// 传入 JS 引擎

在这个例子中,JS 引擎会知道 : string 是一个类型注释,在 = 处结尾。这实际的工作方式是复杂的,需要时间来弄清楚。然而,让 TypeScript 能在 JavaScript 中“原生地”运行将降低它被使用的障碍。它会像 Babel 添加 TypeScript 支持时一样对 TypeScript 施加一些约束。但我觉得这是值得的。

Deno 是一个消除所有 TS 障碍的关键例子,它通过运行一个 Rust 编写的工具,能够非常快速地将 TS 编译到 JS,模拟了当前 JavaScript 引擎对原生 TypeScript 的支持。

1.5.2 如今的竞争者

  • JetBrains WebStorm - 这是一个有高级 JavaScript 工具支持的 IDE。他们有自己的引擎用于重构、代码流分析并对 JavaScript 语法进行检查。这很好,JetBrains 在他们所有的 IDE 上都做了扎实的工作。我过去经常使用 AppCode 处理 iOS 的工作。当你有一个 TypeScript 项目时,WebStorm 会将 TypeScript 的语言工具和自己的工具混合在一起,这对你来说是双赢的。
  • 编译到 JS 的语言 - 目前的例子有 Elm,ReScript,KotlinScript,这些语言的核心目标是与 JavaScript 交互。对于 TypeScript 来说,这些都是很有趣的语言,它们有一个干净的环境来实现类型系统 —— 也就是,没有 JS 包袱。作为竞争对手,它们倾向于更细分的市场,因为它们的核心不是 JavaScript ,并且社区也被从 CoffeeScript 迁移所困扰过。
  • WASM - 我听到 WASM 作为 TypeScript 竞争者的观点是,WASM 可以作为语言取代 JS 控制浏览器 DOM。反对这一观点的人普遍认为,WASM 没有 DOM 绑定,而且可能永远不会有。TypeScript 包含了 JavaScript 的缺点,如果你在 JavaScript 运行时中加入过 WASM 的话,你几乎总是会更加喜欢它。也就是说,AssemblyScript 在这方面做了一些很好的工作。也许把 WASM 想成 JSON 会更好,它是另一个组成项目的工具,不太可能成为 JavaScript 的竞争者,除非 WASM 和 DOM 的交互方式有所改变。
  • 编译到 WASM 的语言 - 比如 Rust,Go,Swift,等其它可以编译到 WASM 的语言。这些语言都可能占据 TypeScript 目前作为工具和 web 核心构建模块的位置,不过世事难料,谁知道会怎么样呢?这些语言能够提供各种不同的基本类型,并且基于不同的目标从头构建。如果 WASM 和 WASI 最终获得成功,那么我认为将会与平台相关(想想 apps 等功能实现),看看它们的发展方向会很有趣。说心里话,它们不会是 TypeScript 的竞争者,而是 JavaScript 的。

1.5.3 TypeScript 怎么看它在生态中的位置?

TypeScript 希望在类型系统和编辑器工具领域进行创新。我们拥有在主流编程语言中表达能力最强的类型系统之一。

TypeScript 最初被创建时,对 JavaScript 进行修改的流程和现在非常不同,所以 TypeScript 中有一些特性实际上是 TC39 的领域,但仍然需要向后兼容。这些特性可能在 JavaScript 中存在很多年,并且经过了多次迭代,这意味着 TypeScript 必须维护一个特定语言特性的两种版本。

所以我们的目标是成为 TC39 JavaScript 语言委员会的优秀成员,就编辑器支持的语言特性进行反馈,支持 TypeScript 用户想要看到的特性。通过这种协作方式,TC39 控制了 JavaScript,TypeScript 也支持他们。

1.5.4 TypeScript 怎么看它的受众?

TypeScript 的受众主要有:

  • JavaScript 用户(作为语言工具)
  • JS + JSDoc 用户(作为语言工具)
  • TypeScript 用户(作为编译器,语言工具)
  • TypeScript 严格模式(作为编译器,语言工具)

虽然项目使用 babel / swc / sucrase / esbuild 等工具构建时,tsc 是可选的,但是上面的几种受众仍然可以在每次或至少每两次 TS 版本发布中获得新特性(译者注:babel、esbuild 等会更新支持 TS 新特性。可能是 TS 团队直接去这些项目里做,也可能会在没有 tsc 的情况下为这些项目提供特性,比如通过 vscode。在 TS roadmap[10] 中可以了解更多发布计划)。

1.5.5 TypeScript 是如何跟踪 JS 生态的?

团队从以下几个方式听取反馈:

  • GitHub issues 有持续不断的评论洪流
  • 微软内部团队要求提供特性,或者要求我们帮忙调试他们缓慢的代码库
  • 通过 Gitter 或者 TypeScript 社区的 Discord 与社区建立联系
  • 通过微团的内部工具对想法 / 设计进行用户测试
  • 与 VS Code 有着非常紧密的联系,许多语言工具的反馈都来自于他们
  • 我们会阅读每一条 @ TypeScript 团队的推特
  • 我们会跟踪迁移到 TypeScript 和从 TypeScript 迁走的博客文章
  • 我们会跟踪行业调查和编程语言概述

二、TypeScript入门

2.1 发现问题

JavaScript 中的每个值都有一组行为,您可以通过运行不同的操作来观察。这听起来很抽象,我们来举一个简单的例子,考虑我们可能对名为message的变量运行的一些操作:

// 在 'message' 上访问属性 'toLowerCase',并调用它
message.toLowerCase();
// 调用 'message'
message();

如果我们分解它,第一行可运行的代码访问一个属性toLowerCase,然后调用它。第二个尝试 message 直接调用。

但是假设我们不知道message。这很常见——我们无法可靠地说出尝试运行任何这些代码会得到什么结果。每个操作的行为完全取决于我们最初给message的赋值。

  • 可以调用message吗?
  • 它有toLowerCase这个属性吗?
  • 如果能,toLowerCase可以调用吗?
  • 如果这两个值都是可调用的,它们返回什么?

这些问题的答案通常是我们在编写 JavaScript 时牢记在心的东西,我们必须希望所有细节都正确。

假设message按以下方式定义:

const message = "Hello World!";

正如您可能猜到的,如果我们尝试运行message.toLowerCase(),我们只会得到相同的小写字符串。

那第二行代码呢?如果您熟悉 JavaScript,您就会知道这会失败并出现异常:

TypeError: message is not a function

如果我们能避免这样的错误,那就太好了。

当我们运行我们的代码时,我们的 JavaScript 运行时选择做什么的方式是通过确定值的类型——它具有什么样的行为和功能。这TypeError就是暗指的一部分- 它说字符串"Hello World!"不能作为函数调用。

对于某些值,例如基本类型stringnumber,我们可以在运行时使用typeof运算符识别它们的类型。但是对于函数之类的其他东西,没有相应的运行时机制来识别它们的类型。例如,考虑这个函数:

function fn(x) {
  return x.flip();
}

我们可以通过阅读代码观察到这个函数只有在给定一个具有可调用flip属性的对象时才能工作,但是 JavaScript 并没有以我们可以在代码运行时检查的方式来显示这些信息。在纯 JavaScript 中,告诉fn特定值做什么的唯一方法是调用它并查看会发生什么。这种行为使得在运行之前很难预测代码会做什么,这意味着在编写代码时更难知道代码会做什么。

这样看来,类型是描述可以传递给fn哪些值会崩溃的概念。JavaScript 只真正提供动态类型——运行代码看看会发生什么。

另一种方法是使用静态类型系统运行之前预测预期的代码。

2.2 静态类型检查

回想一下TypeError我们之前尝试将 string作为函数调用的情况。 大多数人不喜欢在运行他们的代码时出现任何类型的错误 - 这些被认为是错误!当我们编写新代码时,我们会尽量避免引入新的错误。

理想情况下,我们可以有一个工具来帮助我们代码运行之前发现这些错误。这就是像 TypeScript 这样的静态类型检查器所做的。 静态类型系统描述了当我们运行程序时我们的值的形状和行为。像 TypeScript 这样的类型检查器,告诉我们什么时候事情可能会出轨。

在我们运行代码之前,使用 TypeScript 运行最后一个示例会给我们一条错误消息。

2.3 非异常故障

到目前为止,我们一直在讨论运行时错误——JavaScript 运行时告诉我们它认为某些东西是无意义的情况。出现这些情况是因为ECMAScript 规范明确说明了语言在遇到意外情况时应该如何表现。

例如,规范说尝试调用不可调用的东西应该抛出错误。也许这听起来像是“明显的行为”,但您可以想象访问对象上不存在的属性也应该抛出错误。相反,JavaScript 给了我们不同的行为并返回值undefined

const user = {
  name: "小千",
  age: 26,
};
user.location; // 返回 undefined

最终,静态类型系统要求必须调用哪些代码,应该在其系统中标记,即使它是不会立即抛出错误的“有效”JavaScript。比如:在 TypeScript 中,以下代码会产生关于location未定义的错误:

TypeScript 可以在我们的程序中捕获很多合法的错误。例如:

  • 错别字

  • 未调用的函数

  • 或基本逻辑错误

2.4 使用工具

当我们在代码中出错时,TypeScript 可以捕获错误。这很好,但 TypeScript 也可以首先防止我们犯这些错误。

类型检查器有能力帮助我们来检查,诸如是否正在访问变量和其他属性的正确属性。一旦有了这些信息,它还可以开始建议您可能想要使用的属性。

这意味着当利用工具来编辑 TypeScript 代码,核心类型检查器可以在编辑器中键入代码时,提供错误消息和代码完成。这是我们在谈论 TypeScript 中的工具时经常提到的部分内容。

TypeScript 非常重视工具。支持 TypeScript 的编辑器可以提供“快速修复”以自动修复错误、重构以轻松重新组织代码的能力,以及用于跳转到变量定义或查找给定变量的所有引用的有用导航功能。所有这些都建立在类型检查器之上,并且是完全跨平台的,因此您最喜欢的编辑器可能具有可用的 TypeScript 支持。

2.5 tsc编译器

我们一直在谈论类型检查,但我们还没有使用我们的类型检查器。让我们认识一下我们的新朋友tscTypeScript 编译器。首先,我们需要通过 npm 获取它。

npm install -g typescript

这将全局安装 TypeScript 编译器。

现在让我们移动到一个空文件夹,并尝试编写我们的第一个 TypeScript 程序hello.ts

01-ts-basics/hello.ts

// 你好,世界
console.log('Hello World')

注意这里没有多余的装饰;这个“hello world”程序看起来与您在 JavaScript 中为“hello world”程序编写的程序相同。现在让我们通过运行tsctypescript包为我们打包编译它:

[felix] 01-ts-basics $ tsc hello.ts 

我们跑了tsc,什么也没发生!嗯,没有类型错误,所以我们没有在控制台中得到任何输出,因为没有什么可报告的。

但是再检查一下 - 我们得到了一些文件输出。如果我们查看当前目录,我们会发现有两个文件hello.jshello.ts. 这是我们的hello.ts文件在tsc 编译转换为纯 JavaScript 文件后的输出。

如果我们检查hello.js,我们将看到 TypeScript 在处理.ts文件后吐出的内容:

// 你好,世界
console.log('Hello World');

在这种情况下,TypeScript 几乎没有要转换的内容,因此它看起来与我们编写的内容相同。编译器会尝试编译出清晰可读的代码,看起来像一个人写的东西。注释也给我们保留下来。

如果我们确实引入了类型检查错误呢?让我们重写hello.ts

function greet(person, date) {
  console.log(`Hello ${person}, today is ${date}!`);
}
 
greet("小千");

如果我们tsc hello.ts再次运行,我们在命令行上收到了错误!

TypeScript 告诉我们忘记将参数传递给greet函数,这是理所当然的。到目前为止,我们只编写了标准的 JavaScript,但是类型检查仍然能够发现我们代码的其他问题。感谢TypeScript!

2.6 发出错误

从上一个示例中,你可能没有注意到的一件事,就是是我们的hello.js文件再次更改。如果我们打开该文件,那么我们会看到内容仍然与我们的输入文件基本相同。

// 你好,世界
console.log('Hello World');
function greet(person, date) {
    console.log("Hello " + person + ", today is " + date + "!");
}
greet("小千");

鉴于tsc报告了有关我们代码的错误这一事实,这可能有点令人惊讶,但这是基于 TypeScript 的核心价值观之一:大多数情况下,会比 TypeScript 更了解发生了什么。

再回顾一下前面的内容,类型检查代码限制了我们可以运行的程序种类,因此需要权衡类型检查器认为可接受的类型。大多数情况下这没问题,但在某些情况下,这些检查会妨碍到我们。例如,想象自己将 JavaScript 代码迁移到 TypeScript 并引入类型检查错误。最终,你将开始为类型检查器进行代码清理,但原始 JavaScript 代码已经可以运行了!为什么非要将我们的JS代码转换为 TypeScript 代码,来去阻止运行它呢?

所以 TypeScript 默认不会妨碍我们代码的运行,因为这些JS在宿主环境里运行时没有问题的。当然,随着时间的推移,我们可能希望对错误更加防御,并使 TypeScript 的行为更加严格。在这种情况下,我们可以使用noEmitOnError编译器选项。尝试更改我们的hello.ts文件,并tsc使用该选项重新编译:

tsc --noEmitOnError hello.ts

你会注意到hello.js永远不会更新。

2.7 显式类型

到现在为止,我们还没有告诉 typescript person或者date是什么类型。当我们编辑代码时会告诉 TypeScript person是一个string,那date应该是一个Date对象。

有了这个,TypeScript 可以告诉我们其他greet可能被错误调用的情况。例如修改一下 hello.ts代码:

function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}

嗯?TypeScript 在我们的第二个参数上报告了一个错误,这是为什么呢?

也许令人惊讶的是,Date()在 JavaScript 中调用会返回一个string。可以使用new Date()满足我们的期望,快速修复错误:

function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
 
greet("小锋", new Date());

成功的编译输出了 hello.js

请记住,我们并不总是必须给变量编写明确的类型注释。在许多情况下,TypeScript 可以为我们自动推断(或“找出”)类型,即使我们忽略定义这些类型。比如:

这里没有给 msg 指定具体的类型,typescript 会根据函数的实参来自动推断类型。这是一个特性,当类型系统最终会推断出相同的类型时,最好不要添加类型注释。

2.8 擦除类型

让我们来仔细看看,当我们 tsc编译上面的代码会输出的什么样的JavaScript,观察 hello.js

这里要注意两点:

  1. 我们的persondate参数不再有类型注释。
  2. 我们的“模板字符串” - 使用反引号(`字符)的字符串 - 被转换为带有连接 ( + ) 的纯字符串。

稍后会详细介绍第二点,现在让我们专注于第一点。类型注释不是 JavaScript 的一部分(或者说 ECMAScript 是落后的),因此实际上没有任何浏览器,或其他运行时可以不加修改地运行 TypeScript。这就是 TypeScript 首先需要编译器的原因——它需要某种方式来剥离或转换任何特定于 TypeScript 的代码,以便我们可以运行它。大多数 TypeScript 特定的代码都被删除了。

请记住:类型注释永远不会改变程序的运行时行为。

2.9 降级编译

与上面的另一个区别是我们的模板字符串是从:

`Hello ${person}, today is ${date.toDateString()}!`

到:

"Hello " + person + ", today is " + date.toDateString() + "!";

为什么会这样?

模板字符串是 ECMAScript 版本的一个特性,称为 ECMAScript 2015(又名 ECMAScript 6、ES2015、ES6 等)。TypeScript 能够将代码从较新版本的 ECMAScript 重写为旧版本,例如 ECMAScript 3 或 ECMAScript 5(又名 ES3 和 ES5)。这种从更新或“更高”版本的 ECMAScript ,向下移动到旧版本或“更低”版本的过程有时称为降级

默认情况下,TypeScript 以 ES3 为目标,这是一个非常旧的 ECMAScript 版本。通过使用target选项,我们可以选择更新一些的内容。运行--target es2015TypeScript 以针对 ECMAScript 2015进行更改,这意味着代码应该能够在支持 ECMAScript 2015 的任何地方运行。所以运行tsc --target es2015 hello.ts会给我们以下输出:

虽然默认目标是 ES3,但当前绝大多数浏览器都支持 ES2015。因此,大多数开发人员可以安全地将 ES2015 或更高版本指定为目标,除非考虑与某些旧浏览器的兼容性。

2.10 严格模式

不同的用户使用 TypeScript 在类型检查器中,希望检查的严格程度不同。有些人正在寻找更宽松的验证体验,它可以帮助仅验证其程序的某些部分,并且仍然拥有不错的工具。这是 TypeScript 的默认体验,其中类型是可选的,推理采用最宽松的类型,并且不检查潜在的null/undefined值,就像tsc面对错误时如何编译生成JS文件一样。如果你要迁移现有的 JavaScript,这可能是理想的第一步。

相比之下,许多用户更喜欢让 TypeScript 尽可能多地立即验证,这就是该语言也提供严格性设置的原因。这些严格性设置将静态类型检查,从开关(无论您的代码是否被检查)转变为更接近于拨号的东西。你把这个拨盘调得越远,TypeScript 就会为你检查越多。这可能需要一些额外的工作,但总的来说,从长远来看,它是物有所值的,并且可以实现更彻底的检查和更准确的工具。如果可能,新的代码库应该始终打开这些严格性检查。

TypeScript 有几个可以打开或关闭的类型检查严格标志,除非另有说明,否则我们所有的示例都将在启用所有这些标志的情况下编写。在命令行里设置strict ,或在tsconfig.json中配置"strict": true 将它打开。

以上两个案例错误是因为我们配置了 --strict true

同时我们可以单独选择配置它们。我们应该知道的最典型的两个是noImplicitAnystrictNullChecks

  • noImplicitAny

回想一下,在某些地方,TypeScript 不会尝试为我们推断类型,而是退回到最宽松的类型:any。这并不是可能发生的最糟糕的事情——毕竟,any无论如何,都能退回到普通的 JavaScript 体验。

但是,使用any通常首先会破坏使用 TypeScript 的目的。你的程序类型越多,你获得的验证和工具就越多,这意味着你在编写代码时会遇到更少的错误。打开该noImplicitAny标志将对类型隐式推断为,当任何变量发出错误时都应用 any 类型。

  • strictNullChecks

默认情况下,值为nullundefined可分配给任何其他类型。这可以使编写一些代码更容易,但忘记处理null并且undefined是你代码无数错误的元凶 - 有些人认为这是一个十亿美元的错误!该strictNullChecks标志,使得操作nullundefined更加明确,它使我们不用担心是否忘记处理nullundefined

三、常用类型

在本章中,我们将介绍一些在 JavaScript 代码中最常见的值类型,并解释在 TypeScript 中描述这些类型的相应方法。这不是一个详尽的列表,未来的章节将描述命名和使用其他类型的更多方法。

类型还可以出现在更多的*地方,*而不仅仅是类型注释。当我们了解类型本身时,我们还将了解可以引用这些类型以形成新结构的地方。

我们将首先回顾你在编写 JavaScript 或 TypeScript 代码时,可能遇到的最基本和最常见的类型,它是形成更复杂类型的核心构建块。

3.0 TypeScript配置文件

为了方便学习,我们可以在一个单独的配置文件中保存命令行中的参数,应用 tsc 生成配置文件:

在项目根目录下生成了一个配置文件 tsconfig.json。这里给出我们自己的配置:

{
	"compilerOptions": {
		/* Language and Environment */
		"target": "es6", 
		/* Modules */
		"rootDir": "./src",
		/* Emit */
		"outDir": "./dist",
		/* Type Checking */
		"strict": true,
	}
}

3.1 基元类型string,number, 和boolean

JavaScript有三个非常常用的原语:stringnumber,和boolean。每个在 TypeScript 中都有对应的类型。我们发现,这些名称与我们在 JavaScript 应用typeof返回的类型的名称相同:

  • string 表示字符串值,如 "Hello, world"
  • number表示数字值,如 42。JavaScript 没有一个特殊的整数运行时值,所以没有等价于intfloat类型, 一切都只是number
  • boolean只有两个值truefalse

类型名称String, Number, 和Boolean(以大写字母开头)是合法的,但指的是一些很少出现在代码中的特殊内置类型。对于类型,始终使用string, number, 或boolean

let str: string = 'hello typescript'
let num: number = 100
let bool: boolean = true

3.2 数组

数组是指定形如[1, 2, 3]数据,可以使用语法number[]来定义; 此语法适用于任何类型(例如string[],字符串数组等)。

你也可以写成ArrayT当介绍泛型时,我们将了解更多有关这个语法的更多信息。

02-everyday-types/src/02-array.ts

let arr: number[] = [1, 2, 3]
let arr2: Array<number> = [1, 2, 3]

3.3 any

TypeScript 还有一个特殊类型 any,当你不希望某个特定值导致类型检查错误时,可以使用它。

当一个值的类型是any时,可以访问它的任何属性,将它分配给任何类型的值,或者几乎任何其他语法上的东西都合法的:

let obj: any = { x: 0 };
// 以下代码行都不会抛出编译器错误。
// 使用'any'将禁用所有进一步的类型检查
obj.foo();
obj();
obj.bar = 100;
obj = "hello";
const n: number = obj;

但在运行环境下执行代码可能是错误的:

进入到 dist目录中,在 node 环境里运行代码,果然报错了。

当你不想写出长类型只是为了让 TypeScript 相信特定的代码行没问题时,any类型很有用。

  • noImplicitAny

当不指定类型时,并且 TypeScript 无法从上下文推断它时,编译器通常会默认为any.

但是,您通常希望避免这种情况,因为any没有进行类型检查。使用编译器标志noImplicitAny将任何隐式标记any为错误。这个配置我们在前面讲到过。

3.4 变量上的类型注释

当你使用const, var, 或声明变量时let,可以选择添加类型注释来显式指定变量的类型:

let myName: string = "Felixlu";

TypeScript 不使用“左边的类型”风格的声明,比如int x = 0; 类型注解总是被输入的东西之后

但是,在大多数情况下,这不是必需的。只要有可能,TypeScript 就会尝试自动推断代码中的类型。例如,变量的类型是根据其初始化器的类型推断出来的:

// 不需要类型定义--“myName”推断为类型“string”
let myName = "Felixlu";

大多数情况下,不需要明确学习推理规则。如果你刚开始,请尝试使用比你想象的更少的类型注释 - 你可能会惊讶——TypeScript 完全了解正在发生的事情。

3.5 函数

函数是在 JavaScript 中传递数据的主要方式。TypeScript 允许您指定函数的输入和输出值的类型。

  • 参数类型注释

声明函数时,可以在每个参数后添加类型注解,以声明函数接受的参数类型。参数类型注释位于参数名称之后:

// 参数类型定义
function greet(name: string) {
  console.log("Hello, " + name.toUpperCase() + "!!");
}

当参数具有类型注释时,将检查该函数的参数:

// 如果执行,将是一个运行时错误!
greet(42);

即使您的参数上没有类型注释,TypeScript 仍会检查您是否传递了正确数量的参数。

  • 返回类型注释

你还可以添加返回类型注释。返回类型注释出现在参数列表之后:

function getFavoriteNumber(): number {
  return 26;
}

与变量类型注释非常相似,通常不需要返回类型注释,因为 TypeScript 会根据其return语句推断函数的返回类型。上面例子中的类型注释不会改变任何东西。某些代码库会出于文档目的明确指定返回类型,以防止意外更改或仅出于个人偏好。

  • 匿名函数

匿名函数与函数声明有点不同。当一个函数出现在 TypeScript 可以确定它将如何被调用的地方时,该函数的参数会自动指定类型。

下面是一个例子:

// 这里没有类型注释,但是TypeScript可以发现错误
const names = ["Alice", "Bob", "Eve"];
 
// 函数上下文类型
names.forEach(function (s) {
  console.log(s.toUppercase());
});
 
// 上下文类型也适用于箭头函数
names.forEach((s) => {
  console.log(s.toUppercase());
})

即使参数s没有类型注释,TypeScript 也会使用forEach函数的类型,以及数组的推断类型来确定s的类型。

这个过程称为上下文类型,因为函数发生在其中的上下文通知它应该具有什么类型。

与推理规则类似,你不需要明确了解这是如何发生的,但了解它的机制确实可以帮助你注意何时不需要类型注释。稍后,我们将看到更多关于值出现的上下文如何影响其类型的示例。

3.6 对象类型

除了stringnumberboolean类型(又称基元类型)外,你将遇到的最常见的类型是对象类型。这指的是任何带有属性的 JavaScript 值,几乎是所有属性!要定义对象类型,我们只需列出其属性及其类型。

例如,这是一个接受点状对象的函数:

// 参数的类型注释是对象类型
function printCoord(pt: { x: number; y: number }) {
  console.log("坐标的x值为: " + pt.x);
  console.log("坐标的y值为: " + pt.y);
}
printCoord({ x: 3, y: 7 });

在这里,我们使用具有两个属性的类型注释参数 -xy- 这两个属性都是 number类型。你可以以使用,;来分隔属性,最后一个分隔符是可选的。

每个属性的类型部分也是可选的。如果你不指定类型,则将假定为any

  • 可选属性

对象类型还可以指定其部分或全部属性是可选的。为此,请在属性名称后添加一个?

02-everyday-types/src/05-object.ts

function printName(obj: { first: string; last?: string }) {
  // ...
}
// 两种传递参数都可以
printName({ first: "Felix" });
printName({ first: "Felix", last: "Lu" });

在 JavaScript 中,如果访问一个不存在的属性,将获得值undefined而不是运行时错误。因此,当你读取可选属性时,必须使用它之前用undefined进行检查。

function printName(obj: { first: string; last?: string }) {
  // 错误 - 'obj.last' 可能不存在!
  console.log(obj.last.toUpperCase());
  
  if (obj.last !== undefined) {
    // 这样可以
    console.log(obj.last.toUpperCase());
  }
 
  // 使用现代JavaScript语法的安全替代方案:
  console.log(obj.last?.toUpperCase());
}

3.7 联合类型

TypeScript 的类型系统允许你使用多种运算符,从现有类型中构建新类型。现在我们知道如何编写几种类型,是时候开始以有趣的方式组合它们了。

  • 定义联合类型

第一种组合类型的方法是联合类型。联合类型是由两个或多个其他类型组成的类型,表示可能是这些类型中的任何一种的值。我们将这些类型中的每一种称为联合类型的成员

让我们编写一个可以对字符串或数字进行操作的函数:

function printId(id: number | string) {
  console.log("Your ID is: " + id);
}
// 正确
printId(101);
// 正确
printId("202");
// 错误
printId({ myID: 22342 });
  • 使用联合类型

提供匹配联合类型的值很容易- 只需提供匹配任何联合成员的类型。如果你一个联合类型的值,你如何使用它?

如果联合的每个成员都有效,TypeScript 将只允许你使用联合做一些事情。例如,如果你有联合类型 string | number,则不能只使用一种类型的操作,比如string

function printId(id: number | string) {
  console.log(id.toUpperCase());
}

解决方案是用代码缩小联合,就像在没有类型注释的 JavaScript 中一样。 当 TypeScript 可以根据代码结构为值推断出更具体的类型时,就会发生缩小

例如,TypeScript 知道只有一个string值才会有一个typeof"string"

function printId(id: number | string) {
  if (typeof id === "string") {
    // 在此分支中,id的类型为“string”
    console.log(id.toUpperCase());
  } else {
    // 此处,id的类型为“number”
    console.log(id);
  }
}

另一个例子是使用如下函数Array.isArray

function welcomePeople(x: string[] | string) {
  if (Array.isArray(x)) {
    // 此处: 'x' 的类型是 'string[]'
    console.log("Hello, " + x.join(" and "));
  } else {
    // 此处: 'x' 的类型是 'string'
    console.log("Welcome lone traveler " + x);
  }
}

请注意,在else分支中,我们不需要做任何特别的事情——如果x不是 string[],那么它一定是 string

有时你会有一个 union,所有成员都有一些共同点。例如,数组和字符串都有一个slice方法。如果联合中的每个成员都有一个共同的属性,则可以使用该属性而不会缩小范围:

// 返回类型推断为 number[] | string
function getFirstThree(x: number[] | string) {
  return x.slice(0, 3);
}

3.8 类型别名

我们一直在通过直接在类型注释中编写对象类型和联合类型来使用它们。这很方便,但是想要多次使用同一个类型,并用一个名称来引用它是很常见的。

一个类型别名正是一个名称为任何类型的定义。类型别名的语法是:

type Point = {
  x: number;
  y: number;
};
 
// 与前面的示例完全相同
function printCoord(pt: Point) {
  console.log("坐标x的值是: " + pt.x);
  console.log("坐标y的值是: " + pt.y);
}
 
printCoord({ x: 100, y: 100 });

实际上,你可以使用类型别名为任何类型命名,而不仅仅是对象类型。例如,类型别名可以命名联合类型:

type ID = number | string;

请注意,别名只是别名 - 你不能使用类型别名来创建相同类型的不同“版本”。当你使用别名时,就像你编写了别名类型一样。换句话说,这段代码可能看起来不合法,但根据 TypeScript 是可以的,因为这两种类型都是同一类型的别名:

type UserInputSanitizedString = string;
 
function sanitizeInput(str: string): UserInputSanitizedString {
  return str.slice(0, 2)
}
 
// 创建经过 sanitize 的输入
let userInput = sanitizeInput('hello');
 
// 但仍可以使用字符串重新分配值
userInput = "new input";

3.9 接口

一个接口声明是另一种方式来命名对象类型:

interface Point {
  x: number;
  y: number;
}
 
function printCoord(pt: Point) {
  console.log("坐标x的值是: " + pt.x);
  console.log("坐标y的值是: " + pt.y);
}
 
printCoord({ x: 100, y: 100 });

就像我们在上面使用类型别名时一样,该示例就像我们使用了匿名对象类型一样工作。TypeScript 只关心我们传递给的值的结构printCoord——它只关心它是否具有预期的属性。只关心类型的结构和功能,是我们将 TypeScript 称为结构类型类型系统的原因。

  • 类型别名和接口之间的差异

类型别名和接口非常相似,在很多情况下你可以自由选择它们。几乎所有的功能都在interface中可用type,关键区别在于扩展新类型的方式不同:

// 扩展接口
interface Animal {
  name: string
}

interface Bear extends Animal {
  honey: boolean
}

const bear: Bear = {
  name: 'winnie',
  honey: true
}
bear.name
bear.honey
// 通过交叉点扩展类型
type Animal = {
  name: string
}

type Bear = Animal & { 
  honey: boolean 
}

const bear: Bear = {
  name: 'winnie',
  honey: true
} 
bear.name;
bear.honey;
// 向现有接口添加新字段
interface MyWindow {
  title: string
}

interface MyWindow {
  count: number
}

const w: MyWindow = {
  title: 'hello ts',
  count: 100
}
// 类型创建后不可更改
type MyWindow = {
  title: string
}

type MyWindow = {
  count: number
}
  • 在 TypeScript 4.2 版之前,类型别名可能出现在错误消息中,有时会代替等效的匿名类型(这可能是可取的,也可能是不可取的)。接口将始终在错误消息中命名。
  • 类型别名可能不参与声明合并,但接口可以。
  • 接口只能用于声明对象的形状,不能重命名基元。
  • 接口名称将始终以其原始形式出现在错误消息中,但当它们按名称使用时。

大多数情况下,你可以根据个人喜好进行选择,TypeScript 会告诉你是否需要其他类型的声明。如果您想要启发式,请使用interface,然后在需要时使用type

3.10 类型断言

有时,你会获得有关 TypeScript 不知道的值类型的信息。

例如,如果你正在使用document.getElementById,TypeScript 只知道这将返回某种类型的HTMLElement,但你可能知道你的页面将始终具有HTMLCanvasElement给定 ID 的值 。

在这种情况下,你可以使用类型断言来指定更具体的类型:

const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;

与类型注释一样,类型断言由编译器删除,不会影响代码的运行时行为。

还可以使用尖括号语法(除非代码在.tsx文件中),它是等效的:

const myCanvas = document.getElementById("main_canvas");

提醒:因为类型断言在编译时被移除,所以没有与类型断言相关联的运行时检查。null如果类型断言错误,则不会出现异常。

TypeScript 只允许类型断言转换为更具体不太具体的类型版本。此规则可防止“不可能”的强制,例如:

const x = "hello" as number;

将类型string转换为类型number可能是错误的,因为两种类型都没有充分重叠。如果这是有意的,请先将表达式转换为 anyunknownunknown,我们将在后面介绍),然后是所需的类型:

const x = ("hello" as unknown) as number;

3.11 文字类型

除了一般类型stringnumber,我们可以在类型位置引用特定的字符串和数字。

一种方法是考虑 JavaScript 如何以不同的方式声明变量。varlet两者都允许更改变量中保存的内容,const不允许,这反映在 TypeScript 如何为文字创建类型上。

let testString = "Hello World";
testString = "Olá Mundo";

//'testString'可以表示任何可能的字符串,那
//TypeScript是如何在类型系统中描述它的
testString;
 
const constantString = "Hello World";
//因为'constantString'只能表示1个可能的字符串,所以
//具有文本类型表示
constantString;

就其本身而言,文字类型并不是很有价值:

let x: "hello" = "hello";
// 正确
x = "hello";
// 错误
x = "howdy";

拥有一个只能有一个值的变量并没有多大用处!

但是通过文字组合成联合,你可以表达一个更有用的概念——例如,只接受一组特定已知值的函数:

function printText(s: string, alignment: "left" | "right" | "center") {
  // ...
}
printText("Hello, world", "left");
printText("G'day, mate", "centre"); 

数字文字类型的工作方式相同:

function compare(a: string, b: string): -1 | 0 | 1 {
  return a === b ? 0 : a > b ? 1 : -1;
}

当然,你可以将这些与非文字类型结合使用:

interface Options {
  width: number;
}
function configure(x: Options | "auto") {
  // ...
}
configure({ width: 100 });
configure("auto");
configure("automatic");

还有一种文字类型:布尔文字。只有两种布尔文字类型,它们是类型truefalse。类型boolean本身实际上只是联合类型 union 的别名true | false

  • 文字推理

当你使用对象初始化变量时,TypeScript 假定该对象的属性稍后可能会更改值。例如,如果你写了这样的代码:

const obj = { counter: 0 };
if (someCondition) {
  obj.counter = 1;
}

TypeScript 不假定先前具有的字段值0,后又分配1是错误的。另一种说法是obj.counter必须有 number属性, 而非是 0,因为类型用于确定读取写入行为。

这同样适用于字符串:

function handleRequest(url: string, method: 'GET' | 'POST' | 'GUESS') {
  // ...
}

const req = { url: 'https://example.com', method: 'GET' };
handleRequest(req.url, req.method);

在上面的例子req.method中推断是string,不是"GET"。因为代码可以在创建req和调用之间进行评估,TypeScript 认为这段代码有错误。

有两种方法可以解决这个问题。

1. 可以通过在任一位置添加类型断言来更改推理:

// 方案 1:
const req = { url: "https://example.com", method: "GET" as "GET" };
// 方案 2
handleRequest(req.url, req.method as "GET");

方案 1 表示“我打算req.method始终拥有文字类型 "GET"”,从而防止之后可能分配"GUESS"给该字段。

方案 2 的意思是“我知道其他原因req.method具有"GET"值”。

2. 可以使用as const将整个对象转换为类型文字:

const req = { url: "https://example.com", method: "GET" } as const;
handleRequest(req.url, req.method);

as const后缀就像const定义,确保所有属性分配的文本类型,而不是一个更一般的stringnumber

3.12 nullundefined

JavaScript 有两个原始值用于表示不存在或未初始化的值:nullundefined.

TypeScript 有两个对应的同名类型。这些类型的行为取决于您是否设置strictNullChecks选择。

  • strictNullChecks 关闭

使用false,仍然可以正常访问的值,并且可以将值分配给任何类型的属性。这类似于没有空检查的语言(例如 C#、Java)的行为方式。缺乏对这些值的检查往往是错误的主要来源;如果在他们的代码库中这样做可行,我们总是建议大家打开。

  • strictNullChecks 打开

使用true,你需要在对该值使用方法或属性之前测试这些值。就像在使用可选属性之前检查一样,我们可以使用缩小来检查可能的值:

function doSomething(x: string | null) {
  if (x === null) {
    // 做一些事
  } else {
    console.log("Hello, " + x.toUpperCase());
  }
}
  • 非空断言运算符(!后缀)

TypeScript 也有一种特殊的语法nullundefined,可以在不进行任何显式检查的情况下,从类型中移除和移除类型。!在任何表达式之后写入实际上是一种类型断言,即该值不是nullor undefined

function liveDangerously(x?: number | null) {
  // 正确
  console.log(x!.toFixed());
}

就像其他类型断言一样,这不会更改代码的运行时行为,因此仅!当你知道该值不能nullundefined时使用才是重要的。

3.13 枚举

枚举是 TypeScript 添加到 JavaScript 的一项功能,它允许描述一个值,该值可能是一组可能的命名常量之一。与大多数 TypeScript 功能不同,这不是JavaScript 的类型级别的添加,而是添加到语言和运行时的内容。因此,你确定你确实需要枚举在做些事情,否则请不要使用。可以在Enum 参考页 中阅读有关枚举的更多信息。

// ts源码
enum Direction {
  Up = 1,
  Down,
  Left,
  Right,
}
console.log(Direction.Up) // 1
// 编译后的js代码
"use strict";
var Direction;
(function (Direction) {
    Direction[Direction["Up"] = 1] = "Up";
    Direction[Direction["Down"] = 2] = "Down";
    Direction[Direction["Left"] = 3] = "Left";
    Direction[Direction["Right"] = 4] = "Right";
})(Direction || (Direction = {}));
console.log(Direction.Up);

3.14 不太常见的原语

值得一提的是 JavaScript 中一些较新的原语,它们在 TypeScript 类型系统中也实现了。我们先简单的看两个例子:

  • bigint

从 ES2020 开始,JavaScript 中有一个用于非常大的整数的原语BigInt

// 通过bigint函数创建bigint
const oneHundred: bigint = BigInt(100);
 
// 通过文本语法创建BigInt
const anotherHundred: bigint = 100n;

你可以在TypeScript 3.2 发行说明 中了解有关 BigInt 的更多信息。

  • symbol

JavaScript 中有一个原语Symbol(),用于通过函数创建全局唯一引用:

const firstName = Symbol("name");
const secondName = Symbol("name");
 
if (firstName === secondName) {
  // 这里的代码不可能执行
}

此条件将始终返回 false,因为类型 typeof firstNametypeof secondName没有重叠。

四、类型缩小

假设我们有一个名为padLeft的函数:

function padLeft(padding: number | string, input: string): string {
  throw new Error("尚未实现!");
}

我们来扩充一下功能:如果paddingnumber,它会将其视为我们想要添加到input的空格数;如果paddingstring,它只在input上做padding 。让我们尝试实现:

function padLeft(padding: number | string, input: string) {
  return new Array(padding + 1).join(" ") + input;
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IClMmAlx-1668765340993)(/Users/felix/Desktop/felixbooks/2022MD/TypeScript/images/04-01.png)]

呃-哦,在padding + 1处我们遇到错误。TypeScript 警告我们,运算符 + 不能应用于类型 string | numbernumber,这是对的。换句话说,我们没有明确检查 padding是否为 number,也没有处理它是 string 的情况,所以我们这样做:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return new Array(padding + 1).join(" ") + input;
  }
  return padding + input;
}

如果这大部分看起来像无趣的JavaScript代码,这也算是重点吧。除了我们设置的注解之外,这段TypeScript代码看起来就像JavaScript。我们的想法是,TypeScript的类型系统旨在使编写典型的JavaScript代码变得尽可能容易,而不需要弯腰去获得类型安全。

虽然看起来不多,但实际上有很多东西在这里。就像TypeScript使用静态类型分析运行时的值一样,它在JavaScript的运行时控制流构造上叠加了类型分析,如if/else、条件三元组、循环、真实性检查等,这些都会影响到这些类型。

在我们的if检查中,TypeScript看到 typeof padding ==="number",并将其理解为一种特殊形式的代码,称为类型保护。TypeScript遵循我们的程序可能采取的执行路径,以分析一个值在特定位置的最具体的可能类型。它查看这些特殊的检查(称为类型防护)和赋值,将类型细化为比声明的更具体的类型的过程被称为缩小。在许多编辑器中,我们可以观察这些类型的变化,我们甚至会在我们的例子中这样做。

TypeScript 可以理解几种不同的缩小结构。

4.1 typeof类型守卫

正如我们所见,JavaScript 支持一个typeof运算符,它可以提供有关我们在运行时拥有的值类型的非常基本的信息。TypeScript 期望它返回一组特定的字符串:

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

就像我们在 padLeft 中看到的那样,这个运算符经常出现在许多 JavaScript 库中,TypeScript 可以理解为,它缩小在不同分支中的类型。

在 TypeScript 中,检查typeof的返回值是一种类型保护。因为 TypeScript 对typeof操作进行编码,从而返回不同的值,所以它知道对 JavaScript 做了什么。例如,请注意在上面的列表中,typeof不返回 string null。查看以下示例:

function printAll(strs: string | string[] | null) {
  if (typeof strs === "object") {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  } else {
    // 做点事
  }
}

printAll函数中,我们尝试检查strs是否为对象,来代替检查它是否为数组类型(现在可能是强调数组是 JavaScript 中的对象类型的好时机)。但事实证明,在 JavaScript 中,typeof null实际上也是"object"! 这是历史上的不幸事故之一。

有足够经验的用户可能不会感到惊讶,但并不是每个人都在 JavaScript 中遇到过这种情况;幸运的是,typescript 让我们知道,strs只缩小到string[] | null,而不仅仅是string[]

这可能是我们所谓的“真实性”检查的一个很好的过渡。

4.2 真值缩小

真值检查是我们在 JavaScript 中经常做的一件事。在 JavaScript 中,我们可以在条件、&&||if语句、布尔否定 ( !) 等中使用任何表达式。例如,if语句不希望它们的条件总是具有类型boolean

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `现在共有 ${numUsersOnline} 人在线!`;
  }
  return "现在没有人在线. :(";
}

在 JavaScript 中,像这样的if条件语句,首先将它们的条件“强制”转化为boolean 以使其有意义,然后根据结果是true还是false来选择它们的分支。像这面这些值:

  • 0
  • NaN
  • "" (空字符串)
  • 0nbigint零的版本)
  • null
  • undefined

以上所有值强制都转换为false,其他值被强制转化为true。你始终可以在Boolean函数中运行值获得boolean,或使用较短的双布尔否定将值强制转换为boolean。(后者的优点是 TypeScript 推断出一个狭窄的文字布尔类型true,而将第一个推断为 boolean类型。)

// 这两个结果都返回 true
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true, value: true

利用这种行为是相当流行的,尤其是在防范诸如nullundefined之类的值时。例如,让我们尝试将它用于我们的printAll函数。

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === "object") {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}

你会注意到我们已经通过检查strs是否为真,消除了上述错误。这至少可以防止我们在运行代码时出现可怕的错误,例如:

TypeError: null is not iterable

但请记住,对原语的真值检查通常容易出错。例如,考虑改写printAll:

function printAll(strs: string | string[] | null) {
  // !!!!!!!!!!!!!!!!
  //  别这样!
  //  原因在下边
  // !!!!!!!!!!!!!!!!
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

我们将整个函数体包装在一个真实的检查中,但这有一个小的缺点:我们可能不再正确处理空字符串的情况。

TypeScript 在这里根本不会报错,但是如果你不太熟悉 JavaScript,这是值得注意的行为。TypeScript 通常可以帮助你及早发现错误,但是如果你选择对某个值不做任何处理,那么它可以做的就只有这么多,而不会考虑过多的逻辑方面的问题。如果需要,你可以确保使用 linter(程序规范性) 处理此类情况。

关于通过真实性缩小范围的最后一点,是通过布尔否定!把逻辑从否定分支中过滤掉。

function multiplyAll(
  values: number[] | undefined,
  factor: number
): number[] | undefined {
  if (!values) {
    return values;
  } else {
    return values.map((x) => x * factor);
  }
}

4.3 等值缩小

typescript 也使用分支语句做===!====,和!=等值检查,来实现类型缩小。例如:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // 现在可以在x,y上调用字符串类型的方法了
    x.toUpperCase();
    y.toLowerCase();
  } else {
    console.log(x);
    console.log(y);
  }
}

当我们在上面的示例中检查xy是否相等时,TypeScript 知道它们的类型也必须相等。由于stringxy都可以采用的唯一常见类型,因此TypeScript 知道xy如果都是string,则程序走第一个分支中 。

检查特定的字面量值(而不是变量)也有效。在我们关于真值缩小的部分中,我们编写了一个printAll容易出错的函数,因为它没有正确处理空字符串。相反,我们可以做一个特定的检查来阻止null,并且 TypeScript 仍然正确地从strs里移除null

function printAll(strs: string | string[] | null) {
  if (strs !== null) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

JavaScript 更宽松的相等性检查==!=,也能被正确缩小。如果你不熟悉,如何检查某个变量是否== null,因为有时不仅要检查它是否是特定的值null,还要检查它是否可能是undefined。这同样适用于== undefined:它检查一个值是否为nullundefined。现在你只需要这个 ==!=就可以搞定了。

interface Container {
  value: number | null | undefined;
}
 
function multiplyValue(container: Container, factor: number) {
  // 从类型中排除了undefined 和 null
  if (container.value != null) {
    console.log(container.value);
    // 现在我们可以安全地乘以“container.value”了
    container.value *= factor;
  }
}

前三个打印是通过的,第四个有问题了。

4.4 in操作符缩小

JavaScript 有一个运算符,用于确定对象是否具有某个名称的属性:in运算符。TypeScript 考虑到了这一点,以此来缩小潜在类型的范围。

例如,使用代码:"value" in x。这里的 "value"是字符串文字,x是联合类型。值为“true”的分支缩小,需要x具有可选或必需属性的类型的值;值为 “false” 的分支缩小,需要具有可选或缺失属性的类型的值。

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    return animal.swim();
  }
 
  return animal.fly();
}

另外,可选属性还将存在于缩小的两侧,例如,人类可以游泳和飞行(使用正确的设备),因此应该出现在in检查的两侧:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };
 
function move(animal: Fish | Bird | Human) {
  if ("swim" in animal) {
  	// animal: Fish | Human
    animal;
  } else {
  	// animal: Bird | Human
    animal;
  }
}

4.5 instanceof操作符缩小

JavaScript 有一个运算符来 instanceof 检查一个值是否是另一个值的“实例”。更具体地,在JavaScript中x instanceof Foo 检查x原型链是否含有Foo.prototype。虽然我们不会在这里深入探讨,当我们进入 类(class)学习时,你会看到更多这样的内容,它们大多数可以使用new关键字实例化。 正如你可能已经猜到的那样,instanceof也是一个类型保护,TypeScript 在由instanceof保护的分支中实现缩小。

function logValue(x: Date | string) {
  if (x instanceof Date) {
    console.log(x.toUTCString());
  } else {
    console.log(x.toUpperCase());
  }
}

logValue(new Date()) // Mon, 15 Nov 2021 22:34:37 GMT
logValue('hello ts') // HELLO TS

4.6 分配缩小

正如我们之前提到的,当我们为任何变量赋值时,TypeScript 会查看赋值的右侧并适当缩小左侧。

// let x: string | number
let x = Math.random() < 0.5 ? 10 : "hello world!";

x = 1;
// let x: number
console.log(x);
           
x = "goodbye!";
// let x: string
console.log(x);

请注意,这些分配中的每一个都是有效的。即使在我们第一次赋值后观察到的类型x更改为 number,我们仍然可以将string赋值给x。这是因为声明类型x-该类型x开始是string | number

如果我们分配了一个booleanx,我们就会看到一个错误,因为它不是声明类型的一部分。

let x = Math.random() < 0.5 ? 10 : "hello world!";
   
// let x: string | number
x = 1;
     
// let x: number
console.log(x);
 
// 出错了!
x = true;

// let x: string | number
console.log(x);

4.7 控制流分析

到目前为止,我们已经通过一些基本示例来说明 TypeScript 如何在特定分支中缩小范围。但是除了从每个变量中走出来,并在ifwhile、条件等中寻找类型保护之外,还有更多的事情要做。例如:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return new Array(padding + 1).join(" ") + input;
  }
  return padding + input;
}

padLeft从其第一个 if块中返回。TypeScript 能够分析这段代码,并看到在 padding 是数字的情况下,主体的其余部分(return padding + input;)是不可达的。因此,它能够将数字从 padding 的类型中移除(从字符串|数字缩小到字符串),用于该函数的其余部分。

这种基于可达性的代码分析被称为控制流分析,TypeScript使用这种流分析来缩小类型,因为它遇到了类型守卫和赋值。当一个变量被分析时,控制流可以一次又一次地分裂和重新合并,该变量可以被观察到在每个点上有不同的类型。

function example() {
  let x: string | number | boolean;
 
  x = Math.random() < 0.5;
 
  // let x: boolean
  console.log(x);
 
  if (Math.random() < 0.5) {
    x = "hello";
    // let x: string
    console.log(x);
  } else {
    x = 100;
    // let x: number
    console.log(x);
  }
 
  // let x: string | number
  return x;
}

let x = example()
x = 'hello'
x = 100
x = true // error

4.8 使用类型谓词

到目前为止,我们已经用现有的JavaScript结构来处理窄化问题,然而有时你想更直接地控制整个代码中的类型变化。

为了定义一个用户定义的类型保护,我们只需要定义一个函数,其返回类型是一个类型谓词。

type Fish = {
  name: string
  swim: () => void
}

type Bird = {
  name: string
  fly: () => void
}

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined
}

在这个例子中,pet is Fish 是我们的类型谓词。谓词的形式是 parameterName is Type,其中 parameterName 必须是当前函数签名中的参数名称。

任何时候 isFish 被调用时,如果原始类型是兼容的,TypeScript将把该变量缩小到该特定类型。

function getSmallPet(): Fish | Bird {
  let fish: Fish = {
    name: 'gold fish',
    swim: () => {

    }
  }

  let bird: Bird = {
    name: 'sparrow',
    fly: () => {

    }
  }

  return true ? bird : fish
}

// 这里 pet 的 swim 和 fly 都可以访问了
let pet = getSmallPet()
if (isFish(pet)) {
  pet.swim()
} else {
  pet.fly()
}

注意,TypeScript不仅知道 petif 分支中是一条鱼;它还知道在 else 分支中,你没有一条 Fish,所以你一定有一只 Bird

你可以使用类型守卫 isFish 来过滤 Fish | Bird 的数组,获得 Fish 的数组。

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()]
const underWater1: Fish[] = zoo.filter(isFish)
// 或者,等同于
const underWater2: Fish[] = zoo.filter(isFish) as Fish[]

// 对于更复杂的例子,该谓词可能需要重复使用
const underWatch3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === 'frog') {
    return false
  }
  return isFish(pet)
})

4.9 受歧视的 unions

到目前为止,我们所看的大多数例子都是围绕着用简单的类型(如stringbooleannumber)来缩小单个变量。虽然这很常见,但在JavaScript中,大多数时候我们要处理的是稍微复杂的结构。

为了激发灵感,让我们想象一下,我们正试图对圆形和方形等形状进行编码。圆记录了它们的半径,方记录了它们的边长。我们将使用一个叫做 kind 的字段来告诉我们正在处理的是哪种形状。这里是定义 Shape 的第一个尝试。

interface Shape {
  kind: "circle" | "square";
  radius?: number;
  sideLength?: number;
}

注意,我们使用的是字符串字面类型的联合。"circle " "square "分别告诉我们应该把这个形状当作一个圆形还是方形。通过使用 "circle" | "square " 而不是 string,我们可以避免拼写错误的问题。

function handleShape(shape: Shape) {
  // oops!
  if (shape.kind === "rect") {
    // ...
  }
}

我们可以编写一个 getArea 函数,根据它处理的是圆形还是方形来应用正确的逻辑。我们首先尝试处理圆形。

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
}

strictNullChecks 下,这给了我们一个错误——这是很恰当的,因为 radius 可能没有被定义。但是如果我们对 kind 属性进行适当的检查呢?

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  }
}

嗯,TypeScript 仍然不知道该怎么做。我们遇到了一个问题,即我们对我们的值比类型检查器知道的更多。我们可以尝试使用一个非空的断言 (radius 后面的那个叹号! ) 来说明 radius 肯定存在。

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius! ** 2;
  }
}

但这感觉并不理想。我们不得不用那些非空的断言对类型检查器声明一个叹号(),以说服它相信shape.radius 是被定义的,但是如果我们开始移动代码,这些断言就容易出错。此外,在 strictNullChecks 之外,我们也可以意外地访问这些字段(因为在读取这些字段时,可选属性被认为总是存在的)。我们绝对可以做得更好。

Shape 的这种编码的问题是,类型检查器没有办法根据种类属性知道 radiussideLength 是否存在。我们需要把我们知道的东西传达给类型检查器。考虑到这一点,让我们再来定义一下Shape。

interface Circle {
  kind: "circle";
  radius: number;
}
 
interface Square {
  kind: "square";
  sideLength: number;
}
 
type Shape = Circle | Square;

在这里,我们正确地将 Shape 分成了两种类型,为 kind 属性设置了不同的值,但是 radiussideLength 在它们各自的类型中被声明为必需的属性。

让我们看看当我们试图访问 Shape 的半径时会发生什么。

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
}

就像我们对 Shape 的第一个定义一样,这仍然是一个错误。当半径是可选的时候,我们得到了一个错误(仅在strictNullChecks 中),因为 TypeScript 无法判断该属性是否存在。现在 Shape 是一个联合体,TypeScript 告诉我们 shape 可能是一个 Square ,而Square并没有定义半径 radius。 这两种解释都是正确的,但只有我们对 Shape 的新编码仍然在 strictNullChecks 之外导致错误。

但是,如果我们再次尝试检查kind属性呢?

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    // shape: Circle
    return Math.PI * shape.radius ** 2;
  }
}

这就摆脱了错误! 当 union 中的每个类型都包含一个与字面类型相同的属性时,TypeScript 认为这是一个有区别的 union ,并且可以缩小 union 的成员。

在这种情况下,kind 就是那个共同属性(这就是 Shape 的判别属性)。检查 kind 属性是否为 "circle",就可以剔除 Shape 中所有没有 "circle" 类型属性的类型。这就把 Shape 的范围缩小到了Circle这个类型。

同样的检查方法也适用于 switch 语句。现在我们可以试着编写完整的 getArea,而不需要任何讨厌的叹号 非空的断言。

function getArea(shape: Shape) {
  switch (shape.kind) {
  	// shape: Circle
    case "circle":
      return Math.PI * shape.radius ** 2;
      
    // shape: Square
    case "square":
      return shape.sideLength ** 2;
  }
}

这里最重要的是 Shape 的编码。向 TypeScript 传达正确的信息是至关重要的,这个信息就是 CircleSquare实际上是具有特定种类字段的两个独立类型。这样做让我们写出类型安全的TypeScript代码,看起来与我们本来要写的JavaScript没有区别。从那里,类型系统能够做 "正确 "的事情,并找出我们 switch语句的每个分支中的类型。

作为一个旁观者,试着玩一玩上面的例子,去掉一些返回关键词。你会发现,类型检查可以帮助避免在switch语句中不小心落入不同子句的bug。

辨证的联合体不仅仅适用于谈论圆形和方形。它们适合于在JavaScript中表示任何类型的消息传递方案,比如在网络上发送消息(client/server通信),或者在状态管理框架中编码突变。

4.10 never类型与穷尽性检查

在缩小范围时,你可以将一个联合体的选项减少到你已经删除了所有的可能性并且什么都不剩的程度。在这些情况下,TypeScript将使用一个never类型来代表一个不应该存在的状态。

never 类型可以分配给每个类型;但是,没有任何类型可以分配给never(除了never本身)。这意味着你可以使用缩小并依靠never的出现在 switch 语句中做详尽的检查。

例如,在我们的getArea函数中添加一个默认值,试图将形状分配给never,当每个可能的情况都没有被处理时,就会引发。

type Shape = Circle | Square;
 
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

Shape 联盟中添加一个新成员,将导致TypeScript错误。

interface Triangle {
  kind: "triangle";
  sideLength: number;
}
 
type Shape = Circle | Square | Triangle;
 
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

五、函数更多

函数是任何应用程序的基本构件,无论它们是本地函数,从另一个模块导入,还是一个类上的方法。它们也是值,就像其他值一样,TypeScript有很多方法来描述如何调用函数。让我们来学习一下如何编写描述函数的类型。

5.1 函数类型表达式

描述一个函数的最简单方法是用一个函数类型表达式。这些类型在语法上类似于箭头函数。

function greeter(fn: (a: string) => void) {
  fn("Hello, World");
}
 
function printToConsole(s: string) {
  console.log(s);
}
 
greeter(printToConsole);

语法(a: string) => void意味着 “有一个参数的函数,名为 a,类型为字符串,没有返回值”。就像函数声明一样,如果没有指定参数类型,它就隐含为 any 类型。

当然,我们可以用一个类型别名来命名一个函数类型。

type GreetFunction = (a: string) => void;
function greeter(fn: GreetFunction) {
  // ...
}

5.2 调用签名

在JavaScript中,除了可调用之外,函数还可以有属性。然而,函数类型表达式的语法不允许声明属性。如果我们想用属性来描述可调用的东西,我们可以在一个对象类型中写一个调用签名

type DescribableFunction = {
  description: string;
  (someArg: number): boolean;
}

function doSomething(fn: DescribableFunction) {
  console.log(fn.description + " returned " + fn(6));
}

function fn1() {
  return true
}
fn1.description = 'balabala...'

doSomething(fn1)

注意,与函数类型表达式相比,语法略有不同:在参数列表和返回类型之间使用:而不是=>

5.3 构造签名

JavaScript函数也可以用 new 操作符来调用。TypeScript将这些称为构造函数,因为它们通常会创建一个新的对象。你可以通过在调用签名前面添加 new 关键字来写一个构造签名。

class Ctor {
  s: string
  constructor(s: string) {
    this.s = s
  }
}

type SomeConstructor = {
  new (s: string): Ctor
}
function fn(ctor: SomeConstructor) {
  return new ctor("hello")
}

const f = fn(Ctor)
console.log(f.s)

有些对象,如 JavaScript 的 Date 对象,可以在有 new 或没有 new 的情况下被调用。你可以在同一类型中任意地结合调用和构造签名。

interface CallOrConstruct {
  new (s: string): Date;
  (n?: number): number;
}

function fn(date: CallOrConstruct) {
  let d = new date('2021-11-20')
  let n = date(100)
}

再举一个例子:

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);

5.4 泛型函数

在写一个函数时,输入的类型与输出的类型有关,或者两个输入的类型以某种方式相关,这是常见的。让我们考虑一下一个返回数组中第一个元素的函数。

function firstElement(arr: any[]) {
  return arr[0];
}

这个函数完成了它的工作,但不幸的是它的返回类型是 any。如果该函数返回数组元素的类型会更好。

在TypeScript中,当我们想描述两个值之间的对应关系时,会使用泛型。我们通过在函数签名中声明一个类型参数来做到这一点:

function firstElement(arr: Type[]): Type | undefined {
  return arr[0];
}

通过给这个函数添加一个类型参数 Type,并在两个地方使用它,我们已经在函数的输入(数组)和输出(返回值)之间建立了一个联系。现在当我们调用它时,一个更具体的类型就出来了:

// s 是 'string' 类型
const s = firstElement(["a", "b", "c"]);
// n 是 'number' 类型
const n = firstElement([1, 2, 3]);
// u 是 undefined 类型
const u = firstElement([]);

5.4.1 类型推断

请注意,在这个例子中,我们没有必要指定类型。类型是由TypeScript推断出来的–自动选择。

我们也可以使用多个类型参数。例如,一个独立版本的map看起来是这样的。

function map(arr: Input[], func: (arg: Input) => Output): Output[] {
  return arr.map(func);
}
 
// 参数'n'是'字符串'类型。
// 'parsed'是'number[]'类型。
const parsed = map(["1", "2", "3"], (n) => parseInt(n));

请注意,在这个例子中,TypeScript可以推断出输入类型参数的类型(从给定的字符串数组),以及基于函数表达式的返回值(数字)的输出类型参数。

5.4.2 限制条件

我们已经写了一些通用函数,可以对任何类型的值进行操作。有时我们想把两个值联系起来,但只能对某个值的子集进行操作。在这种情况下,我们可以使用一个约束条件来限制一个类型参数可以接受的类型。

让我们写一个函数,返回两个值中较长的值。要做到这一点,我们需要一个长度属性,是一个数字。我们通过写一个扩展子句将类型参数限制在这个类型上。

function longest(a: Type, b: Type) {
  if (a.length >= b.length) {
    return a;
  } else {
    return b;
  }
}
 
// longerArray 的类型是 'number[]'
const longerArray = longest([1, 2], [1, 2, 3]);
// longerString 是 'alice'|'bob' 的类型。
const longerString = longest("alice", "bob");
// 错误! 数字没有'长度'属性
const notOK = longest(10, 100);

在这个例子中,有一些有趣的事情需要注意。我们允许TypeScript推断 longest 的返回类型。返回类型推断也适用于通用函数。

因为我们将 Type 约束为 { length: number },所以我们被允许访问 ab 参数的 .length 属性。如果没有类型约束,我们就不能访问这些属性,因为这些值可能是一些没有长度属性的其他类型。

longerArraylongerString 的类型是根据参数推断出来的。记住,泛型就是把两个或多个具有相同类型的值联系起来。

最后,正如我们所希望的,对 longest(10, 100) 的调用被拒绝了,因为数字类型没有一个.length属性。

5.4.3 使用受限值

这里有一个使用通用约束条件时的常见错误。

function minimumLength(
  obj: Type,
  minimum: number
): Type {
  if (obj.length >= minimum) {
    return obj
  } else {
    return { length: minimum }
  }
}

看起来这个函数没有问题–Type被限制为{ length: number },而且这个函数要么返回Type,要么返回一个与该限制相匹配的值。问题是,该函数承诺返回与传入的对象相同的类型,而不仅仅是与约束条件相匹配的一些对象。如果这段代码是合法的,你可以写出肯定无法工作的代码。

// 'arr' 获得值: { length: 6 }
const arr = minimumLength([1, 2, 3], 6);
//在此崩溃,因为数组有一个'切片'方法,但没有返回对象!
console.log(arr.slice(0));

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mfra8v7q-1668765340998)(/Users/felix/Library/Application Support/typora-user-images/image-20211121105340359.png)]

5.4.4 指定类型参数

TypeScript 通常可以推断出通用调用中的预期类型参数,但并非总是如此。例如,假设你写了一个函数来合并两个数组:

function combine(arr1: Type[], arr2: Type[]): Type[] {
  return arr1.concat(arr2);
}

通常情况下,用不匹配的数组调用这个函数是一个错误:

const arr = combine([1, 2, 3], ["hello"]);

然而,如果你打算这样做,你可以手动指定类型:

const arr = combine([1, 2, 3], ["hello"]);

5.4.5 编写优秀通用函数的准则

编写泛型函数很有趣,而且很容易被类型参数所迷惑。有太多的类型参数或在不需要的地方使用约束,会使推理不那么成功,使你的函数的调用者感到沮丧。

  • 类型参数下推

下面是两种看似相似的函数写法。

function firstElement1(arr: Type[]) {
  return arr[0];
}
 
function firstElement2(arr: Type) {
  return arr[0];
}
 
// a: number (推荐)
const a = firstElement1([1, 2, 3]);
// b: any (不推荐)
const b = firstElement2([1, 2, 3]);

乍一看,这些可能是相同的,但 firstElement1 是写这个函数的一个更好的方法。它的推断返回类型是Type,但 firstElement2 的推断返回类型是 any,因为TypeScript必须使用约束类型来解析arr[0]表达式,而不是在调用期间 "等待 "解析该元素。

**规则:**在可能的情况下,使用类型参数本身,而不是对其进行约束

  • 使用更少的类型参数

下面是另一对类似的函数。

function filter1(arr: Type[], func: (arg: Type) => boolean): Type[] {
  return arr.filter(func);
}
 
function filter2 boolean>(
  arr: Type[],
  func: Func
): Type[] {
  return arr.filter(func);
}

我们已经创建了一个类型参数 Func,它并不涉及两个值。这总是一个值得标记的坏习惯,因为它意味着想要指定类型参数的调用者必须无缘无故地手动指定一个额外的类型参数。Func除了使函数更难阅读和推理外,什么也没做。

**规则:**总是尽可能少地使用类型参数

  • 类型参数应出现两次

有时我们会忘记,一个函数可能不需要是通用的:

function greet(s: Str) {
  console.log("Hello, " + s);
}
 
greet("world");

我们完全可以写一个更简单的版本:

function greet(s: string) {
  console.log("Hello, " + s);
}

记住,类型参数是用来关联多个值的类型的。如果一个类型参数在函数签名中只使用一次,那么它就没有任何关系。

**规则:**如果一个类型的参数只出现在一个地方,请重新考虑你是否真的需要它

5.5 可选参数

JavaScript中的函数经常需要一个可变数量的参数。例如,numbertoFixed方法需要一个可选的数字计数。

function f(n: number) {
  console.log(n.toFixed()); // 0 个参数
  console.log(n.toFixed(3)); // 1 个参数
}

我们可以在TypeScript中通过将参数用 ?标记:

function f(x?: number) {
  // ...
}
f(); // 正确
f(10); // 正确

虽然参数被指定为 number 类型,但 x 参数实际上将具有 number | undefined 类型,因为在JavaScript中未指定的参数会得到 undefined 的值。

你也可以提供一个参数默认值。

function f(x = 10) {
  // ...
}

现在在 f 的主体中,x 将具有 number 类型,因为任何 undefined 的参数将被替换为 10。请注意,当一个参数是可选的,调用者总是可以传递未定义的参数,因为这只是模拟一个 "丢失 "的参数:

declare function f(x?: number): void;

// 以下调用都是正确的
f();
f(10);
f(undefined);

5.5.1 回调中的可选参数

一旦你了解了可选参数和函数类型表达式,在编写调用回调的函数时就很容易犯以下错误:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i);
  }
}

我们在写 index? 作为一个可选参数时,通常是想让这些调用都是合法的:

myForEach([1, 2, 3], (a) => console.log(a));
myForEach([1, 2, 3], (a, i) => console.log(a, i));

这实际上意味着回调可能会被调用,只有一个参数。换句话说,该函数定义说,实现可能是这样的:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    // 我现在不想提供索引
    callback(arr[i]);
  }
}

反过来,TypeScript会强制执行这个意思,并发出实际上不可能的错误:

myForEach([1, 2, 3], (a, i) => {
  console.log(i.toFixed())
})

在JavaScript中,如果你调用一个形参多于实参的函数,额外的参数会被简单地忽略。TypeScript的行为也是如此。参数较少的函数(相同的类型)总是可以取代参数较多的函数的位置。

当为回调写一个函数类型时,永远不要写一个可选参数,除非你打算在不传递该参数的情况下调用函数。

5.6 函数重载

一些 JavaScript 函数可以在不同的参数数量和类型中被调用。例如,你可能会写一个函数来产生一个Date,它需要一个时间戳(一个参数)或一个月/日/年规格(三个参数)。

在TypeScript中,我们可以通过编写重载签名来指定一个可以以不同方式调用的函数。要做到这一点,要写一些数量的函数签名(通常是两个或更多),然后是函数的主体:

function makeDate(timestamp: number): Date;
function makeDate(m: number, d: number, y: number): Date;
function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
  if (d !== undefined && y !== undefined) {
    return new Date(y, mOrTimestamp, d);
  } else {
    return new Date(mOrTimestamp);
  }
}
const d1 = makeDate(12345678);
const d2 = makeDate(5, 5, 5);
const d3 = makeDate(1, 3);

在这个例子中,我们写了两个重载:一个接受一个参数,另一个接受三个参数。这前两个签名被称为重载签名。

然后,我们写了一个具有兼容签名的函数实现。函数有一个实现签名,但这个签名不能被直接调用。即使我们写了一个在所需参数之后有两个可选参数的函数,它也不能以两个参数被调用!

5.6.1 重载签名和实现签名

这是一个常见的混乱来源。通常我们会写这样的代码,却不明白为什么会出现错误:

function fn(x: string): void;
function fn() {
  // ...
}
// 期望能够以零参数调用
fn();

同样,用于编写函数体的签名不能从外面 “看到”。

实现的签名从外面是看不到的。在编写重载函数时,你应该总是在函数的实现上面有两个或多个签名。

实现签名也必须与重载签名兼容。例如,这些函数有错误,因为实现签名没有以正确的方式匹配重载:

function fn(x: boolean): void;
// 参数类型不正确
function fn(x: string): void;
function fn(x: boolean) {}
function fn(x: string): string;
// 返回类型不正确
function fn(x: number): boolean;
function fn(x: string | number) {
  return "oops";
}

5.6.2 编写好的重载

和泛型一样,在使用函数重载时,有一些准则是你应该遵循的。遵循这些原则将使你的函数更容易调用,更容易理解,更容易实现。

让我们考虑一个返回字符串或数组长度的函数:

function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
  return x.length;
}

这个函数是好的;我们可以用字符串或数组来调用它。然而,我们不能用一个可能是字符串或数组的值来调用它,因为TypeScript只能将一个函数调用解析为一个重载:

len(""); // OK
len([0]); // OK
len(Math.random() > 0.5 ? "hello" : [0]);

因为两个重载都有相同的参数数量和相同的返回类型,我们可以改写一个非重载版本的函数:

function len(x: any[] | string) {
  return x.length;
}

len(""); // OK
len([0]); // OK
len(Math.random() > 0.5 ? "hello" : [0]); // OK

这就好得多了! 调用者可以用任何一种值来调用它,而且作为额外的奖励,我们不需要找出一个正确的实现签名。

在可能的情况下,总是倾向于使用联合类型的参数而不是重载参数

5.6.3 函数内This的声明

TypeScript会通过代码流分析来推断函数中的 this 应该是什么,比如下面的例子:

const user = {
  id: 123,
 
  admin: false,
  becomeAdmin: function () {
    this.admin = true;
  },
};

TypeScript理解函数 user.becomeAdmin 有一个对应的 this,它是外部对象 user。这个对于很多情况来说已经足够了,但是有很多情况下你需要更多的控制 this 代表什么对象。JavaScript规范规定,你不能有一个叫 this的参数,所以TypeScript使用这个语法空间,让你在函数体中声明this的类型。

interface User {
  admin: boolean
}

interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}

const db:DB = {
  filterUsers: (filter: (this: User) => boolean) => {
    let user1 = {
      admin: true
    }
    let user2 = {
      admin: false
    }
    return [user1, user2]
  }
}

const admins = db.filterUsers(function (this: User) {
  return this.admin;
})

这种模式在回调式API中很常见,另一个对象通常控制你的函数何时被调用。注意,你需要使用函数而不是箭头函数来获得这种行为。

interface User {
  admin: boolean
}

interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}

const db:DB = {
  filterUsers: (filter: (this: User) => boolean) => {
    let user1 = {
      admin: true
    }
    let user2 = {
      admin: false
    }
    return [user1, user2]
  }
}
// 不能为箭头函数
const admins = db.filterUsers(() => this.admin);

5.7 需要了解的其他类型

有一些额外的类型你会想要认识,它们在处理函数类型时经常出现。像所有的类型一样,你可以在任何地方使用它们,但这些类型在函数的上下文中特别相关。

5.7.1 void

void表示没有返回值的函数的返回值。当一个函数没有任何返回语句,或者没有从这些返回语句中返回任何明确的值时,它都是推断出来的类型。

// 推断出的返回类型是void
function noop() {
  return;
}

在JavaScript中,一个不返回任何值的函数将隐含地返回undefinded的值。然而,在TypeScript中,voidundefined是不一样的。在本章末尾有进一步的细节。

voidundefined不一样。

5.7.2 object

特殊类型 object 指的是任何不是基元的值(stringnumberbigintbooleansymbolnull undefined)。这与空对象类型{ }不同,也与全局类型 Object 不同。你很可能永远不会使用 Object

object不是Object。始终使用object!

请注意,在JavaScript中,函数值是对象。它们有属性,在它们的原型链中有Object.prototype,是 Object的实例,你可以对它们调用 Object.key,等等。由于这个原因,函数类型在TypeScript中被认为是object

5.7.3 unknown

unknown类型代表任何值。这与 any 类型类似,但更安全,因为对未知 unknown 值做任何事情都是不合法的。

function f1(a: any) {
  a.b(); // 正确
}
function f2(a: unknown) {
  a.b();
}

这在描述函数类型时很有用,因为你可以描述接受任何值的函数,而不需要在函数体中有 any 值。

反之,你可以描述一个返回未知类型的值的函数:

function safeParse(s: string): unknown {
  return JSON.parse(s);
}
 
// 需要小心对待'obj'!
const obj = safeParse(someRandomString);

5.7.4 never

有些函数永远不会返回一个值:

function fail(msg: string): never {
  throw new Error(msg);
}

never类型表示永远不会被观察到的值。在一个返回类型中,这意味着函数抛出一个异常或终止程序的执行。

never也出现在TypeScript确定一个 union 中没有任何东西的时候。

function fn(x: string | number) {
  if (typeof x === "string") {
    // 做一些事
  } else if (typeof x === "number") {
    // 再做一些事
  } else {
    x; // 'never'!
  }
}

5.7.5 Function

全局性的 Function 类型描述了诸如 bindcallapply和其他存在于JavaScript中所有函数值的属性。它还有一个特殊的属性,即 Function 类型的值总是可以被调用;这些调用返回 any

function doSomething(f: Function) {
  return f(1, 2, 3);
}

这是一个无类型的函数调用,一般来说最好避免,因为 any 返回类型都不安全。

如果你需要接受一个任意的函数,但不打算调用它,一般来说,() => void的类型比较安全。

5.8 参数展开运算符

5.8.1 形参展开(Rest Parameters)

除了使用可选参数或重载来制作可以接受各种固定参数数量的函数之外,我们还可以使用休止参数来定义接受无限制数量的参数的函数。

rest参数出现在所有其他参数之后,并使用...的语法:

function multiply(n: number, ...m: number[]) {
  return m.map((x) => n * x);
}
// 'a' 获得的值 [10, 20, 30, 40]
const a = multiply(10, 1, 2, 3, 4);

在TypeScript中,这些参数的类型注解是隐含的 any[],而不是any,任何给出的类型注解必须是ArrayT[]的形式,或一个元组类型(我们将在后面学习)。

5.8.2 实参展开(Rest Arguments)

反之,我们可以使用 spread 语法从数组中提供可变数量的参数。例如,数组的 push 方法需要任意数量的参数。

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
arr1.push(...arr2);

请注意,一般来说,TypeScript并不假定数组是不可变的。这可能会导致一些令人惊讶的行为。

// 推断的类型是 number[] -- "一个有零或多个数字的数组"。
// 不专指两个数字
const args = [8, 5];
const angle = Math.atan2(...args);

这种情况的最佳解决方案取决于你的代码,但一般来说,const context 是最直接的解决方案。

// 推断为2个长度的元组
const args = [8, 5] as const;
// 正确
const angle = Math.atan2(...args);

5.9 参数解构

你可以使用参数重构来方便地将作为参数提供的对象,解压到函数主体的一个或多个局部变量中。在JavaScript中,它看起来像这样:

function sum({ a, b, c }) {
  console.log(a + b + c);
}
sum({ a: 10, b: 3, c: 9 });

对象的类型注解在解构的语法之后:

function sum({ a, b, c }: { a: number; b: number; c: number }) {
  console.log(a + b + c);
}

这看起来有点啰嗦,但你也可以在这里使用一个命名的类型:

// 与之前的例子相同
type ABC = { a: number; b: number; c: number };
function sum({ a, b, c }: ABC) {
  console.log(a + b + c);
}

5.10 函数的可分配性

5.10.1 返回 void 类型

函数的 void 返回类型可以产生一些不寻常的,但却是预期的行为。

返回类型为 void 的上下文类型并不强迫函数不返回东西。另一种说法是,一个具有 void返回类型的上下文函数类型(type vf = () => void),在实现时,可以返回任何其他的值,但它会被忽略。

因此,以下()=> void类型的实现是有效的:

type voidFunc = () => void
 
const f1: voidFunc = () => {
  return true
}
 
const f2: voidFunc = () => true
 
const f3: voidFunc = function () {
  return true
}

而当这些函数之一的返回值被分配给另一个变量时,它将保留 void 的类型:

const v1 = f1();
const v2 = f2();
const v3 = f3();

这种行为的存在使得下面的代码是有效的,即使 Array.prototype.push返回一个数字,而Array.prototype.forEach方法期望一个返回类型为void的函数:

const src = [1, 2, 3];
const dst = [0];
 
src.forEach((el) => dst.push(el));

还有一个需要注意的特殊情况,当一个字面的函数定义有一个 void 的返回类型时,该函数必须不返回任何东西。

function f2(): void {
  return true;
}
 
const f3 = function (): void {
  return true;
};

六、对象类型

在JavaScript中,我们分组和传递数据的基本方式是通过对象。在TypeScript中,我们通过对象类型来表示这些对象。

正如我们所见,它们可以是匿名的:

function greet(person: { name: string; age: number }) {
  return "Hello " + person.name;
}

或者可以通过使用一个接口来命名它们:

interface Person {
  name: string;
  age: number;
}
 
function greet(person: Person) {
  return "Hello " + person.name;
}

或一个类型别名:

type Person = {
  name: string;
  age: number;
};
 
function greet(person: Person) {
  return "Hello " + person.name;
}

在上面的三个例子中,我们写了一些函数,这些函数接收包含属性 name(必须是一个 string)和 age(必须是一个 number)的对象。

6.1 属性修改器

对象类型中的每个属性都可以指定几件事:类型、属性是否是可选的,以及属性是否可以被写入。

6.1.1 可选属性

很多时候,我们会发现自己处理的对象可能有一个属性设置。在这些情况下,我们可以在这些属性的名字后面加上一个问号(?),把它们标记为可选的。

type Shape = {}

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}
 
function paintShape(opts: PaintOptions) {
  // ...
}
 
const shape:Shape = {}
paintShape({ shape });
paintShape({ shape, xPos: 100 });
paintShape({ shape, yPos: 100 });
paintShape({ shape, xPos: 100, yPos: 100 });

在这个例子中,xPosyPos都被认为是可选的。我们可以选择提供它们中的任何一个,所以上面对paintShape的每个调用都是有效的。所有的可选性实际上是说,如果属性被设置,它最好有一个特定的类型。

我们也可以从这些属性中读取,但当我们在strictNullChecks下读取时,TypeScript会告诉我们它们可能是未定义的。

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos;
  let yPos = opts.yPos;
  // ...
}

在JavaScript中,即使该属性从未被设置过,我们仍然可以访问它–它只是会给我们未定义的值。我们可以专门处理未定义。

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos === undefined ? 0 : opts.xPos;
  let yPos = opts.yPos === undefined ? 0 : opts.yPos;
  // ...
}

请注意,这种为未指定的值设置默认值的模式非常普遍,以至于JavaScript有语法来支持它。

function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
  console.log("x coordinate at", xPos);
  console.log("y coordinate at", yPos);
  // ...
}

在这里,我们为 paintShape 的参数使用了一个解构模式,并为 xPosyPos 提供了默认值。现在 xPosyPos 都肯定存在于 paintShape 的主体中,但对于 paintShape 的任何调用者来说是可选的。

请注意,目前还没有办法将类型注释放在解构模式中。这是因为下面的语法在JavaScript中已经有了不同的含义。

function draw({ shape: Shape, xPos: number = 100 /*...*/ }) {
  render(shape);
  render(xPos);
}

在一个对象解构模式中,shape: Shape 意味着 "获取属性 shape,并在本地重新定义为一个名为 Shape 的变量。同样,xPos: number 创建一个名为number的变量,其值基于参数的 xPos

6.1.2 只读属性

对于TypeScript,属性也可以被标记为只读。虽然它不会在运行时改变任何行为,但在类型检查期间,一个标记为只读的属性不能被写入。

interface SomeType {
  readonly prop: string;
}
 
function doSomething(obj: SomeType) {
  // 可以读取 'obj.prop'.
  console.log(`prop has the value '${obj.prop}'.`);
 
  // 但不能重新设置值
  obj.prop = "hello";
}

使用 readonly 修饰符并不一定意味着一个值是完全不可改变的。或者换句话说,它的内部内容不能被改变,它只是意味着该属性本身不能被重新写入。

interface Home {
  readonly resident: { name: string; age: number };
}
 
function visitForBirthday(home: Home) {
  // 我们可以从'home.resident'读取和更新属性。
  console.log(`Happy birthday ${home.resident.name}!`);
  home.resident.age++;
}
 
function evict(home: Home) {
  // 但是我们不能写到'home'上的'resident'属性本身。
  home.resident = {
    name: "Victor the Evictor",
    age: 42,
  };
}

管理对 readonly 含义的预期是很重要的。在TypeScript的开发过程中,对于一个对象应该如何被使用的问题,它是有用的信号。TypeScript在检查两个类型的属性是否兼容时,并不考虑这些类型的属性是否是 readonly,所以 readony 属性也可以通过别名来改变。

interface Person {
  name: string;
  age: number;
}
 
interface ReadonlyPerson {
  readonly name: string;
  readonly age: number;
}
 
let writablePerson: Person = {
  name: "Person McPersonface",
  age: 42,
};
 
// 正常工作
let readonlyPerson: ReadonlyPerson = writablePerson;
 
console.log(readonlyPerson.age); // 打印 '42'
writablePerson.age++;
console.log(readonlyPerson.age); // 打印 '43'

6.1.3 索引签名

有时你并不提前知道一个类型的所有属性名称,但你知道值的形状。

在这些情况下,你可以使用一个索引签名来描述可能的值的类型,比如说:

interface StringArray {
  [index: number]: string;
}
 
const myArray: StringArray = ['a', 'b'];
const secondItem = myArray[1];

上面,我们有一个 StringArray 接口,它有一个索引签名。这个索引签名指出,当一个 StringArray 被数字索引时,它将返回一个字符串。

索引签名的属性类型必须是 stringnumber

支持两种类型的索引器是可能的,但是从数字索引器返回的类型必须是字符串索引器返回的类型的子类型。这是因为当用 "数字 "进行索引时,JavaScript实际上会在索引到一个对象之前将其转换为 “字符串”。这意味着用100(一个数字)进行索引和用"100"(一个字符串)进行索引是一样的,所以两者需要一致。

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

interface NotOkay {
  [x: number]: Animal;
  [x: string]: Dog;
}

虽然字符串索引签名是描述 "字典 "模式的一种强大方式,但它也强制要求所有的属性与它们的返回类型相匹配。这是因为字符串索引声明 obj.property 也可以作为 obj["property"]。在下面的例子中,name的类型与字符串索引的类型不匹配,类型检查器会给出一个错误:

interface NumberDictionary {
  [index: string]: number;
 
  length: number; // ok
  name: string;
}

然而,如果索引签名是属性类型的联合,不同类型的属性是可以接受的:

interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // 正确, length 是 number 类型
  name: string; // 正确, name 是 string 类型
}

最后,你可以使索引签名为只读,以防止对其索引的赋值:

interface ReadonlyStringArray {
  readonly [index: number]: string;
}
 
let myArray: ReadonlyStringArray = getReadOnlyStringArray();
myArray[2] = "Mallory";

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

6.2 扩展类型

有一些类型可能是其他类型的更具体的版本,这是很常见的。例如,我们可能有一个BasicAddress类型,描述发送信件和包裹所需的字段。

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

在某些情况下,这就足够了,但是如果一个地址的小区内有多个单元,那么地址往往有一个单元号与之相关。我们就可以描述一个 AddressWithUnit

interface AddressWithUnit {
  name?: string;
  unit: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

这就完成了工作,但这里的缺点是,当我们的变化是纯粹的加法时,我们不得不重复 BasicAddress 的所有其他字段。相反,我们可以扩展原始的 BasicAddress 类型,只需添加 AddressWithUnit 特有的新字段:

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}
 
  interface AddressWithUnit extends BasicAddress {
    unit: string;
  }

接口上的 extends 关键字,允许我们有效地从其他命名的类型中复制成员,并添加我们想要的任何新成员。这对于减少我们不得不写的类型声明模板,以及表明同一属性的几个不同声明可能是相关的意图来说,是非常有用的。例如,AddressWithUnit 不需要重复 street 属性,而且因为 street 源于 BasicAddress,我们会知道这两种类型在某种程度上是相关的。

接口也可以从多个类型中扩展。

interface Colorful {
  color: string;
}
 
interface Circle {
  radius: number;
}
 
interface ColorfulCircle extends Colorful, Circle {}
 
const cc: ColorfulCircle = {
  color: "red",
  radius: 42,
};

6.3 交叉类型

接口允许我们通过扩展其他类型建立起新的类型。TypeScript提供了另一种结构,称为交叉类型,主要用于组合现有的对象类型。

交叉类型是用 & 操作符定义的。

interface Colorful {
  color: string;
}
interface Circle {
  radius: number;
}
 
type ColorfulCircle = Colorful & Circle;

const cc: ColorfulCircle = {
  color: "red",
  radius: 42,
}

在这里,我们将 ColorfulCircle 相交,产生了一个新的类型,它拥有 ColorfulCircle 的所有成员。

function draw(circle: Colorful & Circle) {
  console.log(`Color was ${circle.color}`);
  console.log(`Radius was ${circle.radius}`);
}
 
// 正确
draw({ color: "blue", radius: 42 });
 
// 错误
draw({ color: "red", raidus: 42 });

6.7 接口与交叉类型

我们刚刚看了两种组合类型的方法,它们很相似,但实际上有细微的不同。对于接口,我们可以使用 extends子句来扩展其他类型,而对于交叉类型,我们也可以做类似的事情,并用类型别名来命名结果。两者之间的主要区别在于如何处理冲突,这种区别通常是你在接口和交叉类型的类型别名之间选择一个的主要原因之一。

接口可以定义多次,多次的声明会自动合并:

interface Sister { 
  name: string; 
} 
interface Sister { 
  age: number; 
} 
 
const sisterAn: Sister = { 
  name: 'sisterAn' 
}
 
const sisterRan: Sister = { 
  name: 'sisterRan',  
  age: 12 
}

但是类型别名如果定义多次,会报错:

type Sister = {
  name: string; 
} 
 
type Sister = {
  age: number; 
} 

6.8 泛型对象类型

让我们想象一下,一个可以包含任何数值的盒子类型:字符串、数字、长颈鹿,等等。

interface Box {
  contents: any;
}

现在,内容属性的类型是任意,这很有效,但会导致下一步的意外。

我们可以使用 unknown,但这意味着在我们已经知道内容类型的情况下,我们需要做预防性检查,或者使用容易出错的类型断言。

interface Box {
  contents: unknown;
}
 
let x: Box = {
  contents: "hello world",
};
 
// 我们需要检查 'x.contents'
if (typeof x.contents === "string") {
  console.log(x.contents.toLowerCase());
}
 
// 或者用类型断言
console.log((x.contents as string).toLowerCase());

一种安全的方法是为每一种类型的内容搭建不同的盒子类型:

interface NumberBox {
  contents: number;
}
 
interface StringBox {
  contents: string;
}
 
interface BooleanBox {
  contents: boolean;
}

但这意味着我们必须创建不同的函数,或函数的重载,以对这些类型进行操作:

function setContents(box: StringBox, newContents: string): void;
function setContents(box: NumberBox, newContents: number): void;
function setContents(box: BooleanBox, newContents: boolean): void;
function setContents(box: { contents: any }, newContents: any) {
  box.contents = newContents;
}

那是一个很大的模板。此外,我们以后可能需要引入新的类型和重载。这是令人沮丧的,因为我们的盒子类型和重载实际上都是一样的。

相反,我们可以做一个通用的 Box 类型,声明一个类型参数:

interface Box {
  contents: Type;
}

你可以把这句话理解为:“一个类型的盒子,是它的内容具有类型的东西”。以后,当我们引用 Box 时,我们必须给一个类型参数来代替 Type

let box: Box;

Box 想象成一个真实类型的模板,其中 Type 是一个占位符,会被替换成其他类型。当 TypeScript 看到 Box 时,它将用字符串替换 Box 中的每个 Type 实例,并最终以 { contents: string } 这样的方式工作。换句话说,Box和我们之前的StringBox工作起来是一样的。

interface Box {
  contents: Type;
}
interface StringBox {
  contents: string;
}

let boxA: Box = { contents: "hello" };
boxA.contents;
 
let boxB: StringBox = { contents: "world" };
boxB.contents;

盒子是可重用的,因为Type可以用任何东西来代替。这意味着当我们需要一个新类型的盒子时,我们根本不需要声明一个新的盒子类型(尽管如果我们想的话,我们当然可以)。

interface Box {
  contents: Type;
}
 
interface Apple {
  // ....
}
 
// 等价于 '{ contents: Apple }'.
type AppleBox = Box;

这也意味着我们可以完全避免重载,而是使用通用函数。

function setContents(box: Box, newContents: Type) {
  box.contents = newContents;
}

值得注意的是,类型别名也可以是通用的。我们可以定义我们新的 Box接口:

interface Box {
  contents: Type;
}

通过使用一个类型别名来代替:

type Box = {
  contents: Type;
}

由于类型别名与接口不同,它不仅可以描述对象类型,我们还可以用它来编写其他类型的通用辅助类型。

type OrNull = Type | null;
 
type OneOrMany = Type | Type[];
 
type OneOrManyOrNull = OrNull>;

type OneOrManyOrNullStrings = OneOrManyOrNull;

我们将在稍后回到类型别名。

通用对象类型通常是某种容器类型,它的工作与它们所包含的元素类型无关。数据结构以这种方式工作是很理想的,这样它们就可以在不同的数据类型中重复使用。

6.9 数组类型

我们一直在使用这样一种类型:数组类型。每当我们写出 number[]string[] 这样的类型时,这实际上只是 ArrayArray的缩写。

function doSomething(value: Array) {
  // ...
}
 
let myArray: string[] = ["hello", "world"];
 
// 这两样都能用
doSomething(myArray);
doSomething(new Array("hello", "world"));

和上面的 Box 类型一样,Array 本身也是一个通用类型。

interface Array {
  /**
   * 获取或设置数组的长度。
   */
  length: number;
 
  /**
   * 移除数组中的最后一个元素并返回。
   */
  pop(): Type | undefined;
 
  /**
   * 向一个数组添加新元素,并返回数组的新长度。
   */
  push(...items: Type[]): number;
 
  // ...
}

现代JavaScript还提供了其他通用的数据结构,比如 Map , Set, 和 Promise。这实际上意味着,由于MapSetPromise的行为方式,它们可以与任何类型的集合一起工作。

6.10 只读数组类型

ReadonlyArray是一个特殊的类型,描述了不应该被改变的数组。

function doStuff(values: ReadonlyArray) {
  // 我们可以从 'values' 读数据...
  const copy = values.slice();
  console.log(`第一个值是 ${values[0]}`);
 
  // ...但我们不能改变 'vulues' 的值。
  values.push("hello!");
}

和属性的 readonly 修饰符一样,它主要是一个我们可以用来了解意图的工具。当我们看到一个返回ReadonlyArrays 的函数时,它告诉我们我们根本不打算改变其内容,而当我们看到一个消耗 ReadonlyArrays的函数时,它告诉我们可以将任何数组传入该函数,而不用担心它会改变其内容。

Array 不同,没有一个我们可以使用的 ReadonlyArray构造函数。

new ReadonlyArray("red", "green", "blue");

相反,我们可以将普通的 Array 分配给 ReadonlyArray

const roArray: ReadonlyArray = ["red", "green", "blue"];

正如 TypeScript为 Array 提供了 Type[] 的速记语法一样,它也为 ReadonlyArray提供了只读Type[]的速记语法。

function doStuff(values: readonly string[]) {
  // 我们可以从 'values' 读数据...
  const copy = values.slice();
  console.log(`The first value is ${values[0]}`);
 
  // 但我们不能改变 'vulues' 的值。
  values.push("hello!");
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EVKej3dm-1668765341000)(./images/06-13.png)]

最后要注意的是,与 readony 属性修改器不同,可分配性在普通 ArrayReadonlyArray 之间不是双向的。

let x: readonly string[] = [];
let y: string[] = [];
 
x = y;
y = x;

6.11 元组类型

Tuple 类型是另一种 Array 类型,它确切地知道包含多少个元素,以及它在特定位置包含哪些类型。

type StringNumberPair = [string, number];

这里,StringNumberPair 是一个 stringnumber 的元组类型。像 ReadonlyArray 一样,它在运行时没有表示,但对TypeScript来说是重要的。对于类型系统来说,StringNumberPair 描述了其 索引 0 包含字符串和 索引1 包含数字的数组。

function doSomething(pair: [string, number]) {
  const a = pair[0];
  const b = pair[1];
  // ...
}
 
doSomething(["hello", 42])

如果我们试图索引超过元素的数量,我们会得到一个错误:

function doSomething(pair: [string, number]) {
  const c = pair[2];
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HCbWM9ot-1668765341001)(./images/06-15.png)]

我们还可以使用JavaScript的数组析构来对元组进行解构。

function doSomething(stringHash: [string, number]) {
  const [inputString, hash] = stringHash;
 
  console.log(inputString);

  console.log(hash);        
}

除了这些长度检查,像这样的简单元组类型等同于 Array 的版本,它为特定的索引声明属性,并且用数字字面类型声明长度。

interface StringNumberPair {
  // 专有属性
  length: 2;
  0: string;
  1: number;
 
  // 其他 'Array' 成员...
  slice(start?: number, end?: number): Array;
}

另一件你可能感兴趣的事情是,元组可以通过在元素的类型后面写出问号(?)—— 可选的元组,元素只能出现在末尾,而且还影响到长度的类型。

type Either2dOr3d = [number, number, number?];
 
function setCoordinate(coord: Either2dOr3d) {
  const [x, y, z] = coord;
  console.log(`提供的坐标有 ${coord.length} 个维度`);
}

图元也可以有其余元素,这些元素必须是 array/tuple 类型。

type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];
  • StringNumberBooleans 描述了一个元组,其前两个元素分别是字符串和数字,但后面可以有任意数量的布尔。

  • StringBooleansNumber 描述了一个元组,其第一个元素是字符串,然后是任意数量的布尔运算,最后是一个数字。

  • BooleansStringNumber 描述了一个元组,其起始元素是任意数量的布尔运算,最后是一个字符串,然后是一个数字。

一个有其余元素的元组没有集合的 “长度”——它只有一组不同位置的知名元素。

const a: StringNumberBooleans = ["hello", 1];
const b: StringNumberBooleans = ["beautiful", 2, true];
const c: StringNumberBooleans = ["world", 3, true, false, true, false, true];

为什么可选元素和其余元素可能是有用的?它允许TypeScript将 tuples 与参数列表相对应。

function readButtonInput(...args: [string, number, ...boolean[]]) {
  const [name, version, ...input] = args;
  // ...
}

基本上等同于:

function readButtonInput(name: string, version: number, ...input: boolean[]) {
  // ...
}

当你想用一个其余(rest)参数接受可变数量的参数,并且你需要一个最小的元素数量,但你不想引入中间变量时,这很方便。

6.12 只读元组类型

关于 tuple 类型的最后一点说明:tuple 类型有只读特性,可以通过在它们前面粘贴一个readonly修饰符来指定——就像数组的速记语法一样。

function doSomething(pair: readonly [string, number]) {
  // ...
}

正如你所期望的,在TypeScript中不允许向只读元组的任何属性写入。

function doSomething(pair: readonly [string, number]) {
  pair[0] = "hello!";
}

在大多数代码中,元组往往被创建并不被修改,所以在可能的情况下,将类型注释为只读元组是一个很好的默认。这一点也很重要,因为带有 const 断言的数组字面量将被推断为只读元组类型。

let point = [3, 4] as const;
 
function distanceFromOrigin([x, y]: [number, number]) {
  return Math.sqrt(x ** 2 + y ** 2);
}
 
distanceFromOrigin(point);

在这里,distanceFromOrigin 从未修改过它的元素,而是期望一个可变的元组。由于 point的类型被推断为只读的 [3, 4],它与 [number, number] 不兼容,因为该类型不能保证 point 的元素不被修改。

七、类型操纵

7.0 从类型中创建类型

TypeScript的类型系统非常强大,因为它允许用其他类型的术语来表达类型。

这个想法的最简单的形式是泛型,我们实际上有各种各样的类型操作符可以使用。也可以用我们已经有的值来表达类型。

通过结合各种类型操作符,我们可以用一种简洁、可维护的方式来表达复杂的操作和值。在本节中,我们将介绍用现有的类型或值来表达一个新类型的方法。

  • 泛型型 - 带参数的类型

  • Keyof 类型操作符- keyof 操作符创建新类型

  • Typeof 类型操作符 - 使用 typeof 操作符来创建新的类型`

  • 索引访问类型 - 使用 Type['a'] 语法来访问一个类型的子集

  • 条件类型 - 在类型系统中像if语句一样行事的类型

  • 映射类型 - 通过映射现有类型中的每个属性来创建类型

  • 模板字面量类型 - 通过模板字面字符串改变属性的映射类型

7.1 泛型

软件工程的一个主要部分是建立组件,这些组件不仅有定义明确和一致的API,而且还可以重复使用。能够处理今天的数据和明天的数据的组件将为你建立大型软件系统提供最灵活的能力。

在像C#和Java这样的语言中,创建可重用组件的工具箱中的主要工具之一是泛型,也就是说,能够创建一个在各种类型上工作的组件,而不是单一的类型。这使得用户可以消费这些组件并使用他们自己的类型。

7.1.1 Hello World

首先,让我们做一下泛型的 " hello world":身份函数。身份函数是一个函数,它将返回传入的任何内容。你可以用类似于echo命令的方式来考虑它。

如果没有泛型,我们将不得不给身份函数一个特定的类型。

function identity(arg: number): number {
  return arg;
}

或者,我们可以用任意类型来描述身份函数。

function identity(arg: any): any {
  return arg;
}

使用 any 当然是通用的,因为它将使函数接受 arg类型的任何和所有的类型,但实际上我们在函数返回时失去了关于该类型的信息。如果我们传入一个数字,我们唯一的信息就是任何类型都可以被返回。

相反,我们需要一种方法来捕获参数的类型,以便我们也可以用它来表示返回的内容。在这里,我们将使用一个类型变量,这是一种特殊的变量,对类型而不是数值起作用。

function identity(arg: Type): Type {
  return arg;
}

我们现在已经在身份函数中添加了一个类型变量 Type。这个 Type 允许我们捕获用户提供的类型(例如数字),这样我们就可以在以后使用这些信息。这里,我们再次使用Type作为返回类型。经过检查,我们现在可以看到参数和返回类型使用的是相同的类型。这使得我们可以将类型信息从函数的一侧输入,然后从另一侧输出。

我们说这个版本的身份函数是通用的,因为它在一系列的类型上工作。与使用任何类型不同的是,它也和第一个使用数字作为参数和返回类型的身份函数一样精确(即,它不会丢失任何信息)。

一旦我们写好了通用身份函数,我们就可以用两种方式之一来调用它。第一种方式是将所有的参数,包括类型参数,都传递给函数:

let output = identity("myString");

这里我们明确地将 Type 设置为string,作为函数调用的参数之一,用参数周围的 <> 而不是 () 来表示。

第二种方式可能也是最常见的。这里我们使用类型参数推理——也就是说,我们希望编译器根据我们传入的参数的类型,自动为我们设置 Type 的值。

let output = identity("myString");

注意,我们不必在角括号(<>)中明确地传递类型;编译器只是查看了 "myString "这个值,并将Type设置为其类型。虽然类型参数推断是一个有用的工具,可以使代码更短、更易读,但当编译器不能推断出类型时,你可能需要像我们在前面的例子中那样明确地传入类型参数,这在更复杂的例子中可能发生。

7.1.2 使用通用类型变量

当你开始使用泛型时,你会注意到,当你创建像identity这样的泛型函数时,编译器会强制要求你在函数主体中正确使用任何泛型参数。也就是说,你实际上是把这些参数当作是任何和所有的类型。

让我们来看看我们前面的 identity 函数。

function identity(arg: Type): Type {
  return arg;
}

如果我们想在每次调用时将参数 arg 的长度记录到控制台,该怎么办?我们可能很想这样写:

function loggingIdentity(arg: Type): Type {
  console.log(arg.length);
  return arg;
}

当我们这样做时,编译器会给我们一个错误,说我们在使用 arg.length 成员,但我们没有说 arg 有这个成员。记住,我们在前面说过,这些类型的变量可以代表任何和所有的类型,所以使用这个函数的人可以传入一个number ,而这个数字没有一个 .length 成员。

比方说,我们实际上是想让这个函数在 Type 的数组上工作,而不是直接在 Type上工作。既然我们在处理数组,那么.length成员应该是可用的。我们可以像创建其他类型的数组那样来描述它。

function loggingIdentity(arg: Type[]): Type[] {
  console.log(arg.length);
  return arg;
}

你可以把 loggingIdentity 的类型理解为 “通用函数 loggingIdentity 接收一个类型参数 Type 和一个参数 argarg是一个Type数组,并返回一个Type数组。” 如果我们传入一个数字数组,我们会得到一个数字数组,因为Type会绑定到数字。这允许我们使用我们的通用类型变量 Type 作为我们正在处理的类型的一部分,而不是整个类型,给我们更大的灵活性。

我们也可以这样来写这个例子:

function loggingIdentity(arg: Array): Array {
  console.log(arg.length); // 数组有一个.length,所以不会再出错了
  return arg;
}

你可能已经从其他语言中熟悉了这种类型的风格。在下一节中,我们将介绍如何创建你自己的通用类型,如Array

7.1.3 泛型类型

在前几节中,我们创建了在一系列类型上工作的通用身份函数。在这一节中,我们将探讨函数本身的类型以及如何创建通用接口。

泛型函数的类型与非泛型函数的类型一样,类型参数列在前面,与函数声明类似:

function identity(arg: Type): Type {
  return arg;
}
 
let myIdentity: (arg: Type) => Type = identity;

我们也可以为类型中的通用类型参数使用一个不同的名字,只要类型变量的数量和类型变量的使用方式一致。

function identity(arg: Type): Type {
  return arg;
}
 
let myIdentity: (arg: Input) => Input = identity;

我们也可以把泛型写成一个对象字面类型的调用签名。

function identity(arg: Type): Type {
  return arg;
}
 
let myIdentity: { (arg: Type): Type } = identity;

这让我们开始编写我们的第一个泛型接口。让我们把前面例子中的对象字面意思移到一个接口中。

interface GenericIdentityFn {
  (arg: Type): Type;
}
 
function identity(arg: Type): Type {
  return arg;
}
 
let myIdentity: GenericIdentityFn = identity;

在一个类似的例子中,我们可能想把通用参数移到整个接口的参数上。这可以让我们看到我们的泛型是什么类型(例如,Dictionary而不是仅仅Dictionary)。这使得类型参数对接口的所有其他成员可见。

interface GenericIdentityFn {
  (arg: Type): Type;
}
 
function identity(arg: Type): Type {
  return arg;
}
 
let myIdentity: GenericIdentityFn = identity;

请注意,我们的例子已经改变了,变成了稍微不同的东西。我们现在没有描述一个泛型函数,而是有一个非泛型的函数签名,它是泛型类型的一部分。当我们使用 GenericIdentityFn 时,我们现在还需要指定相应的类型参数(这里是:数字),有效地锁定了底层调用签名将使用什么。了解什么时候把类型参数直接放在调用签名上,什么时候把它放在接口本身,将有助于描述一个类型的哪些方面是通用的。

除了泛型接口之外,我们还可以创建泛型类。注意,不可能创建泛型枚举和命名空间。

7.1.4 泛型类

一个泛型类的形状与泛型接口相似。泛型类在类的名字后面有一个角括号(<>)中的泛型参数列表。

class GenericNumber {
  zeroValue: NumType;
  add: (x: NumType, y: NumType) => NumType;
}
 
let myGenericNumber = new GenericNumber();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
  return x + y;
};

这是对GenericNumber类相当直白的使用,但你可能已经注意到,没有任何东西限制它只能使用数字类型。我们本可以使用字符串或更复杂的对象。

let stringNumeric = new GenericNumber();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
  return x + y;
}
console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));

就像接口一样,把类型参数放在类本身,可以让我们确保类的所有属性都与相同的类型一起工作。

正如我们在关于类的章节中提到的,一个类的类型有两个方面:静态方面和实例方面。通用类只在其实例侧而非静态侧具有通用性,所以在使用类时,静态成员不能使用类的类型参数。

7.1.5 泛型约束

如果你还记得前面的例子,你有时可能想写一个通用函数,在一组类型上工作,而你对这组类型会有什么能力有一定的了解。在我们的 loggingIdentity 例子中,我们希望能够访问 arg.length 属性,但是编译器无法证明每个类型都有一个 .length 属性,所以它警告我们不能做这个假设。

function loggingIdentity(arg: Type): Type {
  console.log(arg.length);
  return arg;
}

我们希望限制这个函数与 any 和所有类型一起工作,而不是与 any 和所有同时具有 .length 属性的类型一起工作。只要这个类型有这个成员,我们就允许它,但它必须至少有这个成员。要做到这一点,我们必须把我们的要求作为一个约束条件列在 Type 可以是什么。

为了做到这一点,我们将创建一个接口来描述我们的约束。在这里,我们将创建一个接口,它有一个单一的 .length 属性,然后我们将使用这个接和 extends 关键字来表示我们的约束条件。

interface Lengthwise {
  length: number;
}
 
function loggingIdentity(arg: Type): Type {
  console.log(arg.length); // 现在我们知道它有一个 .length 属性,所以不再有错误了
  return arg;
}

因为泛型函数现在被限制了,它将不再对 any 和 所有的类型起作用。

loggingIdentity(3);

相反,我们需要传入其类型具有所有所需属性的值。

loggingIdentity({ length: 10, value: 3 });

7.1.6 在泛型约束中使用类型参数

你可以声明一个受另一个类型参数约束的类型参数。例如,在这里我们想从一个给定名称的对象中获取一个属性。我们想确保我们不会意外地获取一个不存在于 obj 上的属性,所以我们要在这两种类型之间放置一个约束条件。

function getProperty(obj: Type, key: Key) {
  return obj[key];
}
 
let x = { a: 1, b: 2, c: 3, d: 4 };
 
getProperty(x, "a");
getProperty(x, "m");

7.1.7 在泛型中使用类类型

在TypeScript中使用泛型创建工厂时,有必要通过其构造函数来引用类的类型。比如说:

function create(c: { new (): Type }): Type {
  return new c();
}

一个更高级的例子,使用原型属性来推断和约束类类型的构造函数和实例方之间的关系。

class BeeKeeper {
  hasMask: boolean = true;
}
 
class ZooKeeper {
  nametag: string = "Mikle";
}
 
class Animal {
  numLegs: number = 4;
}
 
class Bee extends Animal {
  keeper: BeeKeeper = new BeeKeeper();
}
 
class Lion extends Animal {
  keeper: ZooKeeper = new ZooKeeper();
}
 
function createInstance(c: new () => A): A {
  return new c();
}
 
createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;

7.2 Keyof类型操作符

keyof运算符接收一个对象类型,并产生其键的字符串或数字字面联合。下面的类型P与 “x”|"y "是同一类型。

type Point = { x: number; y: number };
type P = keyof Point;
const p1:P = 'x'
const p2:P = 'y'

如果该类型有一个字符串或数字索引签名,keyof 将返回这些类型。

type Arrayish = { [n: number]: unknown };
type A = keyof Arrayish;
const a:A = 0

type Mapish = { [k: string]: boolean };
type M = keyof Mapish;
const m:M = 'a'
const m2:M = 10

注意,在这个例子中,Mstring|number——这是因为JavaScript对象的键总是被强制为字符串,所以 obj[0]总是与obj["0"]相同。

keyof类型在与映射类型结合时变得特别有用,我们将在后面进一步了解。

7.3 Typeof 类型操作符

JavaScript已经有一个 typeof 操作符,你可以在表达式上下文中使用。

// 输出 "string"
console.log(typeof "Hello world");

TypeScript添加了一个typeof操作符,你可以在类型上下文中使用它来引用一个变量或属性的类型。

let s = "hello";
let n: typeof s;
n = 'world'
n= 100

这对基本类型来说不是很有用,但结合其他类型操作符,你可以使用typeof来方便地表达许多模式。举一个例子,让我们先看看预定义的类型ReturnType。它接收一个函数类型并产生其返回类型:

type Predicate = (x: unknown) => boolean;
type K = ReturnType;

如果我们试图在一个函数名上使用 ReturnType,我们会看到一个指示性的错误。

function f() {
  return { x: 10, y: 3 };
}
type P = ReturnType;

请记住,值和类型并不是一回事。为了指代值f的类型,我们使用 typeof

function f() {
  return { x: 10, y: 3 };
}
type P = ReturnType;

TypeScript 故意限制了你可以使用 typeof 的表达式种类。

具体来说,只有在标识符(即变量名)或其属性上使用typeof是合法的。这有助于避免混乱的陷阱,即编写你认为是在执行的代码,但其实不是。

// 我们认为使用 = ReturnType
let shouldContinue: typeof msgbox("Are you sure you want to continue?");

7.4 索引访问类型

我们可以使用一个索引访问类型来查询另一个类型上的特定属性:

type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"];

索引类型本身就是一个类型,所以我们可以完全使用 unions、keyof 或者其他类型。

interface Person {
  name: string
  age: number
  alive: boolean
}

// type I1 = string | number
type I1 = Person["age" | "name"];
const i11:I1 = 100
const i12:I1 = ''
     
// type I2 = string | number | boolean
type I2 = Person[keyof Person];
const i21:I2 = ''
const i22:I2 = 100
const i23:I2 = false
     
// type I3 = Person[AliveOrName];
type AliveOrName = "alive" | "name";
const aon1:AliveOrName = 'alive'
const aon2:AliveOrName = 'name'

如果你试图索引一个不存在的属性,你甚至会看到一个错误:

type I1 = Person["alve"];

另一个使用任意类型进行索引的例子是使用number来获取一个数组元素的类型。我们可以把它和 typeof结合起来,方便地获取一个数组字面的元素类型。

const MyArray = [
  { name: "Alice", age: 15 },
  { name: "Bob", age: 23 },
  { name: "Eve", age: 38 },
];
 
/* type Person = {
    name: string;
    age: number;
} */
type Person = typeof MyArray[number];
const p:Person = {
  name: 'xiaoqian',
  age: 11
}
       
// type Age = number
type Age = typeof MyArray[number]["age"];
const age:Age = 11
     
// 或者
// type Age2 = number
type Age2 = Person["age"];
const age2:Age2 = 11

你只能在索引时使用类型,这意味着你不能使用const来做一个变量引用:

const key = "age";
type Age = Person[key];

然而,你可以使用类型别名来实现类似的重构风格:

type key = "age";
type Age = Person[key];

7.5 条件类型

在大多数有用的程序的核心,我们必须根据输入来做决定。JavaScript程序也不例外,但鉴于数值可以很容易地被内省,这些决定也是基于输入的类型。条件类型有助于描述输入和输出的类型之间的关系。

interface Animal {
  live(): void;
}
interface Dog extends Animal {
  woof(): void;
}
 
// type Example1 = number
type Example1 = Dog extends Animal ? number : string;
 
// type Example2 = string
type Example2 = RegExp extends Animal ? number : string;

条件类型的形式看起来有点像JavaScript中的条件表达式(condition ? trueExpression : falseExpression)。

 SomeType extends OtherType ? TrueType : FalseType;

extends 左边的类型可以赋值给右边的类型时,那么你将得到第一个分支中的类型("真 "分支);否则你将得到后一个分支中的类型("假 "分支)。

从上面的例子来看,条件类型可能并不立即显得有用——我们可以告诉自己是否 Dog extends Animal,并选择 numberstring!但条件类型的威力来自于它所带来的好处。条件类型的力量来自于将它们与泛型一起使用。

例如,让我们来看看下面这个 createLabel 函数:

interface IdLabel {
  id: number /* 一些字段 */;
}
interface NameLabel {
  name: string /* 另一些字段 */;
}
 
function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw "unimplemented";
}

createLabel的这些重载描述了一个单一的JavaScript函数,该函数根据其输入的类型做出选择。注意一些事情:

  • 如果一个库必须在其API中反复做出同样的选择,这就会变得很麻烦。
  • 我们必须创建三个重载:一个用于确定类型的情况(一个用于 string,一个用于 number),一个用于最一般的情况(取一个 string| number)。对于 createLabel所能处理的每一种新类型,重载的数量都会呈指数级增长。

相反,我们可以在一个条件类型中对该逻辑进行编码:

type NameOrId = T extends number
  ? IdLabel
  : NameLabel;

然后我们可以使用该条件类型,将我们的重载简化为一个没有重载的单一函数。

interface IdLabel {
  id: number /* some fields */;
}
interface NameLabel {
  name: string /* other fields */;
}

type NameOrId = T extends number
  ? IdLabel
  : NameLabel;

function createLabel(idOrName: T): NameOrId {
  throw "unimplemented";
}
 
// let a: NameLabel
let a = createLabel("typescript");
   
// let b: IdLabel
let b = createLabel(2.8);
   
// let c: NameLabel | IdLabel
let c = createLabel(Math.random() ? "hello" : 42);

7.5.1 条件类型约束

通常,条件类型中的检查会给我们提供一些新的信息。就像用类型守卫缩小范围可以给我们一个更具体的类型一样,条件类型的真正分支将通过我们检查的类型进一步约束泛型。

例如,让我们来看看下面的例子:

type MessageOf = T["message"];

在这个例子中,TypeScript出错是因为 T 不知道有一个叫做 message 的属性。我们可以对 T 进行约束,TypeScript就不会再抱怨。

type MessageOf = T["message"];
 
interface Email {
  message: string;
}
 
type EmailMessageContents = MessageOf;

然而,如果我们想让 MessageOf 接受任何类型,并在消息属性不可用的情况下,默认为 never类型呢?我们可以通过将约束条件移出,并引入一个条件类型来做到这一点。

type MessageOf = T extends { message: unknown } ? T["message"] : never;
 
interface Email {
  message: string;
}
 
interface Dog {
  bark(): void;
}
 
// type EmailMessageContents = string
type EmailMessageContents = MessageOf;
const emc:EmailMessageContents = 'balabala...'
 
// type DogMessageContents = never
type DogMessageContents = MessageOf;
const dmc:DogMessageContents = 'error' as never

在真正的分支中,TypeScript知道 T会有一个消息属性。

作为另一个例子,我们也可以写一个叫做 Flatten 的类型,将数组类型平铺到它们的元素类型上,但在其他方面则不做处理。

type Flatten = T extends any[] ? T[number] : T;
 
// 提取出元素类型。
// type Str = string
type Str = Flatten;
     
 
// 单独一个类型。
// type Num = number
type Num = Flatten;

Flatten 被赋予一个数组类型时,它使用一个带有数字的索引访问来获取 string[] 的元素类型。否则,它只是返回它被赋予的类型。

7.5.2 在条件类型内进行推理

我们只是发现自己使用条件类型来应用约束条件,然后提取出类型。这最终成为一种常见的操作,而条件类型使它变得更容易。

条件类型为我们提供了一种方法来推断我们在真实分支中使用 infer 关键字进行对比的类型。例如,我们可以在Flatten 中推断出元素类型,而不是用索引访问类型 "手动 "提取出来。

type Flatten = Type extends Array ? Item : Type;

在这里,我们使用 infer 关键字来声明性地引入一个名为 Item 的新的通用类型变量,而不是指定如何在真实分支中检索 T 的元素类型。这使我们不必考虑如何挖掘和探测我们感兴趣的类型的结构。

我们可以使用 infer 关键字编写一些有用的辅助类型别名。例如,对于简单的情况,我们可以从函数类型中提取出返回类型。

type GetReturnType = Type extends (...args: never[]) => infer Return
  ? Return
  : never;
 
// type Num = number
type Num = GetReturnType<() => number>;
     
// type Str = string
type Str = GetReturnType<(x: string) => string>;
     
// type Bools = boolean[]
type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>;

// 给泛型传入 string 类型,条件类型会返回 never
type Never = GetReturnType
const nev:Never = 'error' as never

当从一个具有多个调用签名的类型(如重载函数的类型)进行推断时,从最后一个签名进行推断(据推测,这是最容许的万能情况)。不可能根据参数类型的列表来执行重载解析。

declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;
 
// type T1 = string | number
type T1 = ReturnType;

7.5.3 分布式条件类型

当条件类型作用于一个通用类型时,当给定一个联合类型时,它们就变成了分布式的。例如,以下面的例子为例:

type ToArray = Type extends any ? Type[] : never;

如果我们将一个联合类型插入ToArray,那么条件类型将被应用于该联合的每个成员。

type ToArray = Type extends any ? Type[] : never;
 
// type StrArrOrNumArr = string[] | number[]
type StrArrOrNumArr = ToArray;

这里发生的情况是,StrArrOrNumArr分布在:

string | number;

并对联合的每个成员类型进行映射,以达到有效的目的:

ToArray | ToArray;

这给我们留下了:

string[] | number[];

通常情况下,分布性是需要的行为。为了避免这种行为,你可以用方括号包围 extends关键字的每一边。

type ToArrayNonDist = [Type] extends [any] ? Type[] : never;
 
// 'StrArrOrNumArr'不再是一个联合类型
// type StrArrOrNumArr = (string | number)[]
type StrArrOrNumArr = ToArrayNonDist;

7.6 映射类型

当你不想重复定义类型,一个类型可以以另一个类型为基础创建新类型。

映射类型建立在索引签名的语法上,索引签名用于声明没有被提前声明的属性类型。

type OnlyBoolsAndHorses = {
  [key: string]: boolean | Horse;
};
 
const conforms: OnlyBoolsAndHorses = {
  del: true,
  rodney: false,
};

映射类型是一种通用类型,它使用 PropertyKeys的联合(经常通过keyof创建)迭代键来创建一个类型。

type OptionsFlags = {
  [Property in keyof Type]: boolean;
};

在这个例子中,OptionsFlags将从Type类型中获取所有属性,并将它们的值改为布尔值。

type FeatureFlags = {
  darkMode: () => void;
  newUserProfile: () => void;
};
 
/*
type FeatureOptions = {
    darkMode: boolean;
    newUserProfile: boolean;
}
*/
type FeatureOptions = OptionsFlags;

7.6.1 映射修改器

在映射过程中,有两个额外的修饰符可以应用:readonly? ,它们分别影响可变性和可选性。

你可以通过用-+作为前缀来删除或添加这些修饰语。如果你不加前缀,那么就假定是+

type CreateMutable = {
  // 从一个类型的属性中删除 "readonly"属性
  -readonly [Property in keyof Type]: Type[Property];
};
 
type LockedAccount = {
  readonly id: string;
  readonly name: string;
};
 
/*
type UnlockedAccount = {
    id: string;
    name: string;
}
*/
type UnlockedAccount = CreateMutable;
// 从一个类型的属性中删除 "可选" 属性
type Concrete = {
  [Property in keyof Type]-?: Type[Property];
};
 
type MaybeUser = {
  id: string;
  name?: string;
  age?: number;
};

/*
type User = {
    id: string;
    name: string;
    age: number;
}
*/ 
type User = Concrete;

7.6.2 通过 askey 重映射

在TypeScript 4.1及以后的版本中,你可以通过映射类型中的as子句重新映射映射类型中的键。

type MappedTypeWithNewProperties = {
   [Properties in keyof Type as NewKeyType]: Type[Properties]
}

你可以利用模板字面类型等功能,从先前的属性名称中创建新的属性名称。

type Getters = {
   [Property in keyof Type as `get${Capitalize}`]: () => Type[Property]
};
 
interface Person {
  name: string;
  age: number;
  location: string;
}
 
/*
type LazyPerson = {
  getName: () => string;
  getAge: () => number;
  getLocation: () => string;
}
*/
type LazyPerson = Getters;

你可以通过条件类型产生never滤掉的键。

// 删除 "kind"属性
type RemoveKindField = {
    [Property in keyof Type as Exclude]: Type[Property]
};
 
/*
type KindlessCircle = {
    radius: number;
}
*/
interface Circle {
    kind: "circle";
    radius: number;
}
 
type KindlessCircle = RemoveKindField;

你可以映射任意的联合体,不仅仅是string | number | symbol的联合体,还有任何类型的联合体。

type EventConfig = {
   [E in Events as E["kind"]]: (event: E) => void;
}
 
type SquareEvent = { kind: "square", x: number, y: number };
type CircleEvent = { kind: "circle", radius: number };
 
/*
type Config = {
    square: (event: SquareEvent) => void;
    circle: (event: CircleEvent) => void;
}
*/
type Config = EventConfig

7.6.3 进一步探索

映射类型与本类型操作部分的其他功能配合得很好,例如,这里有一个使用条件类型的映射类型 ,它根据一个对象的属性pii是否被设置为字面意义上的 true,返回truefalse

type ExtractPII = {
  [Property in keyof Type]: Type[Property] extends { pii: true } ? true : false;
};
 
/*
type ObjectsNeedingGDPRDeletion = {
  id: false;
  name: true;
}
*/
type DBFields = {
  id: { format: "incrementing" };
  name: { type: string; pii: true };
};
 
type ObjectsNeedingGDPRDeletion = ExtractPII

八、类

TypeScript提供了对ES2015中引入的 class 关键词的完全支持。

与其他JavaScript语言功能一样,TypeScript增加了类型注释和其他语法,允许你表达类和其他类型之间的关系。

8.1 类成员

这里有一个最基本的类——一个空的类:

class Point {}

这个类还不是很有用,所以我们开始添加一些成员。

8.1.1 类属性

在一个类上声明字段,创建一个公共的可写属性:A mapped type is a generic type which uses a union of PropertyKeys (frequently created via a keyof) to iterate through keys to create a type:


class Point {
  x: number;
  y: number;
}
 
const pt = new Point();
pt.x = 0;
pt.y = 0;

与其他位置一样,类型注解是可选的,但如果不指定,将是一个隐含的 any 类型。

字段也可以有初始化器;这些初始化器将在类被实例化时自动运行。

class Point {
  x = 0;
  y = 0;
}
 
const pt = new Point();
// Prints 0, 0
console.log(`${pt.x}, ${pt.y}`);

就像 constletvar一样,一个类属性的初始化器将被用来推断其类型。

const pt = new Point();
pt.x = "0";
  • --strictPropertyInitialization

strictPropertyInitialization设置控制是否需要在构造函数中初始化类字段。

class BadGreeter {
  name: string;
}
class GoodGreeter {
  name: string;
 
  constructor() {
    this.name = "hello";
  }
}

请注意,该字段需要在构造函数本身中初始化。TypeScript不会分析你从构造函数中调用的方法来检测初始化,因为派生类可能会覆盖这些方法而无法初始化成员。

如果你打算通过构造函数以外的方式来确定初始化一个字段(例如,也许一个外部库为你填充了你的类的一部分),你可以使用确定的赋值断言操作符

class OKGreeter {
  // 没有初始化,但没报错。
  name!: string;
}

8.1.2 readonly

字段的前缀可以是 readonly 修饰符。这可以防止在构造函数之外对该字段进行赋值。

class Greeter {
  readonly name: string = "world";
 
  constructor(otherName?: string) {
    if (otherName !== undefined) {
      this.name = otherName;
    }
  }
 
  err() {
    this.name = "not ok";
  }
}
const g = new Greeter();
g.name = "also not ok";

8.1.3 构造器

类构造函数与函数非常相似。你可以添加带有类型注释的参数、默认值和重载:

class Point {
  x: number;
  y: number;
 
  // 带默认值的正常签名
  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
}
class Point {
  // 重载
  constructor(x: number, y: string);
  constructor(s: string);
  constructor(xs: any, y?: any) {
    // ...
  }
}

类的构造函数签名和函数签名之间只有一些区别:

  • 构造函数不能有类型参数–这属于外层类的声明,我们将在后面学习。

  • 构造函数不能有返回类型注释——类的实例类型总是被返回的。

Super 调用

就像在JavaScript中一样,如果你有一个基类,在使用任何 this.成员之前,你需要在构造器主体中调用super();

class Base {
  k = 4;
}
 
class Derived extends Base {
  constructor() {
    // 在ES5中打印一个错误的值;在ES6中抛出异常。
    console.log(this.k);
    super();
  }
}

在JavaScript中,忘记调用 super 是一个很容易犯的错误,但TypeScript会在必要时告诉你。

8.1.4 方法

一个类上的函数属性被称为方法。方法可以使用与函数和构造函数相同的所有类型注释。

class Point {
  x = 10;
  y = 10;
 
  scale(n: number): void {
    this.x *= n;
    this.y *= n;
  }
}

除了标准的类型注解,TypeScript并没有为方法添加其他新的东西。

请注意,在一个方法体中,仍然必须通过this访问字段和其他方法。方法体中的非限定名称将总是指代包围范围内的东西。

let x: number = 0;
 
class C {
  x: string = "hello";
 
  m() {
    // 这是在试图修改第1行的'x',而不是类属性。
    x = "world";
  }
}

8.1.5 Getters / Setters

类也可以有访问器:

class C {
  _length = 0;
  get length() {
    return this._length;
  }
  set length(value) {
    this._length = value;
  }
}

请注意,一个没有额外逻辑的字段支持的get/set对在JavaScript中很少有用。如果你不需要在get/set操作中添加额外的逻辑,暴露公共字段也是可以的。

TypeScript对访问器有一些特殊的推理规则:

  • 如果存在 get,但没有set,则该属性自动是只读的

  • 如果没有指定setter参数的类型,它将从getter的返回类型中推断出来

  • 访问器和设置器必须有相同的成员可见性

从TypeScript 4.3开始,可以有不同类型的访问器用于获取和设置。

class Thing {
  _size = 0;
 
  get size(): number {
    return this._size;
  }
 
  set size(value: string | number | boolean) {
    let num = Number(value);
 
    // 不允许NaN、Infinity等
 
    if (!Number.isFinite(num)) {
      this._size = 0;
      return;
    }
 
    this._size = num;
  }
}

8.1.6 索引签名

类可以声明索引签名;这些签名的作用与其他对象类型的索引签名相同。

class MyClass {
  [s: string]: boolean | ((s: string) => boolean);
 
  check(s: string) {
    return this[s] as boolean;
  }
}

因为索引签名类型需要同时捕获方法的类型,所以要有用地使用这些类型并不容易。一般来说,最好将索引数据存储在另一个地方,而不是在类实例本身。

8.2 类继承

像其他具有面向对象特性的语言一样,JavaScript中的类可以继承自基类。

8.2.1 implements子句

你可以使用一个 implements 子句来检查一个类,是否满足了一个特定的接口。如果一个类不能正确地实现它,就会发出一个错误。

interface Pingable {
  ping(): void;
}
 
class Sonar implements Pingable {
  ping() {
    console.log("ping!");
  }
}
 
class Ball implements Pingable {
  pong() {
    console.log("pong!");
  }
}

类也可以实现多个接口,例如 class C implements A, B {

注意事项

重要的是要明白, implements 子句只是检查类是否可以被当作接口类型来对待。它根本不会改变类的类型或其方法。一个常见的错误来源是认为 implements 子句会改变类的类型–它不会!它不会。

interface Checkable {
  check(name: string): boolean;
}
 
class NameChecker implements Checkable {
  check(s) {
    // any:注意这里没有错误
    return s.toLowercse() === "ok";
  }
}

在这个例子中,我们也许期望 s 的类型会受到 checkname: string参数的影响。事实并非如此–实现子句并没有改变类主体的检查方式或其类型的推断。

同样地,实现一个带有可选属性的接口并不能创建该属性。

interface A {
  x: number;
  y?: number;
}
class C implements A {
  x = 0;
}
const c = new C();
c.y = 10;

8.2.2 extends子句

类可以从基类中扩展出来。派生类拥有其基类的所有属性和方法,也可以定义额外的成员。

class Animal {
  move() {
    console.log("Moving along!");
  }
}
 
class Dog extends Animal {
  woof(times: number) {
    for (let i = 0; i < times; i++) {
      console.log("woof!");
    }
  }
}
 
const d = new Dog();
// 基类的类方法
d.move();
// 派生的类方法
d.woof(3);

8.2.3 重写方法

派生类也可以覆盖基类的一个字段或属性。你可以使用super.语法来访问基类方法。注意,因为JavaScript类是一个简单的查找对象,没有 "超级字段 "的概念。

TypeScript强制要求派生类总是其基类的一个子类型。

例如,这里有一个合法的方法来覆盖一个方法。

class Base {
  greet() {
    console.log("Hello, world!");
  }
}
 
class Derived extends Base {
  greet(name?: string) {
    if (name === undefined) {
      super.greet();
    } else {
      console.log(`Hello, ${name.toUpperCase()}`);
    }
  }
}
 
const d = new Derived();
d.greet();
d.greet("reader");

派生类遵循其基类契约是很重要的。请记住,通过基类引用来引用派生类实例是非常常见的(而且总是合法的!)。

// 通过基类引用对派生实例进行取别名
const b: Base = d;
// 没问题
b.greet();

如果Derived没有遵守Base的约定怎么办?

class Base {
  greet() {
    console.log("Hello, world!");
  }
}
 
class Derived extends Base {
  // 使这个参数成为必需的
  greet(name: string) {
    console.log(`Hello, ${name.toUpperCase()}`);
  }
}

如果我们不顾错误编译这段代码,这个样本就会崩溃:

const b: Base = new Derived();
// 崩溃,因为 "名称 "将是 undefined。
b.greet();

8.2.4 初始化顺序

在某些情况下,JavaScript类的初始化顺序可能会令人惊讶。让我们考虑一下这段代码:

class Base {
  name = "base";
  constructor() {
    console.log("My name is " + this.name);
  }
}
 
class Derived extends Base {
  name = "derived";
}
 
// 打印 "base", 而不是 "derived"
const d = new Derived();

这里发生了什么?

按照JavaScript的定义,类初始化的顺序是:

  • 基类的字段被初始化
  • 基类构造函数运行
  • 派生类的字段被初始化
  • 派生类构造函数运行

这意味着基类构造函数在自己的构造函数中看到了自己的name值,因为派生类的字段初始化还没有运行。

8.2.5 继承内置类型

注意:如果你不打算继承Array、Error、Map等内置类型,或者你的编译目标明确设置为ES6/ES2015或以上,你可以跳过本节。

在ES2015中,返回对象的构造函数隐含地替代了super(...)的任何调用者的this的值。生成的构造函数代码有必要捕获super(...)的任何潜在返回值并将其替换为this

因此,子类化ErrorArray等可能不再像预期那样工作。这是由于ErrorArray等的构造函数使用ECMAScript 6的new.target来调整原型链;然而,在ECMAScript 5中调用构造函数时,没有办法确保new.target的值。其他的下级编译器一般默认有同样的限制。

对于一个像下面这样的子类:

class MsgError extends Error {
  constructor(m: string) {
    super(m);
  }
  sayHello() {
    return "hello " + this.message;
  }
}

你可能会发现:

  • 方法在构造这些子类所返回的对象上可能是未定义的,所以调用 sayHello 会导致错误。
  • instanceof将在子类的实例和它们的实例之间被打破,所以(new MsgError())instanceof MsgError将返回false

作为建议,你可以在任何super(...)调用后立即手动调整原型。

class MsgError extends Error {
  constructor(m: string) {
    super(m);
 
    // 明确地设置原型。
    Object.setPrototypeOf(this, MsgError.prototype);
  }
 
  sayHello() {
    return "hello " + this.message;
  }
}

然而,MsgError的任何子类也必须手动设置原型。对于不支持Object.setPrototypeOf的运行时,你可以使用__proto__来代替。

不幸的是,这些变通方法在Internet Explorer 10和更早的版本上不起作用。我们可以手动将原型中的方法复制到实例本身(例如MsgError.prototypethis),但是原型链本身不能被修复。

8.2 成员的可见性

你可以使用TypeScript来控制某些方法或属性对类外的代码是否可见。

8.2.1 public

类成员的默认可见性是公共(public)的。一个公共(public)成员可以在任何地方被访问。

class Greeter {
  public greet() {
    console.log("hi!");
  }
}
const g = new Greeter();
g.greet();

因为public已经是默认的可见性修饰符,所以你永远不需要在类成员上写它,但为了风格/可读性的原因,可能会选择这样做。

8.2.2 protected

受保护的(protected)成员只对它们所声明的类的子类可见。

class Greeter {
  public greet() {
    console.log("Hello, " + this.getName());
  }
  protected getName() {
    return "hi";
  }
}
 
class SpecialGreeter extends Greeter {
  public howdy() {
    // 在此可以访问受保护的成员
    console.log("Howdy, " + this.getName());
  }
}
const g = new SpecialGreeter();
g.greet(); // 没有问题
g.getName(); // 无权访问
  • 受保护成员的暴露

派生类需要遵循它们的基类契约,但可以选择公开具有更多能力的基类的子类型。这包括将受保护的成员变成公开。

class Base {
  protected m = 10;
}
class Derived extends Base {
  // 没有修饰符,所以默认为'公共'('public')
  m = 15;
}
const d = new Derived();
console.log(d.m); // OK

8.2.3 private

privateprotected一样,但不允许从子类中访问该成员。

class Base {
  private x = 0;
}
const b = new Base();
// 不能从类外访问
console.log(b.x);
class Base {
  private x = 0;
}
const b = new Base();

class Derived extends Base {
  showX() {
    // 不能在子类中访问
    console.log(this.x);
  }
}

因为私有(private)成员对派生类是不可见的,所以派生类不能增加其可见性。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MwIybuVo-1668765341003)(/Users/felix/Library/Application Support/typora-user-images/image-20211130104942865.png)]

  • 跨实例的私有访问

不同的OOP语言对同一个类的不同实例,是否可以访问对方的私有成员,有不同的处理方法。虽然像Java、C#、C++、Swift和PHP等语言允许这样做,但Ruby不允许。

TypeScript确实允许跨实例的私有访问:

class A {
  private x = 10;
 
  public sameAs(other: A) {
    // 可以访问
    return other.x === this.x;
  }
}
  • 注意事项

像TypeScript类型系统的其他方面一样,privateprotected只在类型检查中被强制执行。

这意味着JavaScript的运行时结构,如in或简单的属性查询,仍然可以访问一个私有或保护的成员。

class MySafe {
  private secretKey = 12345;
}
// 在JS环境中...
const s = new MySafe();
// 将打印 12345
console.log(s.secretKey);

private也允许在类型检查时使用括号符号进行访问。这使得私有声明的字段可能更容易被单元测试之类的东西所访问,缺点是这些字段是软性私有的,不能严格执行私有特性。

class MySafe {
  private secretKey = 12345;
}
 
const s = new MySafe();
 
// 在类型检查期间不允许
console.log(s.secretKey);
 
// 正确
console.log(s["secretKey"]);

与TypeScript的 private不同,JavaScript的private字段(#)在编译后仍然是private的,并且不提供前面提到的像括号符号访问那样的转义窗口,使其成为硬private

class Dog {
  #barkAmount = 0;
  personality = "happy";
 
  constructor() {
    // 0
    console.log(this.#barkAmount)
  }
}

const dog = new Dog()
// undefined
console.log(dog.barkAmount)

当编译到ES2021或更少时,TypeScript将使用WeakMaps来代替 #

"use strict";
var _Dog_barkAmount;
class Dog {
  constructor() {
    _Dog_barkAmount.set(this, 0);
    this.personality = "happy";
  }
}
_Dog_barkAmount = new WeakMap();

如果你需要保护你的类中的值免受恶意行为的影响,你应该使用提供硬运行时隐私的机制,如闭包、WeakMaps或私有字段。请注意,这些在运行时增加的隐私检查可能会影响性能。

8.3 静态成员

类可以有静态成员。这些成员并不与类的特定实例相关联。它们可以通过类的构造函数对象本身来访问。

class MyClass {
  static x = 0;
  static printX() {
    console.log(MyClass.x);
  }
}
console.log(MyClass.x);
MyClass.printX();

静态成员也可以使用相同的publicprotectedprivate可见性修饰符。

class MyClass {
  private static x = 0;
}
console.log(MyClass.x);

静态成员也会被继承。

class Base {
  static getGreeting() {
    return "Hello world";
  }
}
class Derived extends Base {
  myGreeting = Derived.getGreeting();
}

8.3.1 特殊静态名称

一般来说,从函数原型覆盖属性是不安全的/不可能的。因为类本身就是可以用new调用的函数,所以某些静态名称不能使用。像namelengthcall这样的函数属性,定义为静态成员是无效的。

class S {
  static name = "S!";
}

8.3.2 为什么没有静态类?

TypeScript(和JavaScript)没有像C#和Java那样有一个叫做静态类的结构。

这些结构体的存在,只是因为这些语言强制所有的数据和函数都在一个类里面;因为这个限制在TypeScript中不存在,所以不需要它们。一个只有一个实例的类,在JavaScript/TypeScript中通常只是表示为一个普通的对象。

例如,我们不需要TypeScript中的 "静态类 "语法,因为一个普通的对象(甚至是顶级函数)也可以完成这个工作。

// 不需要 "static" class
class MyStaticClass {
  static doSomething() {}
}
 
// 首选 (备选 1)
function doSomething() {}
 
// 首选 (备选 2)
const MyHelperObject = {
  dosomething() {},
};

8.4 类里的 static 区块

静态块允许你写一串有自己作用域的语句,可以访问包含类中的私有字段。这意味着我们可以用写语句的所有能力来写初始化代码,不泄露变量,并能完全访问我们类的内部结构。

class Foo {
  static #count = 0;

  get count() {
    return Foo.#count;
  }

  static {
    try {
      const lastInstances = {
        length: 100
      };
      Foo.#count += lastInstances.length;
    }
    catch {}
  }
}

8.5 泛型类

类,和接口一样,可以是泛型的。当一个泛型类用new实例化时,其类型参数的推断方式与函数调用的方式相同。

class Box {
  contents: Type;
  constructor(value: Type) {
    this.contents = value;
  }
}
 
// const b: Box
const b = new Box("hello!");

类可以像接口一样使用通用约束和默认值。

  • 静态成员中的类型参数

这段代码是不合法的,可能并不明显,为什么呢?

class Box {
	// 静态成员不能引用类的类型参数。
  static defaultValue: Type;
}

// Box.defaultValue = 'hello'
// console.log(Box.defaultValue)

请记住,类型总是被完全擦除的! 在运行时,只有一个Box.defaultValue属性。这意味着设置Box.defaultValue(如果有可能的话)也会改变Box.defaultValue,这可不是什么好事。一个泛型类的静态成员永远不能引用该类的类型参数。

8.6 类运行时中的this

重要的是要记住,TypeScript并没有改变JavaScript的运行时行为,而JavaScript的运行时行为偶尔很奇特。

比如,JavaScript对这一点的处理确实是不寻常的:

class MyClass {
  name = "MyClass";
  getName() {
    return this.name;
  }
}
const c = new MyClass();
const obj = {
  name: "obj",
  getName: c.getName,
};
 
// 输出 "obj", 而不是 "MyClass"
console.log(obj.getName());

长话短说,默认情况下,函数内this的值取决于函数的调用方式。在这个例子中,因为函数是通过obj引用调用的,所以它的this值是obj而不是类实例。

这很少是你希望发生的事情! TypeScript提供了一些方法来减轻或防止这种错误。

1、箭头函数

如果你有一个经常会被调用的函数,失去了它的 this 上下文,那么使用一个箭头函数而不是方法定义是有意义的。

class MyClass {
  name = "MyClass";
  getName = () => {
    return this.name;
  };
}
const c = new MyClass();
const g = c.getName;
// 输出 "MyClass"
console.log(g());

这有一些权衡:

  • this 值保证在运行时是正确的,即使是没有经过TypeScript检查的代码也是如此。

  • 这将使用更多的内存,因为每个类实例将有它自己的副本,每个函数都是这样定义的。

  • 你不能在派生类中使用super.getName,因为在原型链中没有入口可以获取基类方法。

2、this 参数

在方法或函数定义中,一个名为this的初始参数在TypeScript中具有特殊的意义。这些参数在编译过程中会被删除。

// 带有 "this" 参数的 TypeScript 输入
function fn(this: SomeType, x: number) {
  /* ... */
}
// 编译后的JavaScript结果
function fn(x) {
  /* ... */
}

TypeScript检查调用带有this参数的函数,是否在正确的上下文中进行。我们可以不使用箭头函数,而是在方法定义中添加一个this参数,以静态地确保方法被正确调用。

class MyClass {
  name = "MyClass";
  getName(this: MyClass) {
    return this.name;
  }
}
const c = new MyClass();
// 正确
c.getName();
 
// 错误
const g = c.getName;
console.log(g());

这种方法做出了与箭头函数方法相反的取舍:

  • JavaScript调用者仍然可能在不知不觉中错误地使用类方法
  • 每个类定义只有一个函数被分配,而不是每个类实例一个函数
  • 基类方法定义仍然可以通过 super调用。

8.7 this类型

在类中,一个叫做 this的特殊类型动态地指向当前类的类型。让我们来看看这有什么用:

class Box {  
  contents: string = "";  
  // (method) Box.set(value: string): this  
  set(value: string) {
    this.contents = value;
    return this;  
  }
}

在这里,TypeScript推断出 set的返回类型是this,而不是Box。现在让我们做一个Box的子类:

class ClearableBox extends Box {
  clear() {
    this.contents = "";
  }
} 
const a = new ClearableBox();

// const b: ClearableBox
const b = a.set("hello");
console.log(b)

你也可以在参数类型注释中使用 this

class Box {
  content: string = "";  
  sameAs(other: this) {
    return other.content === this.content;
  }
}
const box = new Box()
console.log(box.sameAs(box))

这与其他写法不同:Box,如果你有一个派生类,它的 sameAs 方法现在只接受该同一派生类的其他实例。

class Box {
  content: string = ""; 
  sameAs(other: this) { 
    return other.content === this.content; 
  }
} 

class DerivedBox extends Box {
  otherContent: string = "?"; 
} 

const base = new Box(); 
const derived = new DerivedBox(); 
derived.sameAs(base);

8.8 基于类型守卫的this

你可以在类和接口的方法的返回位置使用 this is Type 。当与类型缩小混合时(例如if语句),目标对象的类型将被缩小到指定的Type。

class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep;
  }
  isDirectory(): this is Directory {
    return this instanceof Directory;
  }
  isNetworked(): this is Networked & this {
    return this.networked;
  }
  constructor(public path: string, private networked: boolean) {

  }
}
class FileRep extends FileSystemObject {
  constructor(path: string, public content: string) {
    super(path, false);
  }
}
class Directory extends FileSystemObject {
  children: FileSystemObject[];
}
interface Networked {
  host: string;
}
const fso: FileSystemObject = new FileRep("foo/bar.txt", "foo");
if (fso.isFile()) { 
  // const fso: FileRep  
  fso.content;
} else if (fso.isDirectory()) {  
  // const fso: Directory  
  fso.children;
} else if (fso.isNetworked()) {  
  // const fso: Networked & FileSystemObject  
  fso.host;
}

基于 this 的类型保护的一个常见用例,是允许对一个特定字段进行懒惰验证。例如,这种情况下,当 hasValue被验证为真时,就会从框内持有的值中删除一个未定义值。

class Box  {
  value?: T;
  
  hasValue(): this is { value: T} {
    return this.value !== undefined;
  }
}
const box = new Box();
box.value = "Gameboy"; 

// (property) Box.value?: unknownbox.value;
if (box.hasValue()) { 
  // (property) value: unknown  
  box.value;
}

8.9 参数属性

TypeScript提供了特殊的语法,可以将构造函数参数变成具有相同名称和值的类属性。这些被称为参数属性,通过在构造函数参数前加上可见性修饰符 publicprivateprotectedreadonly中的一个来创建。由此产生的字段会得到这些修饰符。

class Params {
  constructor(public readonly x: number, protected y: number, private z: number) { 
    // No body necessary  
  }
}

const a = new Params(1, 2, 3);

// (property) Params.x: number
console.log(a.x);
console.log(a.z);

8.10 类表达式

类表达式与类声明非常相似。唯一真正的区别是,类表达式不需要一个名字,尽管我们可以通过它们最终绑定的任何标识符来引用它们。

const someClass = class {
  content: Type;  
  constructor(value: Type) {    
    this.content = value;  
  }
}; 

// const m: someClass
const m = new someClass("Hello, world");

8.11 抽象类和成员

TypeScript中的类、方法和字段可以是抽象的。

一个抽象的方法或抽象的字段是一个没有提供实现的方法或字段。这些成员必须存在于一个抽象类中,不能直接实例化。

抽象类的作用是作为子类的基类,实现所有的抽象成员。当一个类没有任何抽象成员时,我们就说它是具体的。

让我们看一个例子:

abstract class Base {
  abstract getName(): string;
  printName() {
    console.log("Hello, " + this.getName());
  }
}
const b = new Base();

我们不能用 new来实例化Base,因为它是抽象的。相反,我们需要创建一个派生类并实现抽象成员。

class Derived extends Base {
  getName() {
    return "world";
  }
}
const d = new Derived();
d.printName();
  • 抽象构造签名

有时你想接受一些类的构造函数,产生一个从某些抽象类派生出来的类的实例。

例如,你可能想写这样的代码:

function greet(ctor: typeof Base) {
  const instance = new ctor();
  instance.printName();
}

TypeScript正确地告诉你,你正试图实例化一个抽象类。毕竟,鉴于greet的定义,写这段代码是完全合法的,它最终会构造一个抽象类。

// 槽糕
greet(Base);

相反,你想写一个函数,接受具有结构化签名的东西:

function greet(ctor: new() => Base) {
  const instance = new ctor();
  instance.printName();
}
greet(Derived);
greet(Base);

现在TypeScript正确地告诉你哪些类的构造函数可以被调用:Derived可以,因为它是具体的,但Base不能。

8.12 类之间的关系

在大多数情况下,TypeScript中的类在结构上与其他类型相同,是可以比较的。

例如,这两个类可以互相替代使用,因为它们是相同的:

class Point1 {
  x = 0;
  y = 0;
}
class Point2 {
  x = 0;
  y = 0;
} 

// 正确
const p: Point1 = new Point2();

同样地,即使没有明确的继承,类之间的子类型关系也是存在的:

class Person {
  name: string;
  age: number;
}
class Employee {
  name: string;
  age: number;
  salary: number;
} 

// 正确
const p: Person = new Employee();

这听起来很简单,但有几种情况似乎比其他情况更奇怪。

空的类没有成员。在一个结构化类型系统中,一个没有成员的类型通常是其他任何东西的超类型。所以如果你写了一个空类(不要!),任何东西都可以用来代替它。

class Empty {
  
}

function fn(x: Empty) { 
  // 不能用'x'做任何事
}

// 以下调用均可
!fn(window);
fn({});
fn(fn);

九、模块

JavaScript有很长的历史,有不同的方式来处理模块化的代码。TypeScript从2012年开始出现,已经实现了对许多这些格式的支持,但随着时间的推移,社区和JavaScript规范已经趋向于一种名为ES模块(或ES6模块)的格式。你可能知道它是import/export语法。

ES Modules在2015年被加入到JavaScript规范中,到2020年,在大多数网络浏览器和JavaScript运行时中都有广泛的支持。

为了突出重点,本手册将涵盖ES Modules及其流行的前驱CommonJS module.exports =语法。

9.1 如何定义JavaScript模块

在TypeScript中,就像在ECMAScript 2015中一样,任何包含顶级importexport的文件都被认为是一个模块。

相反,一个没有任何顶级导入或导出声明的文件被视为一个脚本,其内容可在全局范围内使用(因此也可用于模块)。

模块在自己的范围内执行,而不是在全局范围内。这意味着在模块中声明的变量、函数、类等在模块外是不可见的,除非它们被明确地用某种导出形式导出。相反,要使用从不同模块导出的变量、函数、类、接口等,必须使用导入的形式将其导入。

9.2 非模块

在我们开始之前,重要的是要了解TypeScript认为什么才是模块。JavaScript规范声明,任何没有 export或顶层 await 的JavaScript文件都应该被认为是一个脚本而不是一个模块。

在一个脚本文件中,变量和类型被声明为在共享的全局范围内,并且假定你会使用outFile编译器选项将多个输入文件加入一个输出文件,或者在你的HTML中使用多个

你可能感兴趣的:(前端,typescript)