TypeScript手册——常见类型 (Everyday Types)

64 阅读19分钟

免责声明: 本文为翻译自 Typescript 官方手册内容,非原创。版权归原作者所有。

原文来源: Everyday Types

翻译者注: 本人在翻译过程中已尽最大努力确保内容的准确性和完整性,但翻译工作难免有疏漏。如读者在阅读中发现任何问题,欢迎提出建议或直接查阅原文以获取最准确的信息。

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

类型不仅可以出现在类型注释中,还可以出现在许多其他地方。在我们了解类型本身的同时,我们还将了解在哪些地方可以引用这些类型来形成新的结构。

我们将首先回顾编写 JavaScript 或 TypeScript 代码时可能遇到的最基本和常见的类型。这些稍后将成为更复杂类型的核心构建模块。

基本类型:stringnumber,和 boolean

JavaScript 有三种非常常用的原始类型:字符串(string)、数字(number)和布尔值(boolean)。在 TypeScript 中,每种类型都有对应的类型。正如你所预期的那样,如果你在这些类型的值上使用 JavaScript typeof 运算符,你会看到相同的名称。

  • string: 表示像 "Hello, world" 这样的字符串值
  • number: 表示像 42 这样的数值。JavaScript 没有针对整数的特殊运行时值,因此没有等同于 intfloat 的概念 - 一切都是简单地 number 类型
  • boolean: 表示 truefalse 两个值

类型名称 StringNumberBoolean(以大写字母开头)是合法的,但它们指的是一些特殊的内置类型,在你的代码中很少出现。始终使用 stringnumberboolean 来表示类型。

数组

要指定一个类似 [1, 2, 3] 的数组的类型,可以使用语法 number[];这个语法适用于任何类型(例如 string[] 是字符串数组等)。你也可能会看到它写作 Array<number>,意思是一样的。当我们学习泛型时,将更多地了解 T<U> 这种语法。

any

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

当一个值的类型是 any 时,你可以访问它的任何属性(这些属性本身也会是 any 类型),像调用函数一样调用它,将其赋值给(或从中赋值)任意类型的值,或者几乎做任何其他在语法上合法的操作。

let obj: any = { x: 0 };

// 以下代码行不会引发编译器错误。
// 使用 `any` 关键字将禁用所有进一步的类型检查,假定你比 TypeScript 更了解环境。
obj.foo();
obj();
obj.bar = 100;
obj = "hello";
const n: number = obj;

当你不想只是为了让 TypeScript 相信某行特定代码没问题而编写长类型时,any 类型非常有用。

noImplicitAny

如果没有指定类型,并且 TypeScript 无法从上下文中推断出类型时,编译器通常会默认为 any

但是,通常应该避免使用 any,因为它不进行类型检查。使用编译器标志 noImplicitAny 将隐式的 any 标记为错误。

变量的类型注释

当使用 constvarlet 声明变量时,可以选择性地添加类型注释来明确指定变量的类型。

let myName: string = "Alice";

TypeScript 不使用“左侧类型”声明,例如 int x = 0; 类型注释总是在被注释的变量之后。

在大多数情况下,这是不必要的。TypeScript 会尽可能地自动推断代码中的类型。例如,变量的类型会根据其初始化值的类型进行推断:

// 不需要类型注释 -- 'myName' 推断为字符串类型
let myName = "Alice";

大部分情况下,你不需要刻意的去学习推断规则。如果你刚开始使用,请尝试使用比你想象的更少的类型注释 - 你可能会惊讶地发现,对于 TypeScript 来说,你所需的很少。

函数

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

参数类型注释

当你声明一个函数时,可以在每个参数后面添加类型注释来声明函数接受的参数类型。参数类型注释放在参数名之后。

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

当参数具有类型注释时,将对该函数的参数进行检查。

// 如果执行,将会出现运行时错误!
greet(42);
// Argument of type 'number' is not assignable to parameter of type 'string'.

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

返回类型注释

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

function getFavoriteNumber(): number {
  return 26;
}

和变量类型注释相同,通常不需要特意添加返回类型注释,因为 TypeScript 会根据函数的返回语句推断出其返回类型。上面示例中的类型注释并没有改变任何内容。某些代码库可能会明确指定返回类型以供方便阅读文档、防止意外更改或个人偏好。

返回 Promise 的函数

