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

TypeScript泛型编程的六大实战模式

2021-03-017.5k 阅读

模式一:函数泛型

在TypeScript中,函数泛型是一种强大的特性,它允许我们在定义函数时使用类型参数,从而使函数能够处理不同类型的数据,同时保持类型安全。

基础函数泛型示例

假设我们要创建一个函数,该函数可以接受任意类型的参数并返回该参数。使用函数泛型可以这样实现:

function identity<T>(arg: T): T {
    return arg;
}
let result = identity<string>("Hello, TypeScript!");
console.log(result); 

在上述代码中,<T> 是类型参数,它代表一个未知的类型。arg: T 表示参数 arg 的类型是 T,而函数的返回值类型也是 T。通过在调用 identity 函数时指定 <string>,我们告诉TypeScript这里的 T 就是 string 类型。

多个类型参数

函数泛型并不局限于单个类型参数,我们可以定义多个类型参数。例如,创建一个函数,它接受两个不同类型的参数并返回一个包含这两个参数的数组:

function pair<U, V>(first: U, second: V): [U, V] {
    return [first, second];
}
let pairResult = pair<number, string>(42, "answer");
console.log(pairResult); 

这里定义了两个类型参数 UV,分别用于表示 firstsecond 参数的类型,返回值类型是一个包含 UV 类型元素的元组。

类型参数的约束

有时候,我们希望类型参数满足一定的条件。例如,我们想要创建一个函数,它接受一个数组和一个索引,返回数组中指定索引位置的元素。但我们希望这个数组类型具有 length 属性,以确保可以安全地访问索引。这时可以使用类型参数约束:

function getElement<T extends { length: number }>(arr: T, index: number): T extends any[]? T[number] : never {
    if (index >= 0 && index < arr.length) {
        return arr[index];
    }
    return undefined as never;
}
let numbers = [1, 2, 3];
let element = getElement(numbers, 1);
console.log(element); 

这里 <T extends { length: number }> 表示类型参数 T 必须是一个具有 length 属性的类型。T extends any[]? T[number] : never 用于在 T 是数组类型时返回数组元素类型,否则返回 never 类型。

模式二:泛型接口

泛型接口允许我们在接口定义中使用类型参数,从而创建可复用的接口结构,适用于不同类型的数据。

简单泛型接口示例

定义一个泛型接口 Box,它有一个 value 属性,类型由类型参数决定:

interface Box<T> {
    value: T;
}
let numberBox: Box<number> = { value: 42 };
let stringBox: Box<string> = { value: "Hello" };

在上述代码中,Box<T> 是一个泛型接口,T 是类型参数。通过 Box<number>Box<string>,我们创建了针对 numberstring 类型的具体接口实例。

泛型接口与函数类型

泛型接口也可以用于定义函数类型。例如,定义一个泛型接口 Mapper,它描述了一个接受一个参数并返回另一个类型值的函数:

interface Mapper<T, U> {
    (arg: T): U;
}
let stringToNumberMapper: Mapper<string, number> = (str) => parseInt(str);
let resultMapper = stringToNumberMapper("42");
console.log(resultMapper); 

这里 Mapper<T, U> 定义了一个函数类型,T 是参数类型,U 是返回值类型。stringToNumberMapper 是一个符合该接口的具体函数。

继承泛型接口

我们可以让一个接口继承另一个泛型接口,并进一步约束类型参数。例如:

interface Animal {
    name: string;
}
interface Dog extends Animal {
    bark(): void;
}
interface Container<T extends Animal> {
    item: T;
}
interface DogContainer extends Container<Dog> {
    playWithDog(): void;
}
let myDog: Dog = { name: "Buddy", bark() { console.log("Woof!"); } };
let dogContainer: DogContainer = { item: myDog, playWithDog() { console.log("Playing with Buddy!"); } };

这里 Container<T> 是一个泛型接口,T 约束为 Animal 类型或其子类型。DogContainer 继承自 Container<Dog>,并添加了 playWithDog 方法。

模式三:泛型类

泛型类允许我们在类的定义中使用类型参数,使类能够处理不同类型的数据,同时保持类型安全。

基础泛型类示例

定义一个简单的泛型类 Stack,用于实现一个栈数据结构:

