日常代码中的 TypeScript 泛型:实用指南
你正在编写一个可以处理任何类型的函数,但不想失去类型安全性。你可以使用 any,但这违背了 TypeScript 的初衷。泛型允许你编写可处理多种类型的代码,同时保留类型信息。本指南将向你展示如何在日常代码中使用泛型,而不会把事情搞得太复杂。
什么是泛型?
泛型是一种创建可重用组件的方式,这些组件可以处理多种类型,而不是单一类型。它们允许你捕获输入的类型,并在输出中使用它,从而在不牺牲灵活性的情况下确保类型安全。
把泛型想象成类型参数。就像函数接受值参数一样,泛型函数接受类型参数。例如,恒等函数:
function identity<T>(arg: T): T {
return arg;
}
这里,T 是一个类型参数。当你调用 identity<string>("hello") 时,TypeScript 推断出 T 是 string,返回类型也是 string。如果你传入一个数字,它返回一个数字。这比使用 any 更精确。
为什么不直接使用 any?
使用 any 会禁用类型检查。你会失去自动补全、重构支持,编译器也无法捕获错误。泛型让类型信息在代码中流动,因此你既能获得灵活性,又能保证安全。
实践中的泛型函数
让我们看一个常见场景:一个返回数组第一个元素的函数。
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
没有泛型时,你可能会写 function firstElement(arr: any[]): any,这会丢失类型。使用泛型,如果你传入一个 string[],返回类型就是 string | undefined。
你也可以使用多个类型参数。例如,一个使用回调将数组映射为新数组的函数:
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
这本质上就是 TypeScript 标准库中 Array.prototype.map 的类型定义方式。
泛型接口和类型别名
泛型不仅限于函数。你可以创建泛型接口和类型别名来定义可重用的数据结构。
interface Box<T> {
value: T;
}
const stringBox: Box<string> = { value: "hello" };
const numberBox: Box<number> = { value: 42 };
这对于 API 响应、状态管理或任何容器类型都很有用。
你还可以提供默认类型:
interface ApiResponse<T = unknown> {
data: T;
status: number;
}
现在,不提供类型参数的 ApiResponse 默认为 unknown。
泛型约束
有时你想限制泛型可以使用的类型。你可以使用 extends 关键字来添加约束。
function getLength<T extends { length: number }>(arg: T): number {
return arg.length;
}
现在 T 必须具有 length 属性。你可以调用 getLength("hello") 或 getLength([1,2,3]),但不能调用 getLength(42)。
当处理共享共同形状的对象时,约束非常强大。
在类中使用泛型
类也可以是泛型的。一个常见的例子是泛型集合:
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
const stack = new Stack<number>();
stack.push(1);
stack.push(2);
const popped = stack.pop(); // number | undefined
这确保了只能将数字推入数字栈。
实际示例:类型安全的事件发射器
让我们构建一个简单的事件发射器,它使用泛型将事件名称映射到负载类型。
type EventMap = Record<string, any>;
class EventEmitter<T extends EventMap> {
private listeners: { [K in keyof T]?: Array<(payload: T[K]) => void> } = {};
on<K extends keyof T>(event: K, listener: (payload: T[K]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
this.listeners[event]?.forEach(listener => listener(payload));
}
}
// Usage
interface MyEvents {
login: { userId: string };
logout: { userId: string };
}
const emitter = new EventEmitter<MyEvents>();
emitter.on('login', (payload) => {
console.log(payload.userId); // string
});
emitter.emit('login', { userId: '123' }); // OK
// emitter.emit('login', { userId: 123 }); // Error
这种模式为事件名称和负载提供了完整的类型安全,减少了运行时错误。
常见陷阱和最佳实践
- 不要过度使用泛型。 如果函数只适用于特定类型,就不要将其泛型化。
- 使用描述性的类型参数名称。
T对于简单情况没问题,但TItem或TResponse可能更清晰。 - 优先使用约束以避免运行时错误。 如果你需要某个属性,就添加一个约束。
- 利用类型推断。 通常你不需要显式传递类型参数;TypeScript 可以推断它们。
比较:泛型 vs. any vs. 联合类型
| 方法 | 类型安全 | 灵活性 | 用例 |
|---|---|---|---|
| 泛型 | 高 | 高 | 保留类型的可重用组件 |
any |
无 | 高 | 快速原型设计、迁移 JS |
| 联合类型 | 中 | 中 | 已知类型的有限集合 |
泛型在灵活性和安全性之间取得了平衡,使其成为库和共享工具的理想选择。
常见问题
什么时候应该使用泛型而不是联合类型?
当输入和输出之间的类型关系很重要时,使用泛型。联合类型适用于值可以是固定类型集合中的一种,但具体类型不需要在操作中保留的情况。
我可以在箭头函数中使用泛型吗?
可以,但在 .tsx 文件中语法可能比较棘手。你可以编写 const identity = <T,>(arg: T): T => arg;,并加上尾随逗号以避免 JSX 歧义。
如何将泛型约束为仅接受对象?
使用 T extends object 或 T extends Record<string, unknown> 来确保 T 是对象类型。
泛型是 TypeScript 类型系统的基石。通过在日常代码中使用它们,你可以编写既灵活又安全的函数、接口和类。从简单的泛型函数开始,然后根据需要逐渐引入约束和更复杂的模式。如需更多开发者工具,请查看我们的 JSON Formatter,快速验证和格式化你的 JSON 数据。