likes
comments
collection
share

TypeScript 学习指南——类型系统

作者站长头像
站长
· 阅读数 20

本文选自我在翻译的《Learning TypeScript》,比较长,读完需耐心。

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, -4n
  • symbol... // ,Symbol(), Symbol("hi")

对于这些值,TypeScript 将值的类型理解为七个基本类型之一:

  • null; // null
  • undefined; // undefined
  • true; // boolean
  • "Louise"; // string
  • 1337; // number
  • 1337n; // bigint
  • Symbol("Franklin"); // symbol

如果您忘记了基本类型的名称,则可以在 TypeScript 演练场 或 IDE 键入 let,然后将鼠标悬停在变量名称上。生成的弹出框将包含基本类型的名称,例如此屏幕截图显示悬停在字符串变量(图 2-1)。

TypeScript 学习指南——类型系统

图 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)。

TypeScript 学习指南——类型系统

图 2-2。TypeScript 将 let 变量报告为其三元表达式中的字符串字面量类型

回想一下 JavaScript 中对象和基本类型之间的差异:像 Boolean 和 Number 这样的类包裹着它们的基本类型。TypeScript 的最佳实践通常是引用小写名称,例如分别使用布尔值和数字。

类型系统

类型系统是一组规则,用于说明编程语言如何理解程序中的构造可能具有的类型。

TypeScript 的类型系统的核心是:

  • 阅读代码并了解存在的所有类型和值
  • 对于每个值,查看其初始声明指示可能包含的类型
  • 对于每个值,查看稍后在代码中使用它的所有方式
  • 如果值的用法与其类型不匹配,则向用户报错

让我们详细介绍一下这个类型推理过程。

以下代码片段为例,其中 TypeScript 会抛出有关成员属性被错误地调用为函数的类型错误:

let  firstName = "Whitney"; 
firstName.length();
// ~~~~~~

// 此表达式不可调用。
//    类型 "Number" 没有调用签名。

TypeScript 的报错是按顺序排列的:

  1. 读取代码并了解有一个名为 firstName 的变量
  2. 得出 firstName 的类型为 string,因为它的初始值为 "Whitney"
  3. 看到代码正在尝试访问 firstName.length 成员并像函数一样调用它
  4. 抱怨字符串的 .length 成员是一个数字,而不是一个函数*(它不能像函数一样调用)*

理解 TypeScript 的类型系统是理解 TypeScript 代码的一项重要技能。本章和本书其余部分的代码片段将显示 TypeScript 能够从代码中推断出的越来越复杂的类型。

错误的种类

在编写 TypeScript 时,您最常遇到的两种“错误”是:

  • 语法 阻止 TypeScript 转换为 JavaScript
  • 类型 类型检查器检测到不匹配的内容

两者之间的差异很重要。

语法错误

语法错误是指 TypeScript 检测到它无法理解为代码的错误语法。这些错误阻止 TypeScript 将您的文件正确生成输出 JavaScript。根据您用于将 TypeScript 代码转换为 JavaScript 的工具和设置,您可能仍会获得某种 JavaScript 输出(你会在默认的 tsc 设置中得到)。但如果您这样做,它可能看起来不像您期望的那样。

以下输入中,TypeScript 存在意外 let 的语法错误:

let let wat;
//    ~~~
// Error: ',' expected.

根据 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 能够发现我们正在对演变为 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,以标准化文件之间的 importexport 语法。

作为参考,此模块文件从同级 ./values 文件导入 value 并导出 doubled 变量:

import { value } from "./values";

export const doubled = value * 2;

为了与 ECMAScript 规范相匹配,在本书中我将使用以下命名法:

  • 模块 具有顶级 exportimport 的文件
  • 脚本 任何不是模块的文件

TypeScript 能够处理这些现代模块文件以及旧文件。模块文件中声明的任何内容仅在该文件中可用,除非该文件中明确使用export语句将其导出。在一个模块中声明的变量与在另一个文件中声明的变量同名不会被视为命名冲突(除非一个文件导入另一个文件的变量)。

以下 a.tsb.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.tsb.ts 文件被视为脚本,因为它们没有模块风格的 exportimport 语句。这意味着它们的同名变量相互冲突,就好像它们是在同一个文件中声明的一样:

// a.ts
const shared = "Cher";
//  ~~~~~~
// 无法重新声明块范围变量“shared”。
// b.ts
const shared = "Cher";
//  ~~~~~~
// 无法重新声明块范围变量“shared”。

如果您在 TypeScript 文件中看到这些“Cannot redeclare……”(无法重新声明)的错误,可能是因为您尚未向文件添加 exportimport 语句。根据 ECMAScript 规范,如果您需要一个没有 exportimport 语句的模块,您可以在文件中添加一个 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.


为什么数字和字符串会分开了? 他们不是彼此喜欢的类型。