TypeScript 类型系统核心概念回顾 / Core Concepts of TypeScript Type System
TypeScript 的类型系统是其最强大的特性,它允许开发者在编写代码时捕获错误、增强代码可读性,并提供强大的工具支持。然而,要真正驾驭 TypeScript 的高级特性——如泛型、条件类型和映射类型——我们必须首先深入理解其核心基石。本章将系统回顾 TypeScript 类型系统的基本构成,从原始类型到特殊类型,从结构类型到类型操作,为你构建一个坚实而清晰的认知框架。我们将不仅学习“是什么”,更探讨“为什么”和“如何用”,最终确立“类型即文档”和“类型即测试”的工程化思维,为后续的类型体操打下不可或缺的基础。
---
1.1 基本类型:构建类型的原子 / Basic Types: The Atoms of Typing
TypeScript 的类型系统建立在一些不可再分的基本类型之上,它们是所有复杂类型的构建块。理解这些基本类型是掌握类型系统的第一步。
1.1.1 原始类型 / Primitive Types
TypeScript 支持 JavaScript 的所有原始类型,并对其进行了静态类型检查:
- `boolean`: 表示逻辑值 `true` 或 `false`。
- `number`: 表示双精度 64 位浮点数,包括整数和浮点数。TypeScript 还支持二进制 (`0b1010`)、八进制 (`0o744`)、十六进制 (`0x1A`) 字面量。
- `string`: 表示文本数据序列,使用单引号 (`'`)、双引号 (`"`) 或模板字面量 (`` ` ``) 定义。
- `null` 和 `undefined`: 分别表示“空值”和“未定义”。在严格模式下,它们不能赋值给其他类型,除非该类型显式包含它们(如通过联合类型)。
- `bigint`: 表示任意精度的整数,通过 `BigInt` 构造函数或后缀 `n` 创建(如 `10n`)。
- `symbol`: 表示唯一的、不可变的值,常用于对象属性的键。
此外,TypeScript 还引入了 `object` 类型(注意是小写 `o`)来表示非原始类型,但更常用的是接口、类或对象字面量类型。
关键区别:在 TypeScript 中,`String`、`Number`、`Boolean`(首字母大写)是 JavaScript 中对应的包装对象类型,几乎从不使用。应始终使用小写的原始类型。
```typescript // 正确:使用原始类型 let isDone: boolean = false; let decimal: number = 6; let color: string = "blue"; let big: bigint = 9007199254740991n; let sym: symbol = Symbol("unique");
// 错误:避免使用包装对象类型 let wrong: String = "hello"; // 警告:应使用 string ```
工程化视角:原始类型是类型系统的“原子”。它们简单、明确,是定义变量、函数参数和返回值的基石。在 API 设计中,明确使用原始类型是文档化的第一步。
1.1.2 数组与元组 / Arrays and Tuples
数组表示同质或异质元素的集合,元组表示固定长度、固定类型的数组。
数组:有两种常见定义方式: ```typescript // 方式1:元素类型后接 [] let list: number[] = [1, 2, 3]; let list2: Array<string> = ["a", "b"]; // 泛型形式
// 方式2:数组字面量推断 let inferredList = [1, 2, 3]; // 推断为 number[] ```
元组:允许表示一个已知元素数量和类型的数组,各元素类型可以不同: ```typescript let tuple: [string, number, boolean]; tuple = ["hello", 10, true]; // 正确 tuple = [10, "hello", true]; // 错误:类型不匹配 ```
元组的元素可通过索引访问,但索引类型是联合的: ```typescript tuple[0]; // 类型为 string | number | boolean // 为了安全,通常需要类型守卫或断言 if (typeof tuple[0] === "string") { console.log(tuple[0].toUpperCase()); // 此时 TypeScript 知道是 string } ```
只读数组:使用 `ReadonlyArray<T>` 或 `readonly` 修饰符防止修改: ```typescript let readonlyList: ReadonlyArray<number> = [1, 2, 3]; // readonlyList.push(4); // 错误:只读
let readonlyTuple: readonly [string, number] = ["a", 1]; // readonlyTuple[0] = "b"; // 错误:只读 ```
工程化视角:数组用于表示列表数据,元组用于表示固定结构的记录(如函数返回多个值、CSV 行解析)。`readonly` 是重要的安全特性,能防止意外修改,尤其在函数参数中传递数组时。
1.1.3 对象类型与字面量类型 / Object Types and Literal Types
对象类型描述对象的结构。最简单的形式是对象字面量: ```typescript let obj: { name: string; age: number } = { name: "Alice", age: 30 }; ```
可选属性:使用 `?` 标记: ```typescript interface Config { timeout?: number; // 可选 retries: number; // 必选 } ```
索引签名:允许动态属性名: ```typescript interface StringDict { [key: string]: string; // 所有属性值必须是 string } let dict: StringDict = { a: "apple", b: "banana" }; ```
字面量类型:表示具体的值,而不仅仅是值的类型: ```typescript let yes: true = true; // 只能是 true let direction: "left" | "right" = "left"; // 只能是这两个字符串之一 ```
字面量类型在联合类型中特别有用,用于创建受限的常量集合: ```typescript type Status = "idle" | "loading" | "success" | "error"; let currentStatus: Status = "loading"; // 正确 // currentStatus = "pending"; // 错误 ```
工程化视角:对象类型是定义数据结构的主要方式。字面量类型提供了“枚举替代方案”,在需要有限集合且无需额外方法时更轻量。索引签名适用于字典类结构,但应谨慎使用,因为它弱化了类型检查。
---
1.2 类型注解与类型推断 / Type Annotations and Type Inference
TypeScript 允许我们显式标注类型(注解),也允许编译器自动推断类型(推断)。理解两者的平衡是编写简洁且安全代码的关键。
1.2.1 类型注解 / Type Annotations
类型注解在变量声明、函数参数、返回值等处显式指定类型: ```typescript let name: string = "Bob"; // 变量注解 function add(a: number, b: number): number { // 参数和返回值注解 return a + b; } ```
何时使用注解:
- 函数公开 API(参数和返回值):明确契约。
- 变量声明时初始化值为 `null` 或 `undefined`:帮助推断 intended 类型。
- 复杂对象或数组:提高可读性。
- 避免歧义:当初始化表达式无法明确推断时(如 `[]` 空数组)。
1.2.2 类型推断 / Type Inference
TypeScript 会尽可能根据上下文推断类型,减少冗余注解: ```typescript let inferredString = "hello"; // 推断为 string let inferredNumber = 123; // 推断为 number
function multiply(x, y) { // 无注解,参数默认为 any(在 noImplicitAny 下报错) return x * y; } // 更好的写法:让编译器推断返回类型,但参数建议注解 function betterMultiply(x: number, y: number) { return x * y; // 返回类型推断为 number } ```
推断规则:
- 变量初始化时,从初始值推断。
- 函数返回时,从 `return` 语句推断(若无 `return` 或返回值复杂,可能推断为 `void` 或 `any`)。
- 上下文类型:当表达式位置有预期类型时,从上下文推断。常见于函数参数、数组字面量赋值给特定类型变量等。
示例:上下文类型推断 ```typescript type Callback = (name: string) => void; const cb: Callback = (arg) => { // arg 被推断为 string console.log(arg.toUpperCase()); // 安全调用 string 方法 }; ```
最佳实践:
- **“最小权限”原则**:只在编译器无法正确推断或需要明确文档时添加注解。
- **函数参数始终注解**:即使返回类型可推断,参数注解也是 API 契约的核心。
- **避免 `any`**:启用 `noImplicitAny`,让编译器强制你思考类型。
工程化视角:类型注解是“主动文档”,类型推断是“编译器智能”。平衡二者:不过度注解导致冗余,也不依赖推断导致隐晦。在团队协作中,明确的参数注解是沟通的桥梁。
---
1.3 结构类型系统:鸭子辨型法 / Structural Typing: Duck Typing
TypeScript 采用结构类型系统(也称“鸭子类型”),即两个类型如果结构相同,则它们兼容,无论其名称是否相同。这与 Java、C# 等的名义类型系统(需显式声明继承或实现)形成对比。
1.3.1 核心规则:结构兼容性 / Core Rule: Structural Compatibility
如果类型 `S` 要赋值给类型 `T`,则 `S` 必须至少拥有 `T` 的所有属性,且对应属性类型兼容。
```typescript interface Named { name: string; } interface Aged { age: number; }
// 结构兼容:Person 有 name 和 age,满足 Named 和 Aged 的要求 let person: Named & Aged = { name: "Alice", age: 30 };
// 即使没有显式声明实现,只要结构匹配即可 class Employee { constructor(public name: string, public age: number) {} } let employee: Named = new Employee("Bob", 40); // 正确:Employee 结构包含 name ```
1.3.2 属性比较规则 / Property Comparison Rules
- **必需属性**:目标类型的所有必需属性必须在源类型中存在。
- **额外属性**:源类型可以有额外属性(“超集”兼容“子集”)。
- **可选属性**:源类型中,目标类型的可选属性可以不存在,但若存在则类型必须兼容。
- **私有/保护成员**:如果类型有 `private` 或 `protected` 成员,则必须来自同一声明(名义类型检查介入)。
```typescript interface Point { x: number; y: number; } interface Point3D extends Point { z: number; }
let p2d: Point = { x: 1, y: 2 }; let p3d: Point3D = { x: 1, y: 2, z: 3 };
p2d = p3d; // 正确:Point3D 是 Point 的超集 // p3d = p2d; // 错误:Point 缺少 z 属性 ```
1.3.3 函数类型的结构兼容性 / Function Type Compatibility
函数类型兼容性基于参数和返回值:
- **返回值**:源函数返回值类型必须赋值给目标函数返回值类型(协变)。
- **参数**:源函数参数类型必须能被目标函数参数类型赋值(逆变),但 TypeScript 默认是**双向协变**(为了常见模式如数组),在 `strictFunctionTypes` 下参数变为**逆变**(更安全)。
```typescript // 返回值协变:子类型可赋值给父类型 type ReturnsNumber = () => number; type ReturnsString = () => string; let fnNum: ReturnsNumber = () => 42; let fnStr: ReturnsString = () => "hello"; // fnNum = fnStr; // 错误:string 不能赋值给 number fnStr = fnNum; // 正确:number 可视为 string?不,实际上这里可能有问题
// 更清晰的例子:动物与猫 interface Animal { name: string } interface Cat extends Animal { meow(): void } let getAnimal: () => Animal = () => ({ name: "generic" }); let getCat: () => Cat = () => ({ name: "whiskers", meow() {} }); // getAnimal = getCat; // 在 strictFunctionTypes 下错误:参数逆变?不,这里讨论返回值 // 返回值:Cat 是 Animal 的子类型,所以 () => Cat 可赋值给 () => Animal?不,应该是反的。 // 正确:getCat = getAnimal; // 错误,因为 Animal 不一定有 meow // 实际上:函数返回值是协变,所以 () => Cat 可以当作 () => Animal 使用(因为 Cat 是 Animal 的子类型),因此: getAnimal = getCat; // 正确:getCat 返回 Cat,而 Cat 是 Animal,所以满足 () => Animal ```
参数逆变示例(`strictFunctionTypes` 开启): ```typescript type Comparator = (a: number, b: number) => number; let numComparator: Comparator = (a, b) => a - b;
type StringComparator = (a: string, b: string) => number; let strComparator: StringComparator = (a, b) => a.localeCompare(b);
// 在严格函数类型下,参数是逆变: // 期望 Comparator(参数为 number),实际传入的函数参数类型应更“宽”(如 unknown 或 never?不,逆变意味着可以接受更具体的类型) // 实际上:如果函数期望 number,你可以传一个接受 any 的函数,但不能传一个只接受 string 的函数。 // 逆变:目标类型参数是基类,源类型参数是派生类?不,逆变是反的。 // 简单规则:函数赋值时,源函数的参数类型应包含目标函数的参数类型(即更一般),返回值应被包含(即更具体)。 // 所以:Comparator 的参数是 number,StringComparator 的参数是 string,两者不相关,无法赋值。 // 正确示例: type Handler = (event: Event) => void; type MouseHandler = (event: MouseEvent) => void; // MouseEvent 继承自 Event let handler: Handler = (e) => console.log(e); let mouseHandler: MouseHandler = (e) => console.log(e.button); // handler = mouseHandler; // 在严格模式下:正确!因为 MouseEvent 是 Event 的子类型,所以 MouseHandler 可以当作 Handler 使用(参数逆变:接受更具体的 event 没问题) // 解释:当调用 handler(e: Event) 时,e 可能是任何 Event,但 mouseHandler 能处理 MouseEvent,而 MouseEvent 是 Event 的子集,所以安全。 ```
工程化视角:结构类型使得 TypeScript 非常灵活,无需显式继承即可复用类型。但需注意:两个结构相同的类型可能来自不同领域(如 `{ x: number, y: number }` 可用于点或坐标)