如果你想注释一个返回 Promise 的函数的返回类型,你应该使用 Promise 类型:

async function getFavoriteNumber(): Promise<number> {
  return 26;
}

匿名函数

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

这是一个例子:

const names = ["Alice", "Bob", "Eve"];
 
// 函数的上下文类型推断 - 推断参数 s 的类型为字符串
names.forEach(function (s) {
  console.log(s.toUpperCase());
});
 
// 上下文类型推断也适用于箭头函数
names.forEach((s) => {
  console.log(s.toUpperCase());
});

尽管参数 s 没有类型注释,但 TypeScript 使用 forEach 函数的类型以及数组的推断类型来确定 s 将具有的类型。

这个过程被称为上下文类型化,因为函数发生在其中的上下文会告诉它应该具有什么样的类型。

类似于推断规则,你不需要明确学习这是如何发生的,但了解它确实发生可以帮助你区分什么时候可以不需要类型注释。稍后,我们将看到更多关于值所处上下文如何影响其类型的示例。

对象类型

除了原始类型外,你会遇到的最常见的类型是对象。这指的是具有属性的任何 JavaScript 值,几乎都是如此!要定义一个对象类型,我们只需列出其属性及其类型。

例如,下面是一个接受坐标对象的函数:

// 参数的类型注释是一个对象类型
function printCoord(pt: { x: number; y: number }) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}
printCoord({ x: 3, y: 7 });

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

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

可选的属性

对象类型还可以指定其某些或全部属性是可选的。要实现这一点,只需在属性名称后面添加一个 ?

function printName(obj: { first: string; last?: string }) {
  // ...
}

// 都可以
printName({ first: "Bob" });
printName({ first: "Alice", last: "Alisson" });

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

function printName(obj: { first: string; last?: string }) {
  // 错误 - 如果未提供 'obj.last',可能会崩溃!
  console.log(obj.last.toUpperCase());
  // 'obj.last' is possibly 'undefined'.
  
  if (obj.last !== undefined) {
    // OK
    console.log(obj.last.toUpperCase());
  }
 
  // 使用现代JavaScript语法的安全替代方案:
  console.log(obj.last?.toUpperCase());
}

联合类型

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

定义一个联合类型

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

我们来编写一个可以操作字符串或数字的函数:

function printId(id: number | string) {
  console.log("Your ID is: " + id);
}
// 没问题
printId(101);

// 没问题
printId("202");

// 报错
printId({ myID: 22342 });
// Argument of type '{ myID: number; }' is not assignable to parameter of type 'string | number'.

使用联合类型

提供与联合类型匹配的值很容易 - 只需提供与联合成员中任何一个匹配的类型即可。如果你有一个联合类型的值,如何处理它?

仅当操作对联合类型的每个成员都有效时,TypeScript 才会允许该操作。例如,如果有 string | number 的联合,则无法使用仅在字符串上可用的方法:

function printId(id: number | string) {
  console.log(id.toUpperCase());
  // Property 'toUpperCase' does not exist on type 'string | number'.
  // Property 'toUpperCase' does not exist on type 'number'.
}

解决方案是缩小代码的联合范围,就像在没有类型注释的 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

有时候会遇到一种所有成员都具有共同特点的联合。例如,数组和字符串都有一个 slice 方法。如果联合中的每个成员都具有共同属性,则可以在不缩小范围的情况下使用该属性:

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

可能会让人感到困惑的是,类型的联合似乎具有这些类型属性的交集。这不是偶然 - union 一词来自于类型理论。联合 number | string 由每个类型中值的并集组成。请注意,给定两个集合以及每个集合的相应事实,只有这些事实的交集适用于集合本身的并集。例如,如果我们有一个房间,里面都是戴着帽子的高个子,另一个房间里讲西班牙语的人都戴着帽子,那么将这些房间组合起来后,我们对每个人唯一了解的就是他们一定戴着帽子。

类型别名

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

类型别名就是为任何类型取一个名称。类型别名的语法是:

type Point = {
  x: number;
  y: number;
};
 
// 与之前的示例完全相同
function printCoord(pt: Point) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}
 
printCoord({ x: 100, y: 100 });

实际上可以使用类型别名来为任何类型命名,不仅限于对象类型。例如,类型别名可以给一个联合类型命名:

type ID = number | string;