class Stack<T> {
    private items: T[] = [];
    push(item: T) {
        this.items.push(item);
    }
    pop(): T | undefined {
        return this.items.pop();
    }
}
let numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
let poppedNumber = numberStack.pop();
console.log(poppedNumber); 

在这个 Stack<T> 类中,T 是类型参数,items 数组的类型是 T[]push 方法接受类型为 T 的参数,pop 方法返回类型为 T | undefined

泛型类的继承

泛型类也可以被继承,并且子类可以继续使用或重新定义类型参数。例如:

class Queue<T> {
    private items: T[] = [];
    enqueue(item: T) {
        this.items.push(item);
    }
    dequeue(): T | undefined {
        return this.items.shift();
    }
}
class PriorityQueue<T extends { priority: number }> extends Queue<T> {
    enqueue(item: T) {
        let inserted = false;
        for (let i = 0; i < this.items.length; i++) {
            if (item.priority < this.items[i].priority) {
                this.items.splice(i, 0, item);
                inserted = true;
                break;
            }
        }
        if (!inserted) {
            this.items.push(item);
        }
    }
}
let task1 = { priority: 2, description: "Second task" };
let task2 = { priority: 1, description: "First task" };
let priorityQueue = new PriorityQueue();
priorityQueue.enqueue(task1);
priorityQueue.enqueue(task2);
let dequeuedTask = priorityQueue.dequeue();
console.log(dequeuedTask); 

这里 Queue<T> 是一个泛型类,PriorityQueue<T extends { priority: number }> 继承自 Queue<T>,并对 T 进行了约束,要求 T 类型具有 priority 属性。PriorityQueue 重写了 enqueue 方法以实现按优先级入队。

静态成员与泛型

需要注意的是,泛型类的静态成员不能使用类的类型参数。因为静态成员属于类本身,而不是类的实例,在类被实例化之前,类型参数的值是不确定的。例如:

class StaticGenericClass<T> {
    static someStaticMethod(): T {
        // 这会报错,因为静态方法不能使用类的类型参数T
        return undefined as any;
    }
}

如果需要在静态方法中使用泛型,可以单独为静态方法定义类型参数:

class StaticGenericClass {
    static someStaticMethod<U>(arg: U): U {
        return arg;
    }
}
let staticResult = StaticGenericClass.someStaticMethod<string>("Static result");
console.log(staticResult); 

模式四:泛型约束与条件类型

泛型约束和条件类型是TypeScript中非常强大的功能,它们可以让我们根据类型参数的不同,进行不同的类型推导和处理。

泛型约束回顾

在前面的例子中,我们已经使用过泛型约束。例如,T extends { length: number } 表示类型参数 T 必须具有 length 属性。泛型约束可以确保在泛型代码中能够安全地访问特定的属性或方法。

条件类型基础

条件类型使用 T extends U? X : Y 的语法,它的含义是:如果类型 T 可以赋值给类型 U,则结果为类型 X,否则为类型 Y。例如:

type IsString<T> = T extends string? true : false;
type StringCheck = IsString<string>; 
type NumberCheck = IsString<number>; 

在上述代码中,IsString<T> 是一个条件类型,StringCheck 的值为 true,因为 string 类型可以赋值给 string 类型;NumberCheck 的值为 false,因为 number 类型不能赋值给 string 类型。

条件类型与泛型结合

我们可以将条件类型与泛型结合,实现更复杂的类型推导。例如,定义一个类型 Flatten,它可以将数组类型展开为元素类型,如果不是数组类型则保持不变:

type Flatten<T> = T extends Array<infer U>? U : T;
type StringArrayFlatten = Flatten<string[]>; 
type NumberFlatten = Flatten<number>; 

这里 infer U 用于推断出数组元素的类型。如果 T 是数组类型,Flatten<T> 的结果就是数组元素的类型;否则就是 T 本身。

分布式条件类型

当条件类型作用于泛型类型参数,并且该参数是裸类型参数(没有被其他类型包装)时,会产生分布式条件类型。例如:

type ToArray<T> = T extends any? T[] : never;
type StringToArray = ToArray<string>; 
type UnionToArray = ToArray<string | number>; 

对于 ToArray<string | number>,TypeScript会将其分布式地应用到联合类型的每个成员上,结果为 string[] | number[]

模式五:映射类型

映射类型是TypeScript中一种基于现有类型创建新类型的强大方式,它可以对类型的属性进行逐个转换。

