第五章:解锁类型系统的高级魔法 - 高级泛型

第五章:解锁类型系统的高级魔法 - 高级泛型

摘要: 欢迎来到第五章。在本章,我们的思维将再次跃迁——从“使用类型”到“编程 类型”。我们将直面 Todo 应用在功能迭代(如“部分更新”)中遇到的新瓶颈,并引入 TypeScript 的高级武器库:映射类型条件类型。您将学会如何像炼金术士一样,对现有类型进行裁剪 (Pick/Omit)、转换 (Partial/Required) 和逻辑判断 (extends/infer),创造出全新的、精确的类型。这不仅是技巧的学习,更是理解 Vue/React 等现代框架背后“类型魔法”的关键一步。

注意: 接下来的章节难度会稍有提高,我还是会尽量的秉承我们的讲解风格,但能否听懂这章节以及听懂这章节的用处在乎你如果是一个追求高层次的 Typescript 学习者,还是你只是想在项目中使用 Typescript ,那么这就是一道分水岭,如果你认为后续的章节太难,而又想过度到 Vue3 的话,没问题,后续的章节对您的帮助都不会太大


5.1. 痛点呈现:如何优雅地处理“部分更新”

随着我们的 Todo 应用变得越来越真实,一个核心需求出现了:更新一个已存在的待办事项。让我们看看在当前知识体系下,实现这个功能会遇到什么尴尬的局面。

第一步:在我们的 types.ts 中添加 AppState

为了代码的清晰,我们将整个应用的状态也模型化。

文件路径: src/types.ts

1
2
3
4
5
6
// 确保存在如下的状态模型
export type AppState = {
todos: Todo[];
currentUser: User[] | null; // 用户可能存在,也可能未登录
currentFilter: Filter;
}``

第二步:在 main.ts 中添加 updateTodo 函数的“天真”实现

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Todo, AppState, Filter } from './types'; // 引入 AppState

// ... state 定义 (请确保 state 符合 AppState 类型)
const state: AppState = {
// ...
};

// ... 其他函数 函数 ...

// “天真”的 updateTodo 函数
function updateTodo(id: number, updatedTodo: Todo): void {
const todoToUpdate = state.todos.find(todo => todo.id === id);
if (todoToUpdate) {
// 通过传入进来的新对象合并我们的旧对象
Object.assign(todoToUpdate, updatedTodo);
}
}

第三步:暴露痛点

现在,假设我们只想将一个 Todo 的状态切换为“已完成”,我们该如何调用 updateTodo

1
2
3
4
5
6
7
8
9
10
11
12
// 场景:只想把 id 为 2 的 todo 标记为完成
const todoToUpdate = findItemById(state.todos, 2);

if (todoToUpdate) {
// 痛点来了!
// 为了只修改 `completed` 属性,我们却被迫传入一个完整的 Todo 对象,
// 甚至不得不把旧的 `text` 和 `id` 也带上。
updateTodo(2, {
...todoToUpdate,
completed: true
});
}

核心问题: 函数的类型签名 (updatedTodo: Todo) 过于严格,缺乏灵活性。它要求我们为了修改一小部分数据,而兴师动众地提供一个完整的数据结构。这在工程实践中是繁琐且低效的。


5.2. 映射类型:类型的“批量转换”

为了解决上述痛点,TypeScript 提供了一种强大的元编程能力——映射类型。它允许我们基于一个现有类型,通过某种规则“批量转换”出另一个新类型。

5.2.1. Partial<T>: 完美的更新载荷 (Payload)

Partial<T> 是 TypeScript 内置的一个映射类型,它的作用是将类型 T 的所有属性都变为 可选的

第四步:使用 Partial<T> 重构 updateTodo