请注意,别名只是别名 - 你不能使用类型别名来创建同一类型的不同“版本”。当你使用别名时,它与你编写的别名类型完全一样。换句话说,这段代码可能看起来非法,但在 TypeScript 中却是可以接受的,因为两个类型都是相同类型的别名:

type UserInputSanitizedString = string;
 
function sanitizeInput(str: string): UserInputSanitizedString {
  return sanitize(str);
}
 
// 创建一个经过清理的输入框
let userInput = sanitizeInput(getInput());
 
// 仍然可以使用字符串重新赋值
userInput = "new input";

接口

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

interface Point {
  x: number;
  y: number;
}
 
function printCoord(pt: Point) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}
 
printCoord({ x: 100, y: 100 });

就像我们在上面使用类型别名时一样,该示例的工作方式与我们使用匿名对象类型完全相同。TypeScript 只关心我们传递给 printCoord 的值的结构 - 它只关心它是否具有预期属性。只关注类型的结构和功能,这就是为什么我们称 TypeScript 为结构化类型系统的原因。

类型别名和接口之间的区别

类型别名和接口非常相似,在许多情况下,你可以可以在它们之间自由选择。几乎所有 interface 的特性在 type 中都是可用的,关键区别在于类型不能重新打开以添加新属性,而接口始终是可扩展的。

扩展接口

interface Animal {
  name: string;
}

interface Bear extends Animal {
  honey: boolean;
}

const bear = getBear();
bear.name;
bear.honey;

通过"&"扩展类型

type Animal = {
  name: string;
}

type Bear = Animal & { 
  honey: boolean;
}

const bear = getBear();
bear.name;
bear.honey;

向现有接口添加新字段

interface Window {
  title: string;
}

interface Window {
  ts: TypeScriptAPI;
}

const src = 'const a = "Hello World"';
window.ts.transpileModule(src, {});

类型创建后不可再更改

type Window = {
  title: string
}

type Window = {
  ts: TypeScriptAPI
}

// Error: Duplicate identifier 'Window'.

在后面的章节中你会学到更多关于这些概念的知识,所以如果你当前没有理解这些知识,请不要担心。

在大多数情况下,你可以根据个人喜好进行选择,TypeScript 会告诉你它是否需要其他类型的声明。如果你想要启发式方法,可以使用 interface 直到你需要使用 type 中的功能。

类型断言

有时候,你会拥有 TypeScript 无法知晓的关于值类型的信息。

例如,如果你正在使用 document.getElementById,TypeScript 只知道它将返回某种 HTMLElement 类型,但是你可能知道你的页面上总是会有一个具有特定 ID 的 HTMLCanvasElement。

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

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

与类型注释类似,类型断言会被编译器移除,并不会影响代码的运行时行为。

你还可以使用尖括号语法(代码必须在 .tsx 文件中),这是等效的:

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

提醒:由于类型断言在编译时被移除,因此与类型断言相关的运行时检查是不存在的。如果类型断言错误,将不会生成异常或空值。

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

const x = "hello" as number;
// Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently 
// overlaps with the other. If this was intentional, convert the expression to 'unknown' first.

有时候这个规则可能过于保守,会禁止一些可能是有效的更复杂的强制转换。如果发生这种情况,你可以使用两个断言,首先是到 any(或 unknown,在后面我们将介绍),然后再到所需的类型:

const a = expr as any as T;

字面量类型

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

思考这个问题的一种方式是考虑 JavaScript 如何使用不同的方式来声明变量。varlet 都允许改变变量内部存储的内容,而 const 则不允许。TypeScript 对字面值创建类型时也反映了这一点。

let changingString = "Hello World";
changingString = "Olá Mundo";
// 因为`changingString`可以表示任何可能的字符串,这就是TypeScript在类型系统中描述它的方式
 
const constantString = "Hello World";
// 因为`constantString`只能表示一个可能的字符串,所以它具有字面类型表示。

字面量类型本身并不是很有价值:

let x: "hello" = "hello";

// 没问题
x = "hello";

// ...
x = "howdy";
// Type '"howdy"' is not assignable to type '"hello"'.

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

但是通过将字面值组合成联合,可以表达一个更加有用的概念 - 例如,仅接受一组已知值集合的函数:

function printText(s: string, alignment: "left" | "right" | "center") {
  // ...
}