基础映射类型示例

假设我们有一个类型 User

interface User {
    name: string;
    age: number;
    email: string;
}
type ReadonlyUser = {
    readonly [P in keyof User]: User[P];
};
let readonlyUser: ReadonlyUser = { name: "John", age: 30, email: "john@example.com" };
// readonlyUser.name = "Jane"; // 这会报错,因为属性是只读的

ReadonlyUser 类型中,[P in keyof User] 表示遍历 User 类型的所有属性键,readonly User[P] 表示将每个属性变为只读,类型保持不变。

可选属性映射

我们可以将一个类型的所有属性变为可选。例如:

type OptionalUser = {
    [P in keyof User]?: User[P];
};
let optionalUser: OptionalUser = { name: "Jane" }; 

这里通过在属性定义中添加 ?,将 User 类型的所有属性变为可选。

移除特定属性

可以使用映射类型移除某个类型的特定属性。例如,移除 User 类型中的 email 属性:

type UserWithoutEmail = {
    [P in Exclude<keyof User, "email">]: User[P];
};
let userWithoutEmail: UserWithoutEmail = { name: "Bob", age: 25 }; 

Exclude<keyof User, "email"> 用于排除 email 属性键,然后通过映射类型创建一个新类型,只包含剩余的属性。

模式六:高级类型组合

在实际开发中,我们经常需要将多种泛型编程模式组合使用,以实现复杂的类型需求。

结合泛型类、接口与条件类型

例如,我们要创建一个 DataProcessor 类,它可以根据数据的类型进行不同的处理。如果数据是数组,我们对数组元素进行某种操作;如果不是数组,则直接返回数据。

interface Processor<T> {
    process(data: T): T;
}
class DataProcessor<T> {
    private processor: Processor<T>;
    constructor(processor: Processor<T>) {
        this.processor = processor;
    }
    handleData(data: T): T {
        return this.processor.process(data);
    }
}
type ArrayProcessor<T> = T extends Array<infer U>? (value: U) => U : never;
class ArrayDataProcessor<T extends Array<any>> implements Processor<T> {
    private itemProcessor: ArrayProcessor<T>;
    constructor(itemProcessor: ArrayProcessor<T>) {
        this.itemProcessor = itemProcessor;
    }
    process(data: T): T {
        return data.map((item) => this.itemProcessor(item)) as T;
    }
}
class SingleDataProcessor<T> implements Processor<T> {
    process(data: T): T {
        return data;
    }
}
let numberArrayProcessor = new ArrayDataProcessor<number[]>(value => value * 2);
let numberDataProcessor = new DataProcessor<number[]>(numberArrayProcessor);
let resultArray = numberDataProcessor.handleData([1, 2, 3]);
console.log(resultArray); 
let singleDataProcessor = new DataProcessor<string>(new SingleDataProcessor<string>());
let resultSingle = singleDataProcessor.handleData("Hello");
console.log(resultSingle); 

在这段代码中,Processor 接口定义了处理数据的方法。DataProcessor 类接受一个 Processor 实例并调用其 process 方法。ArrayProcessor 是一个条件类型,用于处理数组元素的处理器类型。ArrayDataProcessor 实现了 Processor 接口,专门处理数组数据。SingleDataProcessor 则处理非数组数据。

映射类型与条件类型的结合

再比如,我们有一个类型 Product,它有一些属性,我们希望创建一个新类型,对于字符串类型的属性变为只读,其他类型属性保持不变。

interface Product {
    name: string;
    price: number;
    description: string;
}
type ModifiedProduct = {
    [P in keyof Product]: Product[P] extends string? readonly Product[P] : Product[P];
};
let modifiedProduct: ModifiedProduct = { name: "Widget", price: 10, description: "A useful widget" };
// modifiedProduct.name = "New Widget"; // 这会报错,因为name属性是只读的

这里通过结合映射类型和条件类型,遍历 Product 类型的所有属性,对于字符串类型的属性变为只读,其他类型属性保持不变。

通过这些实战模式的学习和应用,我们能够在TypeScript中充分发挥泛型编程的优势,编写出更加灵活、可复用且类型安全的代码,提升开发效率和代码质量。无论是小型项目还是大型企业级应用,这些模式都能为我们的开发工作带来极大的便利。