JavaScript 的力量
来自灵活性
小心!
我在第 1 章“从 JavaScript 到 TypeScript”中简要地谈到了 TypeScript 中存在一个“类型检查器”,它可以查看你的代码,理解它是如何工作的,并让你知道你可能在哪里搞砸了。但是类型检查器到底是如何工作的呢?
类型中有什么?
“类型”就是描述 JavaScript 值形状可能是什么。我所说的“形状”是指值上存在哪些属性和方法,以及内置的 typeof
运算符会将其描述为什么。
例如,当你创建一个初始值为 "Aretha"
的变量时:
let singer = "Aretha";
TypeScript 可以推断或确定 singer
变量是字符串类型。
TypeScript 中最基本的类型对应于 JavaScript 中的七种基本类型:
null
undefined
boolean
// 真或假string
// , ..."", "Hi!", "abc123"number
// 4, ...0, 2.1, -bigint
// , ...0n, 2n, -4nsymbol
... // ,Symbol(), Symbol("hi")
对于这些值,TypeScript 将值的类型理解为七个基本类型之一:
null
; // nullundefined
; // undefinedtrue
; // boolean"Louise"
; // string1337
; // number1337n
; // bigintSymbol("Franklin")
; // symbol
如果你忘记了基本类型的名称,则可以在 TypeScript 演练场或 IDE 键入 let
,然后将鼠标悬停在变量名称上。生成的弹出框将包含基本类型的名称,例如此截图显示悬停在字符串变量(图 2-1)。
图 2-1。TypeScript 在悬停信息中显示字符串变量类型
TypeScript 也足够聪明,能够推断出计算其起始值的变量的类型。在此示例中,TypeScript 知道三元表达式总是生成字符串,因此 bestSong
变量是 string
:
// Inferred type: string
let bestSong = Math.random() > 0.5
? "Chain of Fools"
: "Respect";
回到 TypeScript 演练场或你的 IDE,尝试将光标悬停在该 bestSong
变量上。你应该会看到一些信息框或消息,告诉你 TypeScript 已推断 bestSong
变量的类型为 string
(图 2-2)。
图 2-2。TypeScript 将 let 变量报告为其三元表达式中的字符串字面量类型
回想一下 JavaScript 中对象和基本基本类型之间的差异:像 Boolean 和 Number 这样的类包裹着它们的原始等价物。TypeScript 的最佳实践通常是指向小写名称,例如分别使用布尔值和数字。
类型系统
类型系统是指编程语言理解程序中的构造可能具有的类型的一组规则。
在其核心上,TypeScript 的类型系统通过以下方式工作:
- 读取你的代码并理解其中所有存在的类型和值
- 对于每个值,查看其初始声明指示的可能包含的类型
- 对于每个值,在代码的后续使用中查看其所有用法
- 如果一个值的使用与其类型不匹配,则向用户发出警告
让我们详细介绍一下这个类型推理过程。
以下代码片段为例,其中 TypeScript 会抛出有关成员属性被错误地调用为函数的类型错误:
let firstName = "Whitney";
firstName.length();
// ~~~~~~
// 此表达式不可调用。
// 类型 "Number" 没有调用签名。
TypeScript 在这个报错中,按照顺序做了以下事情:
- 读取代码并理解为一个名为
firstName
的变量 - 推断出
firstName
的类型为string
,因为它的初始值为"Whitney"
- 看到代码正在试图访问
firstName
的.length
成员,并像函数一样调用它 - 报错,因为字符串的
.length
成员是一个数字,而不是一个函数(它不能像函数一样调用)
理解 TypeScript 的类型系统是理解 TypeScript 代码的一项重要技能。本章和本书其余部分的代码片段将显示 TypeScript 能够从代码中推断出的越来越复杂的类型。
错误的种类
在编写 TypeScript 时,你最常遇到的两种“错误”是:
- 语法
阻止 TypeScript 转换为 JavaScript - 类型
类型检查器检测到不匹配的内容
两者之间的差异很重要。
语法错误
语法错误是指 TypeScript 检测到它无法理解为代码的错误语法。这些错误阻止 TypeScript 将你的文件正确生成输出 JavaScript。根据你用于将 TypeScript 代码转换为 JavaScript 的工具和设置,你可能仍会获得某种 JavaScript 输出(在默认的 tsc
设置中,你会得到)。但如果你这样做,它可能看起来不像你期望的那样。
以下输入中,TypeScript 存在意外 let
的语法错误:
let let wat;
// ~~~
// 错误: 应为“,”。
根据 TypeScript 编译器版本,其编译的 JavaScript 输出可能如下所示:
let let, wat;
无论语法错误如何,TypeScript 都会尽最大努力输出 JavaScript 代码,但输出代码可能不是你想要的。最好在尝试运行输出 JavaScript 之前修复语法错误。
类型错误
当语法有效但 TypeScript 类型检查器检测到程序类型错误时,会抛出类型错误。这些不会阻止 TypeScript 语法转换为 JavaScript。但是,它们通常表示,如果允许代码运行,或者表现出意想不到的行为。
你可以在第 1 章,“从 JavaScript 到 TypeScript” 通过 console.blub
示例中看到了这一点,代码在语法上是正确的,但 TypeScript 可以检测到它在运行时可能会崩溃:
console.blub("Nothing is worth more than laughter.");
// ~~~~
// 错误:类型“'Console'”上不存在属性“blub”。
尽管存在类型错误,但 TypeScript 可能会输出 JavaScript 代码,但类型错误通常表明输出 JavaScript 可能不会按你想要的方式运行。最好在运行 JavaScript 之前阅读它们并考虑修复任何报告的问题。
某些项目配置为在开发期间阻止运行代码,直到修复所有 TypeScript 类型错误(而不仅仅是语法)。许多开发人员,包括我自己,通常认为这很烦人且没有必要。大多数项目都有一种不被阻止的方法,例如使用 tsconfig.json 文件和第 13 章介绍的配置选项。
可分配性
TypeScript 读取变量的初始值以确定允许这些变量的类型。稍后如果看到为该变量分配了新值,它将检查该新值的类型是否与变量的类型相同。
TypeScript 可以稍后将相同类型的不同值分配给变量。例如,如果一个变量最初是 string
的值,那么稍后再给它分配一个 string
就可以了:
let firstName = "Carole";
firstName = "Joan";
如果 TypeScript 看到不同类型的赋值,它将给我们抛出一个类型错误。例如,我们不能最初声明一个具有 string
值的变量,然后放入 boolean
:
let lastName = "King";
lastName = true;
// 错误:不能将类型“boolean”分配给类型“string”。
TypeScript 对是否允许向函数调用或变量提供值的检查称为可分配性:该值是否可以分配给它传递给的预期类型。这将是后面章节中的一个重要术语,因为我们将比较更复杂的对象。
了解可分配性错误
格式为“不能将类型...分配给类型...”将是你在编写 TypeScript 代码时会看到的一些最常见的错误类型。
该错误消息中提到的第一种类型是代码试图分配给接收者的值的类型。提到的第二种类型是被分配第一种类型的接收者的类型。例如,当我们在上一个代码段中写入 lastName = true
时,我们试图将 true
(类型 boolean
)的值分配给接收者变量 lastName
(类型 string
)。
随着本书的学习,你会看到越来越复杂的可分配性问题。请记住仔细阅读它们,以了解实际类型和预期类型之间的报告差异。这样做将使你在TypeScript给出类型错误时更容易处理。
类型注解
有时变量没有初始值供 TypeScript 读取。TypeScript 不会尝试从后续的使用中推断变量的初始类型。默认情况下,它会将变量隐式地视为 any
类型,表示它可以是世界上的任何类型。
不能推断出初始类型的变量经历了所谓的演化 any:TypeScript 不会强制指定任何特定类型,而是每次分配新值时逐渐演化其对变量类型的理解。
在这个例子中,将演化 any
变量 rocker
首先赋值为一个字符串,这意味着它具有字符串方法,比如 toUpperCase
,但随后它演化为一个 number
:
let rocker; // Type: any
rocker = "Joan Jett"; // Type: string
rocker.toUpperCase(); // Ok
rocker = 19.58; // Type: number
rocker.toPrecision(1); // Ok
rocker.toUpperCase();
// ~~~~~~~~~
// 错误:类型“number”上不存在属性“toUpperCase”
TypeScript 能够检测到我们在一个演化成 number
类型的变量上调用了 toUpperCase()
方法。然而,它无法提前告诉我们是否有意将变量从 string
演化为 number
。
允许变量具有不断变化的 any
类型,以及在一般情况下使用 any
类型,部分违背了 TypeScript 类型检查的目的!TypeScript 在了解值的预期类型时效果最好。许多 TypeScript 的类型检查无法应用于 any
类型的值,因为它们没有已知的类型可以进行检查。第 13 章“配置选项”将介绍如何配置 TypeScript 的隐式 any
报错。
TypeScript 提供了一种语法,用于声明变量的类型,而无需为其分配初始值,称为类型注解。类型注解放在变量名称之后,包括一个冒号,后跟类型名称。
这个类型注解表明 rocker
变量应为 string
类型:
let rocker: string;
rocker = "Joan Jett";
这些类型注解仅适用于 TypeScript,它们不会影响运行时代码,也不是有效的 JavaScript 语法。如果你运行 tsc
将 TypeScript 源代码编译为 JavaScript,它们将被擦除。例如,前面的示例将编译为大致如下 JavaScript:
// output .js file
let rocker;
rocker = "Joan Jett";
将类型不可分配的值分配给变量的注解类型将导致类型错误。
这段代码为之前声明为 string
类型的 rocker
变量分配一个数字,从而导致类型错误:
let rocker: string;
rocker = 19.58;
// 错误: 不能将类型“number”分配给类型“string”。
在接下来的几章中,你将看到类型注解如何允许你增强 TypeScript 对代码的洞察,从而在开发过程中为你提供更好的功能。TypeScript 包含各种新语法片段,例如仅存在于类型系统中的类型注解。
仅存在于类型系统中的任何内容都不会被复制到已生成的 JavaScript 中。TypeScript 类型不会影响已生成的 JavaScript。
不必要的类型注解
类型注解允许我们向 TypeScript 提供它自己无法收集的信息。你也可以在具有可立即推断类型的变量上使用它们,但你不会告诉 TypeScript 任何它不知道的事情。
以下 :string
类型注解是多余的,因为 TypeScript 已经可以推断出 firstName
的类型为 string
:
let firstName: string = "Tina";
// ~~~~~~~~ 不更改类型系统...
如果你确实向具有初始值的变量添加了类型注解,TypeScript 将检查它是否与变量值的类型匹配。
以下 firstName
声明为 string
,但其初始值设定项是 number 42
,TypeScript 认为这是不兼容的:
let firstName: string = 42;
// ~~~~~~~~~
// 错误:不能将类型“number”分配给类型“string”。
许多开发人员(包括我在内)通常不喜欢在类型注解不会更改任何内容的变量上添加类型注解。手动编写类型注解可能很麻烦,尤其是当它们发生变化时,对于复杂的类型,我将在本书后面向你展示。
有时,在变量上包含显式类型注解以清楚地记录代码和/或使 TypeScript 免受对变量类型的意外更改,这很有用。我们将在后面的章节中看到,显式类型注解有时如何显式地告诉 TypeScript 通常无法推断的信息。
类型形状
TypeScript 的功能不仅仅是检查分配给变量的值是否与其基本类型匹配,TypeScript 还知道对象上应该存在哪些成员属性。如果你尝试访问变量的属性,TypeScript 将确保该属性在该变量的类型上存在。
假设我们声明一个 string
类型的变量 rapper
。稍后,当我们使用该 rapper
变量时, TypeScript 知道可以在字符串上执行的哪些操作是允许的:
let rapper = "Queen Latifah";
rapper.length; // ok
不允许 TypeScript 不知道处理字符串的操作:
rapper.push('!');
// ~~~~~~
// 类型“string”上不存在属性“push”。
类型也可以是更复杂的形状,尤其是对象。在下面的代码片段中,TypeScript 知道 birthNames
对象没有 middleName
键并报错:
let cher = {
firstName: "Cherilyn",
lastName: "Sarkisian",
};
cher.middleName;
// ~~~~~~
// 类型“{ firstName: string; lastName: string; }”上不存在属性
// “middleName”。
TypeScript 对对象形状的理解允许它报告对象使用的问题,而不仅仅是可分配性。第 4 章“对象”将描述更多 TypeScript 围绕对象和对象类型的强大功能。
模块
JavaScript 编程语言直到最近才包含文件之间如何相互共享代码的规范。ECMAScript 2015 添加了“ECMAScript 模块”(ESM),以标准化文件之间的 import
和 export
语法。
作为参考,此模块文件从同级 ./values
文件导入 value
并导出 doubled
变量:
import { value } from "./values";
export const doubled = value * 2;
为了与 ECMAScript 规范相匹配,在本书中我将使用以下命名法:
- 模块
具有顶级export
或import
的文件 - 脚本
任何不是模块的文件
TypeScript 能够与现代模块文件以及旧文件一起工作。除非模块文件中有明确的 export
语句,否则在该文件中声明的任何内容都将仅在该文件中可用。在一个模块中声明的变量与在另一个文件中声明的变量同名不会被视为命名冲突(除非一个文件导入另一个文件的变量)。
以下 a.ts
和 b.ts
文件都是模块,它们可以没有问题地导出一个名为 shared
的变量。c.ts
会导致类型错误,因为它在导入的 shared
与自己的值之间存在命名冲突:
// a.ts
export const shared = "Cher";
// b.ts
export const shared = "Cher";
// c.ts
import { shared } from "./a";
// ~~~~~~
// 错误:导入声明与“shared”的局部声明冲突。
export const shared = "Cher";
// ~~~~~~
// 错误:合并声明“shared”中的单独声明必须全为导出或全为局部声明。
但是,如果文件是脚本,TypeScript 会将其视为全局作用域的,这意味着所有脚本都可以访问其内容,也意味着脚本文件中声明的变量不能与其他脚本文件中声明的变量同名。
以下 a.ts
和 b.ts
文件被视为脚本,因为它们没有模块风格的 export
或 import
语句。这意味着它们的同名变量相互冲突,就好像它们是在同一个文件中声明的一样:
// a.ts
const shared = "Cher";
// ~~~~~~
// 无法重新声明块范围变量“shared”。
// b.ts
const shared = "Cher";
// ~~~~~~
// 无法重新声明块范围变量“shared”。
如果你在 TypeScript 文件中看到这些“Cannot redeclare……”(无法重新声明)的错误,可能是因为你尚未向文件添加 export
或 import
语句。根据 ECMAScript 规范,如果你需要一个没有 export
或 import
语句的模块,你可以在文件中添加一个 export {}
; 在文件中的某个位置强制它成为模块:
// a.ts and b.ts
const shared = "Cher"; // Ok
export {};
警告:TypeScript 将无法识别使用较旧的模块系统(如 CommonJS)编写的 TypeScript 文件中的导入和导出类型。TypeScript 通常会将从 CommonJS 风格的require
函数返回的值定义为any
类型。
总结
在本章中,你了解了 TypeScript 的类型系统的核心工作原理:
- 什么是“类型”以及 TypeScript 识别的基本类型
- 什么是“类型系统”以及 TypeScript 的类型系统如何理解代码
- 类型错误与语法错误的比较
- 推断变量类型和变量可分配性
- 显式声明变量类型的类型注解以并避免演变
any
类型 - 对类型形状的对象成员检查
- ECMAScript 模块文件的声明范围与脚本文件的比较
现在你已经读完了这一章,你最好练习一下学到的东西 https://learningtypescript.com/the-type-system.
为什么数字和字符串分手了?
因为他们不是彼此的类型。
评论 (0)