JavaScript 中的每一个值都有其行为特征,你可以通过运行不同的操作来观察这些行为。这听起来可能比较抽象,举个简单的例子,考虑我们对一个名为 message 的变量执行的某些操作。
js// Accessing the property 'toLowerCase'// on 'message' and then calling itmessage.toLowerCase();// Calling 'message'message();
如果我们拆解这些代码,第一行可运行的代码访问了一个名为 toLowerCase 的属性并调用它。第二行代码尝试直接调用 message。
但假设我们不知道 message 的值是什么——这种情况很常见——我们就无法确定执行这些代码会得到什么结果。每个操作的行为完全取决于我们最初拥有的是什么值。
message可以被调用吗?- 它上面有一个名为
toLowerCase的属性吗? - 如果有的话,
toLowerCase可以被调用吗? - 如果这两个值都可以被调用,它们分别返回什么?
这些问题的答案通常是我们编写 JavaScript 时记在心里的东西,我们必须寄希望于自己把所有细节都弄对了。
假设 message 是按以下方式定义的。
jsconst message = "Hello World!";
正如你所预料的,如果我们尝试运行 message.toLowerCase(),我们将得到相同的小写字符串。
那第二行代码呢?如果你熟悉 JavaScript,你就会知道这会抛出一个异常而失败。
txtTypeError: message is not a function
如果我们能避免这类错误就太好了。
当我们运行代码时,JavaScript 运行时决定如何操作的方式是确定值的类型——即它具备什么样的行为和能力。这就是 TypeError 所暗示的内容的一部分——它表示字符串 "Hello World!" 不能作为函数被调用。
对于某些值,例如基本类型 string 和 number,我们可以在运行时使用 typeof 运算符识别它们的类型。但对于函数等其他事物,没有相应的运行时机制来识别它们的类型。例如,考虑这个函数:
jsfunction fn(x) {return x.flip();}
我们可以通过阅读代码观察到,只有当传入一个具有可调用 flip 属性的对象时,该函数才能正常工作。但 JavaScript 并没有以一种可以在代码运行时进行检查的方式来公开这些信息。在纯 JavaScript 中,确定 fn 对特定值做什么的唯一方法就是调用它看看会发生什么。这种行为使得在运行代码之前很难预测它会做什么,这意味着在你编写代码时,更难预知代码的行为。
从这个角度来看,类型是一种描述哪些值可以传递给 fn 以及哪些值会导致程序崩溃的概念。JavaScript 实际上只提供动态类型——即通过运行代码来查看结果。
另一种选择是使用静态类型系统,在代码运行之前对其预期行为进行预测。
静态类型检查
回想一下我们之前尝试将 string 当作函数调用时遇到的 TypeError。大多数人都不喜欢在运行代码时遇到任何形式的错误——那些被认为是 Bug!而当我们编写新代码时,我们总是尽力避免引入新的 Bug。
如果我们添加一点点代码,保存文件,重新运行,然后立即看到错误,我们或许能快速隔离问题;但情况并不总是这样。我们可能没有充分测试该功能,因此可能永远不会真正遇到那个潜在的错误!或者如果我们幸运地发现了错误,可能已经进行了大规模重构并添加了许多不同的代码,不得不深入挖掘。
理想情况下,我们能有一个工具来帮助我们在代码运行之前发现这些 Bug。这就是像 TypeScript 这样的静态类型检查器所做的事情。静态类型系统描述了程序运行时值的形态和行为。像 TypeScript 这样的类型检查器会利用这些信息,并在事情可能出错时告诉我们。
tsTryconstmessage = "hello!";This expression is not callable. Type 'String' has no call signatures.2349This expression is not callable. Type 'String' has no call signatures.(); message
用 TypeScript 运行上一个示例,在运行代码之前就会给我们一个错误消息。
非异常失败
到目前为止,我们一直在讨论诸如运行时错误之类的事情——即 JavaScript 运行时认为某些操作毫无意义的情况。这些情况的出现是因为 ECMAScript 规范 对语言在遇到意外情况时应如何表现有明确的说明。
例如,规范说明尝试调用不可调用的对象应该抛出错误。也许这听起来是“显而易见的行为”,但你可以想象访问对象上不存在的属性也应该抛出错误。相反,JavaScript 给出了不同的行为并返回了 undefined。
jsconst user = {name: "Daniel",age: 26,};user.location; // returns undefined
最终,静态类型系统必须决定哪些代码应被标记为错误,即使它是不会立即抛出错误的“合法” JavaScript 代码。在 TypeScript 中,以下代码会产生关于 location 未定义的错误:
tsTryconstuser = {name : "Daniel",age : 26,};Property 'location' does not exist on type '{ name: string; age: number; }'.2339Property 'location' does not exist on type '{ name: string; age: number; }'.user .; location
虽然这有时意味着在表达能力上做出了一些权衡,但其目的是为了捕获程序中合法的 Bug。而且 TypeScript 确实捕获了大量合法的 Bug。
例如:拼写错误、
tsTryconstannouncement = "Hello World!";// How quickly can you spot the typos?announcement .toLocaleLowercase ();announcement .toLocalLowerCase ();// We probably meant to write this...announcement .toLocaleLowerCase ();
未调用的函数、
tsTryfunctionflipCoin () {// Meant to be Math.random()returnOperator '<' cannot be applied to types '() => number' and 'number'.2365Operator '<' cannot be applied to types '() => number' and 'number'.Math .random < 0.5;}
或基本的逻辑错误。
tsTryconstvalue =Math .random () < 0.5 ? "a" : "b";if (value !== "a") {// ...} else if (This comparison appears to be unintentional because the types '"a"' and '"b"' have no overlap.2367This comparison appears to be unintentional because the types '"a"' and '"b"' have no overlap.value === "b") {// Oops, unreachable}
工具化的类型支持
TypeScript 可以在我们犯错时捕获 Bug。这很棒,但 TypeScript 也能防止我们在第一时间犯下这些错误。
类型检查器拥有检查诸如“我们是否在变量上访问了正确的属性”等信息。一旦拥有这些信息,它还可以开始建议你可能想要使用的属性。
这意味着 TypeScript 也可以用来辅助代码编辑,核心类型检查器可以在编辑器中输入时提供错误信息和代码补全。这就是人们在谈论 TypeScript 中的工具支持时所指的一部分。
tsTryimportexpress from "express";constapp =express ();app .get ("/", function (req ,res ) {res .sen });app .listen (3000);
TypeScript 非常重视工具支持,这远不止于输入时的补全和报错。支持 TypeScript 的编辑器可以提供“快速修复”来自动修正错误,通过重构轻松重新组织代码,以及用于跳转到变量定义或查找给定变量所有引用的有用导航功能。这一切都建立在类型检查器的基础之上,并且完全跨平台,因此你喜爱的编辑器很可能已经提供了 TypeScript 支持。
tsc,TypeScript 编译器
我们一直在谈论类型检查,但还没有真正使用我们的类型检查器。让我们来认识一下我们的新朋友 tsc——TypeScript 编译器。首先,我们需要通过 npm 获取它。
shnpm install -g typescript
这会全局安装 TypeScript 编译器
tsc。如果你更喜欢从本地node_modules包运行tsc,可以使用npx或类似的工具。
现在让我们切换到一个空文件夹,尝试编写我们的第一个 TypeScript 程序:hello.ts
tsTry// Greets the world.console .log ("Hello world!");
注意这里没有任何花哨的东西;这个“hello world”程序看起来和你为 JavaScript 编写的“hello world”程序完全一样。现在,让我们通过运行由 typescript 包为我们安装的 tsc 命令来检查它的类型。
shtsc hello.ts
当当!
等等,“当当”什么?我们运行了 tsc 但什么也没发生!好吧,因为没有任何类型错误,所以控制台没有输出任何内容,因为没什么可报告的。
但再检查一下——我们得到了一些文件输出。如果我们查看当前目录,会看到 hello.ts 旁边多了一个 hello.js 文件。这就是 tsc 将 hello.ts 编译或转换为纯 JavaScript 文件后的输出。如果我们查看内容,会看到 TypeScript 处理 .ts 文件后导出的代码。
js// Greets the world.console.log("Hello world!");
在这种情况下,TypeScript 几乎没有什么需要转换的,所以它看起来和我们写的一模一样。编译器会尝试输出干净且可读的代码,看起来就像人类编写的一样。虽然这并不总是那么容易,但 TypeScript 会保持缩进一致,注意代码跨行的情况,并尽量保留注释。
如果我们确实引入了一个类型检查错误呢?让我们重写 hello.ts。
tsTry// This is an industrial-grade general-purpose greeter function:functiongreet (person ,date ) {console .log (`Hello ${person }, today is ${date }!`);}greet ("Brendan");
如果我们再次运行 tsc hello.ts,会注意到命令行报错了!
txtExpected 2 arguments, but got 1.
TypeScript 告诉我们忘记给 greet 函数传递参数了,这很合理。到目前为止我们只写了标准的 JavaScript,然而类型检查依然能够发现我们代码中的问题。感谢 TypeScript!
在出现错误时生成输出
你可能在上一个示例中没有注意到的一件事是,我们的 hello.js 文件再次发生了变化。如果我们打开该文件,会发现内容基本上看起来和输入文件一样。考虑到 tsc 报告了代码错误,这可能会令人有些惊讶,但这是基于 TypeScript 的核心价值之一:很多时候,你比 TypeScript 更了解情况。
正如之前重申的那样,类型检查代码限制了你可以运行的程序类型,因此类型检查器认为可接受的内容是一种权衡。大多数时候这没问题,但有些场景下这些检查会阻碍工作。例如,想象你正在将 JavaScript 代码迁移到 TypeScript,并引入了类型检查错误。最终你会清理这些错误,但原始的 JavaScript 代码已经是可运行的了!为什么将其转换为 TypeScript 就应该停止你运行它呢?
所以 TypeScript 不会妨碍你。当然,随着时间的推移,你可能希望对错误更具防范意识,让 TypeScript 的表现更严格。在这种情况下,你可以使用 noEmitOnError 编译器选项。尝试修改 hello.ts 文件并带上该标志运行 tsc。
shtsc --noEmitOnError hello.ts
你会注意到 hello.js 不再更新了。
显式类型
到目前为止,我们还没有告诉 TypeScript person 或 date 是什么。让我们编辑代码,告诉 TypeScript person 是一个 string,而 date 应该是一个 Date 对象。我们还将使用 date 上的 toDateString() 方法。
tsTryfunctiongreet (person : string,date :Date ) {console .log (`Hello ${person }, today is ${date .toDateString ()}!`);}
我们所做的是在 person 和 date 上添加了类型注解,以描述 greet 可以使用什么类型的值来调用。你可以这样阅读该签名:“greet 接受一个类型为 string 的 person,以及一个类型为 Date 的 date”。
有了这个,TypeScript 就可以告诉我们 greet 可能被错误调用的其他情况。例如……
tsTryfunctiongreet (person : string,date :Date ) {console .log (`Hello ${person }, today is ${date .toDateString ()}!`);}Argument of type 'string' is not assignable to parameter of type 'Date'.2345Argument of type 'string' is not assignable to parameter of type 'Date'.greet ("Maddison",Date ());
嗯?TypeScript 报告了关于我们第二个参数的错误,为什么?
也许令人惊讶,在 JavaScript 中调用 Date() 会返回一个 string。而另一方面,使用 new Date() 构造 Date 实际上给出了我们所期望的内容。
无论如何,我们可以快速修复这个错误:
tsTryfunctiongreet (person : string,date :Date ) {console .log (`Hello ${person }, today is ${date .toDateString ()}!`);}greet ("Maddison", newDate ());
请记住,我们并不总是需要编写显式的类型注解。在许多情况下,即使我们省略它们,TypeScript 甚至可以直接推断(或“计算出”)类型。
tsTryletmsg = "hello there!";
即使我们没有告诉 TypeScript msg 的类型是 string,它也能推断出来。这是一个特性,如果类型系统最终能推断出相同的类型,最好不要添加注解。
注意:上一段代码示例中的气泡信息是当你鼠标悬停在该词上时,编辑器会显示的内容。
类型擦除
让我们看看当我们将上面的 greet 函数用 tsc 编译输出 JavaScript 时会发生什么。
tsTry"use strict";function greet(person, date) {console.log("Hello ".concat(person, ", today is ").concat(date.toDateString(), "!"));}greet("Maddison", new Date());
这里注意两件事:
- 我们的
person和date参数不再有类型注解。 - 我们的“模板字符串”——即使用反引号(
`字符)的字符串——被转换成了带有连接符的普通字符串。
稍后会详细介绍第二点,但现在我们先专注于第一点。类型注解不是 JavaScript 的一部分(严格来说不是 ECMAScript 的一部分),所以确实没有任何浏览器或其他运行时可以直接运行未经修改的 TypeScript。这就是为什么 TypeScript 首先需要编译器的原因——它需要某种方式来剥离或转换任何 TypeScript 特有的代码,以便你可以运行它。大多数 TypeScript 特有的代码会被擦除,同样地,这里的类型注解被完全擦除了。
记住:类型注解永远不会改变程序的运行时行为。
降级编译 (Downleveling)
与上面相比的另一个区别是我们的模板字符串被重写了:
js`Hello ${person}, today is ${date.toDateString()}!`;
变成了:
js"Hello ".concat(person, ", today is ").concat(date.toDateString(), "!");
为什么会这样?
模板字符串是 ECMAScript 2015(又名 ECMAScript 6, ES2015, ES6 等——别问)版本中的一项特性。TypeScript 有能力将较新版本的 ECMAScript 代码重写为较旧的版本,如 ECMAScript 3 或 ECMAScript 5(又名 ES5)。这种从较新或“更高级”的 ECMAScript 版本转换到较旧或“更低级”版本的过程有时被称为降级编译 (downleveling)。
默认情况下,TypeScript 的目标版本是 ES5,这是一个非常古老的 ECMAScript 版本。我们可以通过 target 选项选择更新的版本。使用 --target es2015 运行会改变 TypeScript 的目标为 ECMAScript 2015,这意味着代码应该能够在支持 ECMAScript 2015 的任何地方运行。因此运行 tsc --target es2015 hello.ts 会得到以下输出:
jsfunction greet(person, date) {console.log(`Hello ${person}, today is ${date.toDateString()}!`);}greet("Maddison", new Date());
虽然默认目标是 ES5,但绝大多数现代浏览器都支持 ES2015。因此,除非需要兼容某些古老的浏览器,否则大多数开发者可以放心地指定 ES2015 或更高版本作为目标。
严格性
不同的用户使用 TypeScript 是出于不同的目的。有些人正在寻找一种更宽松的“选择性加入”体验,这有助于验证程序的一部分,同时仍然拥有不错的工具支持。这是 TypeScript 的默认体验,其中类型是可选的,推断会使用最宽松的类型,并且不会检查潜在的 null/undefined 值。正如 tsc 在出现错误时依然生成输出一样,这些默认设置是为了不妨碍你。如果你正在迁移现有的 JavaScript,这可能是一个理想的起步。
相比之下,许多用户更喜欢让 TypeScript 从一开始就尽可能多地进行验证,这就是为什么语言也提供了严格性设置。这些严格性设置将静态类型检查从一个开关(要么开启,要么关闭)变成了一个接近于“刻度盘”的东西。你调得越高,TypeScript 为你做的检查就越多。这可能需要一点额外的工作,但通常来说长远来看是非常值得的,并且可以实现更彻底的检查和更准确的工具支持。如果可能的话,新的代码库应该始终开启这些严格性检查。
TypeScript 有几个可以开启或关闭的类型检查严格性标志,除非另有说明,否则我们的所有示例都将开启它们。CLI 中的 strict 标志,或 tsconfig.json 中的 "strict": true 会同时开启它们,但我们也可以分别禁用它们。你应该了解的两个最重要的标志是 noImplicitAny 和 strictNullChecks。
noImplicitAny
回想一下,在某些地方,TypeScript 不会尝试为我们推断类型,而是回退到最宽松的类型:any。这不是最糟糕的情况——毕竟,回退到 any 本来就是普通的 JavaScript 体验。
然而,使用 any 往往违背了使用 TypeScript 的初衷。你的程序类型越明确,你得到的验证和工具支持就越多,这意味着你在编码时遇到的 Bug 就会越少。开启 noImplicitAny 标志将对任何隐式推断为 any 的变量发出错误警告。
strictNullChecks
默认情况下,null 和 undefined 等值可以赋值给任何其他类型。这使得编写某些代码变得更容易,但忘记处理 null 和 undefined 是世界上无数 Bug 的根源——有些人将其视为“十亿美元的错误”!strictNullChecks 标志使得处理 null 和 undefined 更加明确,并省去了我们需要担心是否忘记处理它们的问题。