TypeScript 泛型
1. 泛型的引入
在开发过程中,我们经常会遇到需要编写功能相同、但处理的数据类型不同的组件(如函数或类)。比如下面的队列:
class QueueOfNumbers {
private queue: number[] = [];
public push(value: number): void {
this.queue.push(value);
}
public pop(): number | undefined {
return this.queue.shift();
}
}
如果我们需要一个处理字符串的队列,就不得不再写一个新的实现。这非常繁琐。一个简单的想法是使用 any 类型:
class UnsafeQueue {
private queue: any[] = [];
public push(value: any): void {
this.queue.push(value);
}
public pop(): any {
return this.queue.shift();
}
}
const queue = new UnsafeQueue();
queue.push(1);
queue.push("hello"); // 不会报错,但可能不是我们想要的行为
const first = queue.pop(); // first 的类型是 any
// console.log(first.toFixed(2)); // 运行时可能报错,因为 first 可能是 "hello"
但是 any 是以牺牲类型安全为代价。我们失去了 TypeScript 提供的最大优势——在编译时发现类型错误。这使得 bug 很容易潜入到运行时。
泛型正是为了解决这个问题而设计的,它让我们能够编写既可重用又类型安全的代码。
TypeScript 中的所有类型信息(包括泛型)在编译后都会被擦除,最终生成的是纯粹的、没有类型的 JavaScript 代码。泛型只是在开发阶段保护你的工具,它不是 JavaScript 运行时的特性。
2. 泛型
基本概念与使用
泛型(Generics)允许我们定义一个“类型占位符”,在编写代码时不预先确定具体类型,而是在使用时再指定类型。
可以把泛型想象成一个函数参数,但它传递的不是值,而是类型。通过这个“类型参数”,我们可以创建出能够适应多种数据类型的组件。
使用泛型重写的前面的队列如下:
class Queue<T> {
private queue: T[] = [];
public push(value: T): void {
this.queue.push(value);
}
public pop(): T | undefined {
return this.queue.shift();
}
}
实例化泛型类时,我们需要像传递函数参数一样,在尖括号里传入一个具体的类型:
// 创建一个只能存放 number 的队列
const numberQueue = new Queue<number>();
numberQueue.push(10);
numberQueue.push(20);
// numberQueue.push("hello"); // 编译错误!类型 'string' 的参数不能赋给类型 'number' 的参数。
const num = numberQueue.pop(); // num 的类型被正确推断为 number | undefined
console.log(num?.toFixed(2)); // "10.00"
// 创建一个只能存放 string 的队列
const stringQueue = new Queue<string>();
stringQueue.push("world");
泛型函数
泛型函数可以根据传入参数的类型,动态地确定返回值的类型。
// 一个简单的 identity 函数,输入什么类型,就返回什么类型
function identity<T>(arg: T): T {
return arg;
}
let output = identity<string>("myString"); // output 的类型是 'string'
let inferredOutput = identity(123); // TS 会自动推断类型,inferredOutput 的类型是 'number'
泛型接口
泛型接口常用于定义灵活的数据结构,例如 API 的响应格式。
// 定义一个通用的 API 响应结构
interface ApiResponse<T> {
status: "success" | "error";
data: T;
message?: string;
}
interface User {
id: number;
name: string;
}
// 获取用户信息的 API 响应
const userResponse: ApiResponse<User> = {
status: "success",
data: { id: 1, name: "Alice" },
};
// 获取产品列表的 API 响应
const productsResponse: ApiResponse<string[]> = {
status: "success",
data: ["Laptop", "Mouse"],
};
泛型类型别名
类型别名也可以使用泛型,来创建可重用的类型。
type Nullable<T> = T | null;
let name: Nullable<string> = "Alice";
name = null; // 合法
let age: Nullable<number> = 25;
它也可以用来包装或组合现有类型。
type WithTimestamp<T> = T & {
createdAt: Date;
updatedAt: Date;
};
type Product = {
name: string;
price: number;
};
// ProductWithTimestamp 就是一个包含了 Product
// 所有属性,并额外拥有时间戳属性的新类型
type ProductWithTimestamp = WithTimestamp<Product>;
const laptop: ProductWithTimestamp = {
name: "Laptop Pro",
price: 1999,
createdAt: new Date(),
updatedAt: new Date(),
};
泛型作用域
- 当我们希望整个类的多个方法或属性都围绕着同一个泛型类型
T工作时,在类级别声明是合理的。这使得T在整个类定义中都是可见的。 - 如果只有一两个方法需要泛型,而整个类并不需要,那么更好的方式是将泛型声明在方法上。这可以简化类的使用,并将泛型复杂性限制在需要它的特定方法内部。
3. 泛型约束
有时,我们希望泛型类型 T 必须具备某些特定的属性。例如,一个函数想要访问参数的 .length 属性,但编译器无法保证任意类型 T 都有这个属性。这时,就需要使用泛型约束。
使用 extends 添加约束
我们可以通过 extends 关键字来要求泛型类型 T 必须符合某个接口或类型。
interface Lengthwise {
length: number;
}
// T 必须是拥有 length: number 属性的类型
function logLength<T extends Lengthwise>(arg: T): T {
console.log(arg.length); // 安全!因为 TS 知道 arg 一定有 .length 属性
return arg;
}
logLength("hello"); // string 有 length 属性
logLength([1, 2, 3]); // array 有 length 属性
// logLength(123); // 编译错误!类型 'number' 不满足约束 'Lengthwise'。
使用 keyof 约束键
keyof 操作符可以获取一个类型的所有键,并返回一个联合类型。当与泛型结合时,它可以用来确保函数只能访问对象上存在的属性。
// 一个安全地获取对象属性的函数
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
let person = { name: "Bob", age: 30 };
const personName = getProperty(person, "name"); // 正确,personName 的类型是 string
const personAge = getProperty(person, "age"); // 正确,personAge 的类型是 number
// const personGender = getProperty(person, "gender");
// 编译错误!类型“"gender"”的参数不能赋给类型“"name" | "age"”的参数。
这个模式非常强大,它在 obj 和 key 之间建立了类型安全的关联,杜绝了访问不存在属性的运行时错误。
泛型约束一般原则
一般来说,要把泛型约束为接口:
- 更灵活:约束到接口意味着任何实现了这个接口的类都可以被使用,无论这些类之间有没有继承关系。如 WebStream 和 DiskStream 可能来自完全不同的代码库,但只要它们都 implements IStream,就可以被 Data 类接受。
- 避免继承限制:如果我们将泛型约束为一个具体的基类(例如
<T extends BaseStreamClass>),那么我们就只能使用这个基类及其子类。这大大限制了代码的适用范围,不符合“面向接口编程”的原则。