TypeScript 类型系统入门
第 1 章:TypeScript 类型系统入门
理解类型:为什么 TypeScript 要在代码运行前检查类型
当你在 JavaScript 中写下 `let age = 25; age = "thirty";` 时,程序不会报错,代码会顺利"运行"——直到某个用户输入了这个字符串,你的计算逻辑开始崩溃。JavaScript 的动态类型系统允许变量在运行时改变类型,这种灵活性曾是它的优势,但在大型项目中,它也成了 bug 的温床。TypeScript 的核心问题正是:能否在代码运行之前,让计算机帮助我们发现这些类型相关的错误?答案是肯定的,而这正是 TypeScript 类型系统的职责所在。
本章将带你理解 TypeScript 如何通过静态类型检查来捕获传统 JavaScript 中难以发现的错误。我们从 JavaScript 的动态类型机制开始,解释它为何既强大又危险;然后深入 TypeScript 的基础类型系统,掌握如何用类型注解精确描述数据的形状;接着区分类型注解与类型推断两种工作模式,理解何时让编译器自动推导、何时需要显式声明;最后通过一个完整的实践项目,展示类型系统如何在实际开发中发挥作用。
---
1.1 从灵活到失控:JavaScript 动态类型的双面性
动态类型的工作原理
JavaScript 采用动态类型(Dynamic Typing)系统,这意味着变量的类型不是在声明时固定,而是在程序运行时根据赋值确定。同一个变量可以在不同时间持有不同类型的值:
```javascript let message = "Hello"; // message 是字符串 message = 42; // 现在 message 变成了数字 message = true; // 又变成了布尔值 message = { greeting: "Hi" }; // 甚至可以变成对象 ```
这种机制让 JavaScript 拥有了极高的灵活性。函数可以接受任何类型的参数,返回任何类型的结果,一段代码可以应对多种数据结构。早期 Web 开发中,这种特性简化了原型开发,让开发者能够快速迭代。
动态类型的隐患
然而,灵活性的代价是隐式的类型不确定性。当代码规模扩大、多人协作时,问题逐渐显现:
```javascript // 计算用户年龄 function calculateAge(birthYear) { return 2024 - birthYear; }
// 调用时出错——传入字符串而非数字 let userAge = calculateAge("1990"); // 返回 1034,而非 34 console.log("Your age is: " + userAge); // 输出混乱的结果 ```
这个 bug 不会触发任何运行时错误——JavaScript 只是默默执行了字符串减法操作。由于 `"2024" - "1990"` 的结果在语法上有效(JavaScript 将字符串转为数字进行算术运算),代码"成功"运行,但输出了错误的结果。
更严重的情况出现在类型断言和属性访问中:
```javascript function getUserName(user) { return user.name.toUpperCase(); // 假设 user 一定有 name 属性 }
getUserName({ name: "Alice" }); // 正常工作 getUserName({}); // TypeError: Cannot read property 'name' of undefined getUserName(null); // TypeError: Cannot read property 'name' of null ```
上述代码在生产环境中可能导致应用崩溃,而这些错误只有在特定数据传入时才会暴露——往往是在代码部署之后。
静态类型的解决思路
静态类型(Static Typing)系统采用完全不同的策略:类型检查发生在代码编译阶段,而非运行时。开发者需要在编写代码时明确声明每个变量的类型,编译器(TypeScript 中是 `tsc`)会分析代码逻辑,验证类型的一致性:
```typescript function calculateAge(birthYear: number): number { return 2024 - birthYear; }
calculateAge("1990"); // ❌ 编译错误:Argument of type 'string' is not assignable to parameter of type 'number' ```
现在,同样的错误在代码编写时就被捕获了。编辑器会在你输入 `"1990"` 的瞬间显示红色下划线,告诉你这个字符串不能赋值给期望数字类型的参数。类型错误从生产环境的运行时崩溃,变成了开发阶段的编译警告。
这并不意味着静态类型能捕获所有错误——逻辑错误、业务流程错误仍然需要依靠测试和代码审查。但它显著减少了因类型不一致导致的 bug 类别,让开发者能够更自信地重构代码、修改类型定义。
---
1.2 TypeScript 的基础类型分类
TypeScript 的类型系统建立在 JavaScript 基础之上,同时提供了更精确的类型描述能力。理解这些基础类型是掌握 TypeScript 的第一步。
原始类型(Primitive Types)
JavaScript 有七种原始类型,TypeScript 完全继承并在编译时进行类型检查:
字符串(string):表示文本数据,可以使用单引号、双引号或模板字面量定义:
```typescript const author: string = "TypeScript"; const greeting: string = `Hello, ${author}!`; // 模板字符串 const empty: string = ''; ```
数字(number):所有数字均使用同一类型,包括整数、浮点数、十六进制、二进制和八进制表示:
```typescript const count: number = 42; const price: number = 19.99; const hex: number = 0xFF; // 十六进制:255 const binary: number = 0b1010; // 二进制:10 const octal: number = 0o755; // 八进制:493 ```
布尔值(boolean):最简单的类型,只有 `true` 和 `false` 两个值:
```typescript const isActive: boolean = true; const hasPermission: boolean = false; ```
null 和 undefined:在 TypeScript 中,它们既是值,也是类型:
```typescript const emptyValue: null = null; const uninitialized: undefined = undefined; ```
这两个类型在实际开发中有细微区别:通常 `null` 表示"有意为空",而 `undefined` 表示"尚未赋值"。TypeScript 提供了两个独立的类型控制这一语义。
symbol:表示唯一的标识符,通过 `Symbol()` 函数创建:
```typescript const uniqueId: symbol = Symbol('id'); const anotherId: symbol = Symbol('id'); uniqueId === anotherId; // false,每个 symbol 都是唯一的 ```
bigint:表示大于 `2^53 - 1` 的整数,适用于金融、科学计算等需要大数处理的场景:
```typescript const hugeNumber: bigint = 9007199254740991n; // 注意末尾的 n const factorial: bigint = 20n!; // 2432902008176640000n ```
其他重要类型
数组(Array):两种等效的声明方式:
```typescript const scores: number[] = [98, 85, 92]; const names: Array<string> = ["Alice", "Bob"]; // 泛型语法 ```
元组(Tuple):固定长度、已知类型的数组:
```typescript const point: [number, number] = [10, 20]; const entry: [string, number] = ["age", 25]; ```
元组允许你精确描述"恰好两个元素,第一个是字符串,第二个是数字"这样的数据结构。
枚举(Enum):为一组相关值创建命名集合:
```typescript enum Direction { Up, Down, Left, Right }
const move: Direction = Direction.Up; // 值为 0 ```
any 和 unknown:当需要绕过类型检查时的特殊类型:
```typescript let flexible: any = 42; flexible = "string"; // 允许 flexible.toUpperCase(); // 允许,但不安全
let uncertain: unknown = 42; uncertain.toUpperCase(); // ❌ 错误:需要类型守卫 ```
最佳实践是尽量避免 `any`,使用 `unknown` 作为"类型未知"的类型安全替代——`unknown` 要求在使用前进行类型检查。
void 和 never:表示函数返回值的特殊类型:
```typescript function logMessage(message: string): void { console.log(message); // 不返回任何值 }
function throwError(message: string): never { throw new Error(message); // 永远不会返回 } ```
---
1.3 类型注解与类型推断:显式与隐式的权衡
TypeScript 提供了两种确定变量类型的方式:开发者显式声明的类型注解(Type Annotation),以及编译器自动推导的类型推断(Type Inference)。
类型注解:显式声明类型
类型注解使用 `: Type` 语法紧跟在变量、函数参数或返回值之后:
```typescript let username: string = "Alice"; let age: number = 30; let isSubscribed: boolean = true;
function greet(name: string): string { return `Hello, ${name}!`; } ```
类型注解的优势在于明确性和文档性。当你看到 `let username: string` 时,无需运行代码就知道这个变量应该存储文本内容。当团队成员阅读你的代码时,类型注解本身就是一种说明文档。
类型推断:编译器自动推导
大多数情况下,TypeScript 能够根据上下文自动推导类型:
```typescript let username = "Alice"; // TypeScript 推断为 string let age = 30; // TypeScript 推断为 number let isSubscribed = true; // TypeScript 推断为 boolean
function greet(name) { return `Hello, ${name}!`; // TypeScript 推断返回值为 string } ```
在这些情况下,类型注解是可选的——TypeScript 会根据赋值的值推导出正确的类型。
何时使用类型注解
尽管类型推断很智能,以下场景仍建议使用类型注解:
变量声明时未初始化:
```typescript let username: string; // 如果不注解,类型为 any username = "Alice"; // 后续赋值时才确定类型 ```
函数参数和返回值:明确函数的输入输出契约:
```typescript function add(a: number, b: number): number { return a + b; } ```
导出公共 API:类型注解成为库的公共接口:
```typescript // library.ts export function processData(input: string[]): Record<string, number> { // ... } ```
复杂类型结构:提高代码可读性:
```typescript type User = { id: number; name: string; email: string; createdAt: Date; };
const users: User[] = []; ```
类型推断的优势
在局部变量、函数内部逻辑等场景下,类型推断能减少代码冗余:
```typescript // 使用类型注解——冗长 const data: number[] = [1, 2, 3, 4, 5]; const sum: number = data.reduce((acc: number, val: number): number => acc + val, 0);
// 使用类型推断——简洁 const data = [1, 2, 3, 4, 5]; const sum = data.reduce((acc, val) => acc + val, 0); ```
TypeScript 能理解 `reduce` 的类型参数,因此无需显式注解。这让代码更简洁,同时保持了类型安全。
最佳实践原则
对于初学者,建议采用"防御性注解"策略:如果你对某个类型不确定,就添加注解。随着经验积累,你会逐渐形成直觉,知道何时信任类型推断、何时需要显式声明。一个实用的原则是:类型注解的受众首先是其他开发者(包括未来的自己),其次才是编译器。 如果某处类型不明显,注解能大幅提升代码可读性。
---
1.4 编译时检查与运行时检查的边界
理解 TypeScript 类型检查的边界——它能做什么、不能做什么——是有效使用该语言的关键。
TypeScript 的类型检查发生在编译阶段
当你运行 `tsc`(TypeScript 编译器)时,代码会经历以下阶段:
```mermaid graph LR A[源代码 .ts] --> B[语法解析] B --> C[类型检查] C --> D{类型正确?} D -->|是| E[生成 JavaScript] D -->|否| F[编译错误] E --> G[输出 .js] ```
在这个流程中,类型检查发生在第三阶段。如果类型检查通过,TypeScript 生成等效的 JavaScript 代码;如果发现类型错误,编译失败,不生成任何输出。
这意味着 TypeScript 能够捕获以下错误:
```typescript function processUser(user: { name: string; age: number }) { return user.name.toUpperCase() + " is " + user.age; }
// 错误调用 processUser({ name: "Alice" }); // ❌ 缺少 age 属性 processUser({ name: "Alice", age: "30" }); // ❌ age 应该是 number processUser({ name: 123, age: 30 }); // ❌ name 应该是 string processUser(null); // ❌ 不能是 null ```
TypeScript 无法检查的边界
然而,TypeScript 的类型系统在编译后就消失了。生成的 JavaScript 代码完全移除了类型信息——这是一项设计决策,确保 TypeScript 能够与任何 JavaScript 运行时兼容。
这导致 TypeScript 无法在以下场景提供保护:
运行时数据验证:
```typescript // TypeScript 允许这段代码编译通过 interface User { name: string; age: number; }
const input = JSON.parse('{"name": "Alice", "age": "thirty"}'); const user: User = input; // 编译时:input 被推断为 any // 运行时:user.age 是字符串 "thirty" ```
JSON 解析的结果来自外部数据,TypeScript 只能将其标记为 `any`(或你使用 `unknown` 要求手动类型守卫)。实际的数据验证需要在运行时进行。
类型断言的滥用:
```typescript const value: u