文件路径: src/main.ts (修改 updateTodo 函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ... 其他代码 ...

// 重构后的 updateTodo 函数
// 我们将第二个参数的类型从 Todo 改为 Partial <Todo>
function updateTodo(id: number, payload: Partial<Todo>): void {
const todoToUpdate = state.todos.find(todo => todo.id === id);
if (todoToUpdate) {
// payload 中有什么属性,就更新什么属性
Object.assign(todoToUpdate, payload);
console.log(`新更新的state.todos ${JSON.stringify(state.todos)}`);
}
}

// 现在,调用变得无比优雅和精确
updateTodo(2, { completed: true }); // 只想更新 completed,就只传 completed
updateTodo(1, { text: "学习 TypeScript 高级类型" }); // 只更新 text

思维转变: Partial<T> 让我们学会了创建专门用于“更新”场景的类型。我们不再传递完整的数据实体,而是传递一个描述“变化”的、类型安全的 载荷 (Payload)。这是现代状态管理(如 Redux/Pinia)思想的基石。


5.2.2. 核心基石:深入理解 keyof 操作符

痛点: 在我们能随心所欲地“裁剪”类型之前,我们必须先学会如何获取一个类型的所有“钥匙”——也就是它的所有属性名。

解决方案: keyof 操作符正是为此而生。它接收一个对象类型,并返回一个由该对象所有属性名组成的 字符串字面量联合类型

1
2
3
4
5
6
7
8
9
10
import { Todo } from './types';

// 使用 keyof 获取 Todo 接口的所有键
type TodoKeys = keyof Todo;

// 鼠标悬停在 TodoKeys 上,你会看到它的类型是:
// "id" | "text" | "completed" | "dueDate"

let key: TodoKeys = "text"; // 正确
// key = "description"; // 错误: "description" 不在 TodoKeys 联合类型中

keyof 是理解 PickOmit 的前置钥匙。K extends keyof T 这种泛型约束,正是利用 keyof 来保证我们只能对一个类型已存在的属性进行操作。


5.2.3. Pick<T, K>Omit<T, K>:类型的“裁剪”

除了 Partial,还有两个极其常用的映射类型,用于创建现有类型的子集。

  • Pick<Type, Keys>: 从 Type挑选Keys 联合类型中指定的属性。
  • Omit<Type, Keys>: 从 Type忽略Keys 联合类型中指定的属性。

第五步:在我们的 Todo 应用中实践

场景: 假设我们有一个“预览”组件,它只需要显示 Todo 的 textcompleted 状态,不需要 iddueDate

文件路径: src/types.ts

1
2
3
4
// ... 其他类型 ...

// 使用 Pick 创建一个只包含 text 和 completed 的“预览”类型
export type TodoPreview = Pick<Todo, "text" | "completed">;

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
function renderTodoPreview(preview: TodoPreview): void {
console.log(`[预览] ${preview.text} - ${preview.completed ? '已完成' : '未完成'}`);
// 这一行就会编译错误了
// console.log(preview.id);
}

const todo = findItemById(state.todos, 1);
if (todo) {
// preview 对象上没有 id 属性,访问会导致编译错误
// 但是我没呢 findItemById 是返回的一个完整的 todo 对象,所以他是可以访问 id 属性的
renderTodoPreview(todo);
}

PickOmit 是前端开发中处理组件 Props 的利器。它们能帮助我们精确地控制一个组件应该接收哪些数据,不多也不少,从而实现组件之间清晰的边界和依赖关系。


5.3. 条件类型:类型世界的 if-else

如果说映射类型是“批量转换”,那么条件类型就是“逻辑判断”。它允许 TypeScript 根据一个类型是否满足某个条件,来决定最终应用哪个类型。

5.3.1. extends 关键字的妙用

条件类型的语法非常像 JavaScript 的三元运算符:

SomeType extends OtherType ? TrueType : FalseType;

这里的 extends 不再是类的继承,而是类型系统中的一个逻辑判断,意为“SomeType 是否可以赋值给 OtherType”。

场景: 我们需要一个 process 函数,如果传入的是 string,就返回 string;如果传入的是 number,就返回 number。但如果传入的是 nullundefined,我们希望返回 never 类型,让这种调用在类型层面就变得“不可能”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 定义一个条件类型
type Processable = string | number;
type ProcessResult<T> = T extends Processable ? T : never;

function process<T>(value: T): ProcessResult<T> {
if (typeof value === 'string' || typeof value === 'number') {
return value as ProcessResult<T>;
}
throw new Error("Invalid value");
}

let result1 = process("hello"); // result1 的类型是 string
let result2 = process(123); // result2 的类型是 number
// let result3 = process(true); // 编译错误!因为 ProcessResult <boolean> 是 never

5.4. 实践:掌握核心内置函数工具类型

痛点: 在大型项目中,我们经常需要编写一些工具函数来处理或包装其他函数(例如,添加日志、缓存等)。在 JS 中,我们无法静态地知道被包装函数的参数和返回值类型,只能依赖 any

解决方案: TypeScript 提供了 Parameters<T>ReturnType<T> 这两个强大的工具类型,它们都基于 infer 实现,可以精准地“捕获”任何函数的签名。

5.4.1. Parameters<T>: 获取函数参数类型

Parameters<T> 接收一个函数类型 T,并返回一个由该函数所有参数类型组成的 元组类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function addTodo(text: string, dueDate?: Date): number {
const newTodo: Todo = {
id: Date.now(),
text,
completed: false,
dueDate
};
state.todos.push(newTodo);
return newTodo.id;
}

// 捕获 addTodo 函数的参数类型
type AddTodoParams = Parameters<typeof addTodo>;
// AddTodoParams 的类型是: [text: string, dueDate?: Date | undefined]

5.4.2. ReturnType<T>: 获取函数返回类型

ReturnType<T> 接收一个函数类型 T,并返回该函数的返回值类型。

1
2
3
// 捕获 addTodo 函数的返回类型
type AddTodoReturn = ReturnType<typeof addTodo>;
// AddTodoReturn 的类型是: number

5.4.3. 实践:创建一个类型安全的 logFunctionCall 工具

现在,我们将这两个工具类型组合起来,创建一个通用的、完全类型安全的函数日志记录器。

文件路径: src/utils.ts

1
2
3
4
5
6
7
8
9
10
11
12
// F 必须是一个函数类型
export function logFunctionCall<F extends (...args: any[]) => any>(
fn: F,
...args: Parameters<F> // ...args 的类型被精确地定义为 F 的参数类型
): ReturnType<F> { // 返回值类型被精确地定义为 F 的返回类型

console.log(`${fn.name} 被调用,参数:`, ...args);
const result = fn(...args);
console.log(`${fn.name} 返回:`, result);

return result;
}

main.ts 中使用它:

1
2
3
4
5
6
7
8
import { logFunctionCall } from './utils';
// ...

// 使用 logFunctionCall 来包装 addTodo
const newId = logFunctionCall(addTodo, "完成第四章笔记");

// 尝试使用错误的参数类型,会立刻得到编译时错误!
// logFunctionCall(addTodo, 123); // 错误: 类型“number”的参数不能赋给类型“string”的参数。

5.5 本章小结

高级类型核心价值在我们 Todo 应用中的实践
Partial<T>将所有属性变为可选(关键) 优雅地实现了 updateTodo 函数,使其能接收“部分更新”的数据。
keyof获取类型的所有键是理解 Pick/Omit 的基础,用于创建类型安全的泛型约束。
Pick/Omit从类型中挑选/排除属性创建了一个精确的 TodoPreview 类型,用于只需部分数据的场景。
Parameters<T>捕获函数参数类型元组结合 ReturnType 创建了类型安全的 logFunctionCall 通用工具函数。
ReturnType<T>捕获函数返回值类型同上,保证了包装后函数的返回值类型与原函数完全一致。