TypeScript类型系统基础回顾
TypeScript 的类型系统是其核心优势之一,它通过在 JavaScript 之上添加静态类型检查,显著提升了大型应用程序的可维护性、开发工具支持(如智能提示、重构)和代码的健壮性。然而,许多开发者仅停留在使用基本类型(如 `string`、`number`)和简单接口的层面,未能充分释放其潜力。真正掌握 TypeScript 的关键,在于理解其类型系统作为一门“编译时编程语言”的深层能力——即所谓的“类型体操”。这不仅涉及类型的定义,更涉及类型的计算、转换与推理。
本章旨在为整本书奠定坚实基础。我们将系统回顾 TypeScript 类型系统的核心概念,但绝非简单罗列。我们的目标是澄清原理、纠正误区、建立正确的类型思维模式。许多开发者在实际编码中遇到的类型错误或设计困惑,往往源于对基础概念(如结构类型系统、类型兼容性)的理解偏差。因此,本章将重点剖析这些底层机制,并通过精心设计的对比示例,展示“正确”与“看似正确”之间的微妙区别。最终,我们将初步引入类型编程的基本范式,为后续章节中泛型、条件类型与映射类型等高级主题做好铺垫。
---
1.1 原始类型、对象类型与字面量类型
TypeScript 的类型体系始于最基础的原始类型(Primitive Types)。它们对应 JavaScript 运行时的基本数据单位:`string`、`number`、`boolean`、`null`、`undefined`、`symbol`、`bigint`。此外,TypeScript 还引入了两个特殊的原始类型变体:`any` 与 `unknown`,以及表示“无值”的 `void` 和表示“永不出现”的 `never`。
```typescript let name: string = "Alice"; let age: number = 30; let isActive: boolean = true; let maybe: null = null; let optional: undefined = undefined; let sym: symbol = Symbol("id"); let big: bigint = 9007199254740991n;
// any:关闭类型检查,慎用 let flexible: any = "can be anything"; flexible = 123; // 允许
// unknown:类型安全的 any let safeUnknown: unknown = "maybe a string"; // safeUnknown.toFixed(); // 错误:unknown 上没有 toFixed 方法 if (typeof safeUnknown === "string") { console.log(safeUnknown.toUpperCase()); // 正确:类型守卫后缩小为 string }
// void:通常用于函数无返回值 function log(message: string): void { console.log(message); }
// never:函数永远不返回(如抛出异常或无限循环) function throwError(message: string): never { throw new Error(message); } function infiniteLoop(): never { while (true) {} } ```
关键误区澄清: 1. `any` 与 `unknown` 的本质区别:`any` 是类型的“逃逸舱”,赋值给 `any` 的变量可以调用任何方法、访问任何属性而不报错,它完全 bypass 了类型检查。而 `unknown` 是类型安全的顶层类型,任何类型都可以赋值给 `unknown`,但不能直接对 `unknown` 进行操作,必须先通过类型守卫(如 `typeof`、`instanceof`)或类型断言将其缩小为更具体的类型。这是 TypeScript 3.0 引入的重要安全特性。 2. `void` 与 `undefined`:`void` 表示“没有显式返回值”,而 `undefined` 是一个具体的值。函数可以返回 `undefined` 但类型标注为 `void`,反之亦然。在实践中,`void` 主要用于标注函数返回类型,表示调用者不应依赖其返回值。 3. `never` 的真正含义:`never` 表示一个不可能存在的值。它出现在以下情况:函数总是抛出异常、函数无限循环、或者一个联合类型被穷尽检查后剩余的分支。`never` 类型是类型收窄的终点,在条件类型中扮演关键角色。
与原始类型相对的是对象类型(Object Types)。在 TypeScript 中,“对象”是一个广义概念,包括普通对象 `{}`、数组 `[]`、函数 `() => {}` 等非原始值。定义对象类型的主要方式有接口(Interface) 和 类型别名(Type Alias)。
```typescript // 接口:通常用于描述对象形状,支持声明合并 interface User { id: number; name: string; email?: string; // 可选属性 readonly createdAt: Date; // 只读属性 }
// 类型别名:更灵活,可表示任何类型(包括原始、联合、交叉等) type Point = { x: number; y: number; };
type Status = "active" | "inactive" | "pending"; // 字面量联合类型
// 接口与类型别名的选择 // 优先使用接口描述对象结构,因为其“声明合并”特性便于扩展(如库的 augment) // 使用类型别名描述联合、元组、或需要工具类型映射的复杂类型 ```
对象类型的核心特性:
- **可选属性(`?`)**:表示该属性可以不存在。访问时需注意可能为 `undefined`。
- **只读属性(`readonly`)**:防止在对象创建后被修改。对数组可用 `readonly` 修饰符使其成为只读数组。
- **索引签名(Index Signatures)**:允许对象具有动态数量的属性。
```typescript interface Dictionary { [key: string]: number; // 所有字符串键对应的值必须是 number } const dict: Dictionary = { a: 1, b: 2 }; // dict.c = 3; // 错误:c 隐式具有 `any` 类型,因为索引签名是 number ``` 注意:索引签名与显式声明的属性必须类型兼容。
字面量类型(Literal Types) 是 TypeScript 的一个强大特性。它允许将具体的值(如字符串 `"success"`、数字 `42`)视为类型。这为精确建模有限状态、配置选项提供了可能。
```typescript type Direction = "north" | "south" | "east" | "west"; let move: Direction = "north"; // 正确 // move = "up"; // 错误:类型 '"up"' 不能赋值给类型 'Direction'
type HTTPStatus = 200 | 301 | 404 | 500; let status: HTTPStatus = 404;
// 布尔字面量 type BooleanFlag = true | false; let flag: BooleanFlag = true; ```
字面量类型通常与联合类型结合使用,形成字面量联合类型,这是建模枚举、状态机的基石。与 `enum` 相比,字面量联合类型更轻量、类型安全(值必须完全匹配)、且能进行类型收窄。
---
1.2 接口与类型别名:语义、区别与选择
接口(`interface`)和类型别名(`type`)是定义对象类型的两种主要机制,它们在使用上高度重叠,但存在关键差异,理解这些差异对于设计清晰、可维护的类型结构至关重要。
1.2.1 核心相似性
对于描述对象结构,两者基本可以互换: ```typescript interface Person { name: string; age: number; } type PersonType = { name: string; age: number; };
const p1: Person = { name: "Bob", age: 25 }; const p2: PersonType = { name: "Alice", age: 30 }; ```
1.2.2 关键差异
| 特性 | 接口 (`interface`) | 类型别名 (`type`) | | :--- | :--- | :--- | | 扩展方式 | 使用 `extends` 继承其他接口或类型别名 | 使用交叉类型 (`&`) 合并其他类型 | | 声明合并 | 支持。同名的接口会自动合并其成员。 | 不支持。重复定义会报错。 | | 描述范围 | 主要用于对象类型(包括函数、数组、索引签名)。 | 可描述任何类型:原始类型、联合类型、交叉类型、元组、甚至其他类型别名。 | | 实现/继承 | 类可以使用 `implements` 来实现接口。 | 类不能实现类型别名(但可以实现接口)。 |
差异详解与示例:
1. 扩展与合并: ```typescript // 接口扩展 interface Animal { name: string; } interface Dog extends Animal { breed: string; } const dog: Dog = { name: "Rex", breed: "German Shepherd" };
// 类型别名交叉 type AnimalType = { name: string }; type DogType = AnimalType & { breed: string }; const dog2: DogType = { name: "Fido", breed: "Labrador" };
// 接口声明合并(库设计的强大特性) interface Window { title: string; } // 在另一个文件中再次声明同名接口,成员会被合并 interface Window { version: string; } const w: Window = { title: "My App", version: "1.0" }; // 两者属性都存在 ```
2. 描述能力: ```typescript // 类型别名可以定义联合、元组等 type Status = "idle" | "loading" | "success" | "error"; type Coordinates = [number, number]; // 元组
// 接口无法直接做到以上两点 // interface Status = "idle" | ... // 语法错误 ```
3. 类实现: ```typescript interface Shape { area(): number; } class Circle implements Shape { // 正确:类可以实现接口 constructor(public radius: number) {} area() { return Math.PI * this.radius ** 2; } }
type ShapeType = { area(): number; }; // class Circle implements ShapeType {} // 错误:类型别名不能在类实现中使用 ```
1.2.3 选择指南与最佳实践
- **优先使用接口描述对象形状**,尤其是在定义公共 API、库或需要**声明合并**(如为第三方库添加自定义属性)的场景。`interface` 的语义更明确,专为对象结构设计。
- **使用类型别名** 当需要定义:
- 联合类型或交叉类型。
- 元组。
- 映射类型(后续章节详述)或条件类型的输出。
- 需要从现有类型中提取部分(如 `Pick<T, K>`)或转换的类型。
- **注意**:从 TypeScript 4.2 起,类型别名也可以 `extends` 其他类型(包括接口),模糊了部分差异。但**声明合并**和**类实现**仍是接口的独有优势。
一个常见的混淆点:`type` 定义的函数类型与 `interface` 定义的函数类型的写法差异。 ```typescript // 接口:直接描述调用签名 interface Calculator { (a: number, b: number): number; } const add: Calculator = (x, y) => x + y;
// 类型别名:使用类型谓词 type CalculatorType = (a: number, b: number) => number; const subtract: CalculatorType = (x, y) => x - y; ```
---
1.3 联合类型与交叉类型:组合的力量
组合是类型系统构建复杂类型的核心手段。TypeScript 提供了两种主要的类型组合运算符:联合类型(`|`)和交叉类型(`&`)。它们分别对应集合论中的“或”与“与”,但实际应用中远非如此简单。
1.3.1 联合类型(Union Types):`A | B`
联合类型表示一个值可以是类型列表中的任意一个。它是实现类型收窄(Type Narrowing) 的基础。
```typescript type StringOrNumber = string | number; let value: StringOrNumber; value = "hello"; // 正确 value = 42; // 正确 // value = true; // 错误
function format(value: string | number) { // 此时,value 可能是 string 也可能是 number if (typeof value === "string") { // 在此代码块内,TypeScript 自动将 value 收窄为 string console.log(value.toUpperCase()); // 安全:string 有 toUpperCase } else { // 否则(即 number),value 被收窄为 number console.log(value.toFixed(2)); // 安全:number 有 toFixed } }
// 另一种收窄方式:类型守卫函数 function isString(value: any): value is string { return typeof value === "string"; } if (isString(value)) { value; // 在此,value 被收窄为 string } ```
联合类型的常见陷阱:
- **访问共有成员**:只能访问联合类型中**所有成员共有的**属性或方法。
```typescript type Cat = { meow: () => void; breed: string }; type Dog = { bark: () => void; breed: string }; type Pet = Cat | Dog;
const pet: Pet = ...; pet.breed; // 正确:Cat 和 Dog 都有 breed // pet.meow(); // 错误:并非所有成员都有 meow // pet.bark(); // 错误 ``` 要访问特定成员,必须进行类型收窄。
- **与 `any`/`unknown` 的交互**:`any | T` 等于 `any`(破坏性)。`unknown | T` 等于 `unknown | T`(安全,仍需收窄)。
- **可分配性**:联合类型是**分配性**的。`X` 可分配给 `A | B` 当且仅当 `X` 可分配给 `A` **或** `X` 可分配给 `B`。这导致一个反直觉现象:`string` 可分配给 `string | number`,但 `string | number` **不可**分配给 `string`。
1.3.2 交叉类型(Intersection Types):`A & B`
交叉类型表示一个值同时满足多个类型。它常用于组合多个对象类型,模拟“多重继承”或混入(Mixin)。
```typescript type Admin = User & Permissions; interface User { id: number; name: string; } interface Permissions { canDelete: boolean; canEdit: boolean; } const admin: Admin = { id: 1, name: "Admin", canDelete: true, canEdit: true, }; // 必须同时拥有 User 和 Permissions 的所有属性
// 交叉类型与函数组合 type Log = (msg: string) => void