printText("Hello, world", "left");
printText("G'day, mate", "centre");
// Argument of type '"centre"' is not assignable to parameter of type '"left" | "right" | "center"'.

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

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");
// Argument of type '"automatic"' is not assignable to parameter of type 'Options | "auto"'.

还有一种字面类型:布尔字面量。只有两种布尔字面量类型,你可能猜到了,它们就是 truefalse 这两个类型。实际上,boolean 类型本身只是 true | false 的联合别名。

字面量推断

当你使用一个对象初始化变量时,TypeScript 假设该对象的属性可能会在以后改变值。例如,如果你编写了以下代码:

const obj = { counter: 0 };

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

TypeScript 不会假设将 1 赋值给先前为 0 的字段是一个错误。另一种说法是,obj.counter 必须是 number 类型,而不是 0,因为类型用于确定读取和写入行为。

这同样适用于字符串:

declare function handleRequest(url: string, method: "GET" | "POST"): void;
 
const req = { url: "https://example.com", method: "GET" };
handleRequest(req.url, req.method);
// Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'.

在上面的示例中,req.method 被推断为 string,而不是 "GET"。因为在创建 req 和调用 handleRequest 之间可以评估代码,这段代码可能会将一个新的字符串(如"GUESS")赋给req.method,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"”。

  1. 你可以使用 as const 将整个对象转换为类型字面量:
const req = { url: "https://example.com", method: "GET" } as const;
handleRequest(req.url, req.method);

as const 后缀的作用类似于 const,但是针对类型系统来说,确保所有属性都被赋予字面量类型,而不是更通用的版本,如 stringnumber

nullundefined

JavaScript 有两个原始值用于表示缺失或未初始化的值:nullundefined

TypeScript 有两个相应的同名类型。这些类型的行为取决于是否打开了 strictNullChecks 选项。

strictNullChecks 关闭

在关闭 strictNullChecks 的情况下,仍然可以正常访问可能为 nullundefined 的值,并且可以将 nullundefined 赋给任何类型的属性。这类似于没有 null 检查(例如C#,Java)的语言行为。不对这些值进行检查往往是 bug 的主要来源;我们始终建议人们在他们的代码库中实际可行时打开 strictNullChecks。

关闭 strictNullChecks 后,仍可以正常访问可能为 null 或 undefined 的值,并且可以将值 null 和 undefined 分配给任何类型的属性。 这类似于没有 null 检查的语言(例如 C#、Java)的行为方式。 缺乏对这些值的检查往往是错误的主要来源; 如果在代码库中可行的话,我们总是建议人们打开 strictNullChecks。

strictNullChecks 打开

启用 strictNullChecks 后,当一个值为 nullundefined 时,在使用该值的方法或属性之前,你需要先测试这些值。就像在使用可选属性之前检查是否为 undefined 一样,我们可以使用缩小范围来检查可能为 null 的值:

function doSomething(x: string | null) {
  if (x === null) {
    // do nothing
  } else {
    console.log("Hello, " + x.toUpperCase());
  }
}

非空断言运算符(后缀 !

TypeScript 还有一种特殊的语法,可以在不进行任何显式检查的情况下从类型中移除 nullundefined。在表达式后面加上 ! 实际上是一种类型断言,表示该值不为 nullundefined

function liveDangerously(x?: number | null) {
  // 不报错
  console.log(x!.toFixed());
}

就像其他类型断言一样,这不会改变代码的运行时行为,因此只有在你知道值不能为 nullundefined 时才使用 !

枚举

枚举是由 TypeScript 添加到 JavaScript 中的一个特性,它允许描述一个值,该值可以是一组可能的命名常量之一。与大多数 TypeScript 特性不同,这不是对 JavaScript 的类型级别进行的补充,而是添加到语言和运行时环境中的内容。因此,这是一个你应该知道的特性,但除非你确定需要使用它,否则尽可能的不要用。你可以在“枚举参考页面”上阅读更多关于枚举的信息。

不太常见的原始类型

值得一提的是类型系统中还有 JavaScript 其他原始数据类型,我们在这里不会进行深入讨论。

bigint

从 ES2020 开始,JavaScript 中有一个用于处理非常大的整数的原始类型,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) {
  // This comparison appears to be unintentional because the types 'typeof firstName' and 'typeof secondName' have no overlap.
  // 不可能发生
}

你可以在 Symbols 参考页面 上了解更多相关信息。