TypeScript泛型基础入门与应用示例
什么是 TypeScript 泛型
在深入探讨 TypeScript 泛型的应用之前,我们首先要明确泛型的概念。简单来说,泛型是一种在定义函数、接口或类的时候,不预先指定具体的类型,而是在使用的时候再指定类型的特性。这使得我们编写的代码能够适用于多种数据类型,提高代码的复用性和灵活性。
在传统的编程语言中,我们通常为每种数据类型编写单独的函数或类。例如,假设有一个交换两个变量值的函数,如果我们要处理 number
类型和 string
类型的数据,可能需要编写两个不同的函数:
function swapNumbers(a: number, b: number): [number, number] {
return [b, a];
}
function swapStrings(a: string, b: string): [string, string] {
return [b, a];
}
可以看到,这两个函数的逻辑几乎完全一样,只是参数和返回值的类型不同。这就导致了代码的重复。而使用泛型,我们可以编写一个通用的 swap
函数,适用于任何类型:
function swap<T>(a: T, b: T): [T, T] {
return [b, a];
}
// 使用泛型函数
let result1 = swap<number>(1, 2);
let result2 = swap<string>("hello", "world");
在上述代码中,<T>
就是泛型参数,T
可以看作是一个类型占位符。在函数定义时,我们没有指定 a
和 b
的具体类型,而是使用了 T
。在调用函数时,通过 <number>
和 <string>
来指定 T
的实际类型,这样函数就可以适用于不同的数据类型,而不需要为每种类型单独编写函数。
泛型函数
泛型函数的基本定义
泛型函数的定义由函数声明和泛型参数列表组成。泛型参数列表用尖括号 <>
括起来,紧跟在函数名称之后。例如,我们定义一个获取数组中第一个元素的泛型函数:
function getFirst<T>(arr: T[]): T | undefined {
return arr.length > 0? arr[0] : undefined;
}
let numbers = [1, 2, 3];
let firstNumber = getFirst(numbers);
let strings = ["a", "b", "c"];
let firstString = getFirst(strings);
在这个例子中,<T>
是泛型参数,T[]
表示一个元素类型为 T
的数组。函数返回值类型为 T | undefined
,这意味着如果数组不为空,返回数组的第一个元素(类型为 T
),否则返回 undefined
。
类型推断
TypeScript 强大的类型推断机制使得我们在调用泛型函数时,常常可以省略显式指定泛型参数的类型。例如,对于上述的 getFirst
函数,我们可以这样调用:
let numbers = [1, 2, 3];
let firstNumber = getFirst(numbers); // 这里 TypeScript 会推断 T 为 number 类型
let strings = ["a", "b", "c"];
let firstString = getFirst(strings); // 这里 TypeScript 会推断 T 为 string 类型
TypeScript 会根据传递给函数的参数类型来推断泛型参数 T
的具体类型。这种类型推断极大地提高了代码的简洁性和可读性。
多个泛型参数
一个泛型函数可以有多个泛型参数。例如,我们定义一个比较两个值是否相等的泛型函数,需要考虑两个值可能是不同类型的情况:
function isEqual<T, U>(a: T, b: U): boolean {
return a === b;
}
let result1 = isEqual(1, 1); // true
let result2 = isEqual("hello", "world"); // false
let result3 = isEqual(1, "1"); // false
在这个例子中,<T, U>
表示函数有两个泛型参数 T
和 U
,分别用于表示 a
和 b
的类型。
泛型接口
定义泛型接口
泛型接口是指接口中使用了泛型类型参数。通过泛型接口,我们可以定义一组相关的类型,这些类型的具体类型在使用接口时才确定。例如,我们定义一个简单的泛型接口 KeyValuePair
,用于表示键值对:
interface KeyValuePair<T, U> {
key: T;
value: U;
}
let pair1: KeyValuePair<number, string> = { key: 1, value: "one" };
let pair2: KeyValuePair<string, boolean> = { key: "isDone", value: true };
在上述代码中,<T, U>
是泛型参数,KeyValuePair
接口中的 key
属性类型为 T
,value
属性类型为 U
。在创建 pair1
和 pair2
时,我们分别指定了 T
和 U
的具体类型。
泛型接口与函数
泛型接口常常用于定义函数类型。例如,我们可以定义一个泛型接口来表示一个获取对象属性值的函数类型:
interface Getter<T, K extends keyof T> {
(obj: T, key: K): T[K];
}
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
let user = { name: "John", age: 30 };
let getName: Getter<typeof user, "name"> = getProperty;
let name = getName(user, "name");
在这个例子中,Getter
接口定义了一个函数类型,该函数接受一个类型为 T
的对象和一个类型为 K
的键,K
必须是 T
的键类型(通过 K extends keyof T
来约束),函数返回值类型为 T[K]
,即对象 T
中键 K
对应的值的类型。getProperty
函数符合 Getter
接口定义的函数类型,通过 Getter
接口来定义函数类型,可以增强代码的类型安全性和可读性。
泛型类
泛型类的定义
泛型类是指类在定义时使用了泛型类型参数。泛型类可以让我们创建可复用的类,这些类可以处理不同类型的数据。例如,我们定义一个简单的 Box
类,用于包装一个值:
class Box<T> {
private value: T;
constructor(value: T) {
this.value = value;
}
getValue(): T {
return this.value;
}
}
let numberBox = new Box<number>(10);
let stringBox = new Box<string>("hello");
let num = numberBox.getValue();
let str = stringBox.getValue();
在上述代码中,<T>
是泛型参数,Box
类中的 value
属性类型为 T
,getValue
方法的返回值类型也为 T
。在创建 numberBox
和 stringBox
实例时,分别指定了 T
为 number
和 string
类型。
泛型类的继承与实现
泛型类在继承和实现接口时,也可以处理泛型相关的情况。例如,我们定义一个泛型接口 ValueContainer
和一个继承自该接口的泛型类 ValueHolder
:
interface ValueContainer<T> {
getValue(): T;
}
class ValueHolder<T> implements ValueContainer<T> {
private value: T;
constructor(value: T) {
this.value = value;
}
getValue(): T {
return this.value;
}
}
let holder = new ValueHolder<number>(42);
let value = holder.getValue();
在这个例子中,ValueContainer
接口是一个泛型接口,ValueHolder
类实现了该接口,并使用相同的泛型参数 T
。这样,ValueHolder
类就必须实现 ValueContainer
接口中定义的 getValue
方法,并且方法的返回值类型与 T
一致。
泛型约束
为什么需要泛型约束
在某些情况下,我们希望对泛型类型参数进行一定的限制,以确保类型的安全性和正确性。例如,我们有一个获取对象属性值的泛型函数,我们希望确保传入的键确实是对象的属性,这时候就需要使用泛型约束。
简单的泛型约束
我们可以使用 extends
关键字来对泛型类型参数进行约束。例如,我们定义一个函数,该函数接受一个对象和一个键,返回对象中该键对应的值,但是我们要确保键是对象的属性:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
let user = { name: "John", age: 30 };
let name = getProperty(user, "name");
// 下面这行代码会报错,因为 "address" 不是 user 对象的属性
// let address = getProperty(user, "address");
在上述代码中,K extends keyof T
表示 K
必须是 T
类型对象的键类型。这样就保证了在调用 getProperty
函数时,传入的键一定是对象的有效属性,提高了代码的类型安全性。
基于接口的泛型约束
我们还可以基于接口来定义泛型约束。例如,我们定义一个接口 Lengthwise
,表示具有 length
属性的类型,然后定义一个泛型函数,该函数接受一个符合 Lengthwise
接口的对象,并返回其 length
属性值:
interface Lengthwise {
length: number;
}
function getLength<T extends Lengthwise>(arg: T): number {
return arg.length;
}
let str = "hello";
let len1 = getLength(str);
let arr = [1, 2, 3];
let len2 = getLength(arr);
// 下面这行代码会报错,因为 number 类型没有 length 属性
// let num = 10;
// let len3 = getLength(num);
在这个例子中,T extends Lengthwise
约束了泛型参数 T
必须是具有 length
属性的类型。这样,我们就可以在函数内部安全地访问 arg.length
。
多个类型参数之间的约束
有时候,我们需要在多个泛型类型参数之间建立约束关系。例如,我们定义一个函数,该函数接受两个对象和一个键,返回第一个对象中该键对应的值在第二个对象中对应的值:
function getRelatedValue<T, U, K extends keyof T & keyof U>(obj1: T, obj2: U, key: K): U[K] {
return obj2[key];
}
let user1 = { name: "John", age: 30 };
let user2 = { name: "John", email: "john@example.com" };
let email = getRelatedValue(user1, user2, "name");
在上述代码中,K extends keyof T & keyof U
表示 K
必须同时是 T
和 U
类型对象的键类型。这样就确保了在函数调用时,传入的键在两个对象中都是有效的属性,并且可以正确地返回第二个对象中对应键的值。
泛型的高级应用
条件类型
条件类型是 TypeScript 2.8 引入的一种基于条件表达式进行类型选择的类型。条件类型常常与泛型结合使用,以实现更加灵活和智能的类型推导。例如,我们定义一个 If
条件类型,根据条件选择不同的类型:
type If<C extends boolean, T, F> = C extends true? T : F;
type IsString<T> = T extends string? true : false;
type StringOrNumber<T> = If<IsString<T>, string, number>;
type Result1 = StringOrNumber<string>; // string
type Result2 = StringOrNumber<number>; // number
在上述代码中,If
类型接受三个类型参数,C
是一个布尔类型的条件,T
是条件为 true
时选择的类型,F
是条件为 false
时选择的类型。IsString
类型用于判断一个类型是否为 string
类型,StringOrNumber
类型根据 IsString
的判断结果选择 string
或 number
类型。
映射类型
映射类型允许我们通过对现有类型的属性进行映射来创建新类型。这在处理对象类型时非常有用。例如,我们定义一个 Readonly
映射类型,将对象的所有属性变为只读:
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
interface User {
name: string;
age: number;
}
type ReadonlyUser = Readonly<User>;
let readonlyUser: ReadonlyUser = { name: "John", age: 30 };
// 下面这行代码会报错,因为 ReadonlyUser 的属性是只读的
// readonlyUser.age = 31;
在上述代码中,Readonly<T>
类型使用 in
关键字遍历 T
的所有键,并将每个属性变为只读。通过这种方式,我们可以基于现有的类型快速创建具有特定属性修饰的新类型。
分布式条件类型
分布式条件类型是条件类型在泛型中的一种特殊应用。当条件类型作用于泛型类型参数,并且泛型类型参数是联合类型时,会发生分布式行为。例如:
type Exclude<T, U> = T extends U? never : T;
type Result = Exclude<string | number | boolean, number>; // string | boolean
在上述代码中,Exclude
类型用于从 T
类型中排除 U
类型。当 T
是 string | number | boolean
这样的联合类型时,Exclude
类型会对联合类型中的每个成员分别应用条件类型,最终返回排除 number
后的联合类型 string | boolean
。
泛型在实际项目中的应用
在数据存储与操作中的应用
在实际项目中,我们经常需要处理各种数据结构,如数组、对象等。泛型可以让我们编写通用的数据存储和操作函数,提高代码的复用性。例如,我们可以编写一个通用的 DataStore
类,用于存储和获取不同类型的数据:
class DataStore<T> {
private data: T[] = [];
add(item: T): void {
this.data.push(item);
}
get(index: number): T | undefined {
return this.data[index];
}
}
let numberStore = new DataStore<number>();
numberStore.add(1);
numberStore.add(2);
let num = numberStore.get(0);
let stringStore = new DataStore<string>();
stringStore.add("hello");
stringStore.add("world");
let str = stringStore.get(1);
在这个例子中,DataStore
类使用泛型 T
来表示存储的数据类型。通过这种方式,我们可以创建不同类型的数据存储实例,而不需要为每种数据类型单独编写一个数据存储类。
在 API 调用封装中的应用
在进行 API 调用时,我们通常需要处理不同的请求参数和响应数据类型。泛型可以帮助我们封装 API 调用函数,使其具有通用性。例如,我们定义一个 ApiClient
类,用于发送 HTTP 请求并处理响应:
class ApiClient {
async request<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json() as Promise<T>;
}
}
let client = new ApiClient();
interface UserResponse {
name: string;
age: number;
}
async function getUser(): Promise<UserResponse> {
return client.request<UserResponse>('/api/user');
}
在上述代码中,request
方法使用泛型 T
来表示响应数据的类型。在调用 request
方法时,我们可以通过指定泛型参数来明确响应数据的类型,这样可以提高代码的类型安全性,并且在处理不同 API 接口时不需要重复编写相似的请求处理逻辑。
在组件库开发中的应用
在开发组件库时,泛型也是非常重要的工具。例如,在 React 组件库中,我们可以使用泛型来定义组件的属性类型,使其具有更好的通用性。假设我们开发一个简单的 Button
组件:
import React from'react';
interface ButtonProps<T> {
text: string;
onClick: (data: T) => void;
}
const Button = <T>(props: ButtonProps<T>) => {
return <button onClick={() => props.onClick(null as unknown as T)}>{props.text}</button>;
};
interface ClickData {
message: string;
}
const handleClick = (data: ClickData) => {
console.log(data.message);
};
const App = () => {
return <Button<ClickData> text="Click me" onClick={handleClick} />;
};
在这个例子中,ButtonProps
接口使用泛型 T
来表示 onClick
回调函数接受的数据类型。这样,在使用 Button
组件时,我们可以根据实际需求指定 T
的具体类型,使得组件能够适应不同的业务场景。
通过以上对 TypeScript 泛型的基础入门和应用示例的介绍,我们可以看到泛型在提高代码复用性、灵活性和类型安全性方面具有巨大的优势。无论是小型项目还是大型企业级应用,泛型都是 TypeScript 开发者不可或缺的工具。在实际开发中,我们应该充分利用泛型的特性,编写出更加健壮和可维护的代码。
在复杂的项目中,我们可能还会遇到泛型与其他高级 TypeScript 特性(如条件类型、映射类型等)的结合使用。这需要我们不断地实践和积累经验,深入理解 TypeScript 的类型系统,才能更好地发挥泛型的强大功能。同时,在使用泛型时,也要注意保持代码的可读性和可理解性,避免过度复杂的泛型使用导致代码难以维护。总之,掌握 TypeScript 泛型对于提升我们的编程能力和开发效率具有重要意义。