MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

TypeScript泛型基础入门与应用示例

2023-11-073.2k 阅读

什么是 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 可以看作是一个类型占位符。在函数定义时,我们没有指定 ab 的具体类型,而是使用了 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> 表示函数有两个泛型参数 TU,分别用于表示 ab 的类型。

泛型接口

定义泛型接口

泛型接口是指接口中使用了泛型类型参数。通过泛型接口,我们可以定义一组相关的类型,这些类型的具体类型在使用接口时才确定。例如,我们定义一个简单的泛型接口 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 属性类型为 Tvalue 属性类型为 U。在创建 pair1pair2 时,我们分别指定了 TU 的具体类型。

泛型接口与函数

泛型接口常常用于定义函数类型。例如,我们可以定义一个泛型接口来表示一个获取对象属性值的函数类型:

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 属性类型为 TgetValue 方法的返回值类型也为 T。在创建 numberBoxstringBox 实例时,分别指定了 Tnumberstring 类型。

泛型类的继承与实现

泛型类在继承和实现接口时,也可以处理泛型相关的情况。例如,我们定义一个泛型接口 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 必须同时是 TU 类型对象的键类型。这样就确保了在函数调用时,传入的键在两个对象中都是有效的属性,并且可以正确地返回第二个对象中对应键的值。

泛型的高级应用

条件类型

条件类型是 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 的判断结果选择 stringnumber 类型。

映射类型

映射类型允许我们通过对现有类型的属性进行映射来创建新类型。这在处理对象类型时非常有用。例如,我们定义一个 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 类型。当 Tstring | 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 泛型对于提升我们的编程能力和开发效率具有重要意义。