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

TypeScript 泛型约束的高级用法

2021-07-017.5k 阅读

泛型约束基础回顾

在深入探讨 TypeScript 泛型约束的高级用法之前,让我们先简单回顾一下泛型约束的基础知识。泛型约束允许我们对泛型类型参数添加限制条件,确保传入的类型满足特定的要求。

例如,假设我们有一个函数,它接收一个数组并返回数组的第一个元素。我们可以使用泛型来使这个函数适用于不同类型的数组,但如果我们只想处理至少有一个元素的数组,就可以使用泛型约束:

function getFirst<T extends any[]>(arr: T): T[0] {
    return arr[0];
}

// 正确使用
let numbers = [1, 2, 3];
let firstNumber = getFirst(numbers);

// 错误使用,会报错,因为空数组不满足约束
let emptyArray: number[] = [];
// let error = getFirst(emptyArray); // 报错:类型“number[]”的参数不能赋给类型“any[] & { length: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 |... 208 more... | 4294967295; }”的参数。

在上述代码中,T extends any[] 表示泛型 T 必须是一个数组类型。这就是泛型约束的基本应用,它确保了函数的输入类型符合我们预期的结构。

基于接口的泛型约束

简单接口约束

  1. 示例一:对象属性约束 在实际开发中,我们经常需要处理具有特定属性的对象。假设我们有一个函数,它接收一个对象,并打印该对象的 name 属性。我们可以通过接口来定义对象的形状,并使用泛型约束确保传入的对象符合这个形状:
interface HasName {
    name: string;
}

function printName<T extends HasName>(obj: T) {
    console.log(obj.name);
}

let person: HasName = { name: 'John' };
printName(person);

// 错误使用,会报错,因为没有 name 属性
let withoutName = { age: 30 };
// printName(withoutName); // 报错:类型“{ age: number; }”的参数不能赋给类型“HasName”的参数。对象字面量只能指定已知属性,并且“name”不在类型“{ age: number; }”中。

在这个例子中,T extends HasName 表示泛型 T 必须是一个具有 name 属性且类型为 string 的对象。这样就通过接口约束了泛型的类型结构。

  1. 示例二:函数接口约束 我们还可以对函数类型进行泛型约束。例如,假设我们有一个函数,它接收另一个函数作为参数,并调用这个函数。我们希望传入的函数具有特定的参数和返回值类型。我们可以定义一个函数接口来进行约束:
interface StringToNumberFunc {
    (str: string): number;
}

function callStringToNumber<T extends StringToNumberFunc>(func: T, str: string) {
    return func(str);
}

function parseStringToNumber(str: string): number {
    return parseInt(str);
}

let result = callStringToNumber(parseStringToNumber, '123');
console.log(result);

// 错误使用,会报错,因为函数参数或返回值类型不匹配
function wrongFunc(str: number): string {
    return str.toString();
}
// let wrongResult = callStringToNumber(wrongFunc, '123'); // 报错:类型“(str: number) => string”的参数不能赋给类型“StringToNumberFunc”的参数。Types of parameters 'str' and 'str' are incompatible. Type 'string' is not assignable to type 'number'.

这里,T extends StringToNumberFunc 确保了传入 callStringToNumber 函数的 func 参数是一个接受 string 类型参数并返回 number 类型的函数。

多重接口约束

有时候,我们可能需要一个泛型同时满足多个接口的约束。例如,假设我们有一个接口 HasAge 表示具有 age 属性的对象,以及前面的 HasName 接口。我们想要一个函数处理既具有 name 又具有 age 属性的对象:

interface HasAge {
    age: number;
}

interface HasName {
    name: string;
}

function printNameAndAge<T extends HasName & HasAge>(obj: T) {
    console.log(`Name: ${obj.name}, Age: ${obj.age}`);
}

let personInfo: HasName & HasAge = { name: 'Jane', age: 25 };
printNameAndAge(personInfo);

// 错误使用,会报错,因为缺少 age 属性
let onlyName: HasName = { name: 'Bob' };
// printNameAndAge(onlyName); // 报错:类型“HasName”的参数不能赋给类型“HasName & HasAge”的参数。类型“HasName”中缺少属性“age”,但类型“HasAge”中需要该属性。

在上述代码中,T extends HasName & HasAge 表示泛型 T 必须同时满足 HasNameHasAge 接口的约束,即对象必须同时具有 nameage 属性。

基于类型别名的泛型约束

简单类型别名约束

类型别名也可以用于定义泛型约束。例如,假设我们定义一个类型别名表示数字或字符串类型,然后我们有一个函数,它接收这种类型的值并打印:

type NumberOrString = number | string;

function printValue<T extends NumberOrString>(value: T) {
    console.log(value);
}

printValue(123);
printValue('Hello');

// 错误使用,会报错,因为 boolean 类型不满足约束
// printValue(true); // 报错:类型“boolean”的参数不能赋给类型“NumberOrString”的参数。类型“boolean”不能分配到类型“number | string”。类型“boolean”不能分配到类型“string”。

这里,T extends NumberOrString 确保了泛型 T 只能是 numberstring 类型。

联合与交叉类型别名约束

  1. 联合类型别名约束 我们可以基于联合类型别名创建更复杂的约束。例如,假设我们有一个类型别名表示具有 id 属性且 id 为数字或字符串的对象,然后有一个函数处理这种对象:
type IdObject = { id: number } | { id: string };

function printId<T extends IdObject>(obj: T) {
    console.log(obj.id);
}

let numberIdObj: { id: number } = { id: 1 };
let stringIdObj: { id: string } = { id: '2' };

printId(numberIdObj);
printId(stringIdObj);

// 错误使用,会报错,因为缺少 id 属性
let withoutId = { name: 'Test' };
// printId(withoutId); // 报错:类型“{ name: string; }”的参数不能赋给类型“IdObject”的参数。对象字面量只能指定已知属性,并且“id”不在类型“{ name: string; }”中。

在这个例子中,T extends IdObject 使得泛型 T 必须是 IdObject 联合类型中的一种,即对象必须具有 id 属性且 id 为数字或字符串。

  1. 交叉类型别名约束 类似地,我们可以使用交叉类型别名进行约束。假设我们有一个类型别名表示既是 HasName 接口又是 HasAge 接口的对象,然后有一个函数处理这种对象:
interface HasName {
    name: string;
}

interface HasAge {
    age: number;
}

type NameAndAge = HasName & HasAge;

function printNameAndAge<T extends NameAndAge>(obj: T) {
    console.log(`Name: ${obj.name}, Age: ${obj.age}`);
}

let person: NameAndAge = { name: 'Alice', age: 30 };
printNameAndAge(person);

// 错误使用,会报错,因为缺少 age 属性
let onlyName: HasName = { name: 'Eve' };
// printNameAndAge(onlyName); // 报错:类型“HasName”的参数不能赋给类型“NameAndAge”的参数。类型“HasName”中缺少属性“age”,但类型“HasAge”中需要该属性。

这里,T extends NameAndAge 确保了泛型 T 必须满足 NameAndAge 交叉类型的约束,即对象同时具有 nameage 属性。

条件类型与泛型约束的结合

条件类型基础

条件类型是 TypeScript 中一种强大的类型运算方式,它允许我们根据类型关系来选择不同的类型。其基本语法为 T extends U? X : Y,表示如果 T 可以赋值给 U,则返回 X 类型,否则返回 Y 类型。

例如:

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

type StringCheck = IsString<string>; // true
type NumberCheck = IsString<number>; // false

条件类型用于泛型约束

  1. 示例一:根据类型选择操作 假设我们有一个函数,它接收一个值,如果这个值是字符串类型,我们将其转换为大写;如果是数字类型,我们将其平方。我们可以结合条件类型和泛型约束来实现:
function transformValue<T extends string | number>(value: T): T extends string? string : number {
    if (typeof value ==='string') {
        return value.toUpperCase() as T extends string? string : number;
    } else {
        return value * value as T extends string? string : number;
    }
}

let stringResult = transformValue('hello');
let numberResult = transformValue(5);

console.log(stringResult); // HELLO
console.log(numberResult); // 25

在上述代码中,T extends string | number 是泛型约束,限制了 T 只能是 stringnumber 类型。而 T extends string? string : number 是条件类型,根据 T 的实际类型来决定函数的返回类型。

  1. 示例二:更复杂的条件约束 假设我们有一个类型 MaybeNumber,它可以是 number 或者 null。我们有一个函数,只有当传入的值是 number 类型时,才进行加法运算:
type MaybeNumber = number | null;

function addIfNumber<T extends MaybeNumber>(a: T, b: T): T extends number? number : null {
    if (typeof a === 'number' && typeof b === 'number') {
        return a + b as T extends number? number : null;
    }
    return null;
}

let numberAddResult = addIfNumber(3, 5);
let nullResult = addIfNumber(null, null);

console.log(numberAddResult); // 8
console.log(nullResult); // null

这里,T extends MaybeNumber 是泛型约束,T extends number? number : null 是条件类型,确保只有当 T 实际为 number 类型时,函数才返回两个数相加的结果,否则返回 null

索引类型与泛型约束

索引类型基础

索引类型允许我们通过索引来访问对象的属性类型。例如,T[K] 表示类型 T 中属性 K 的类型。

interface Person {
    name: string;
    age: number;
}

type NameType = Person['name']; // string
type AgeType = Person['age']; // number

索引类型用于泛型约束

  1. 示例一:获取对象属性值 假设我们有一个函数,它接收一个对象和一个属性名,返回该对象对应属性的值。我们可以使用索引类型和泛型约束来确保属性名在对象中存在:
interface Person {
    name: string;
    age: number;
}

function getProperty<T extends object, K extends keyof T>(obj: T, prop: K): T[K] {
    return obj[prop];
}

let person: Person = { name: 'Tom', age: 20 };
let name = getProperty(person, 'name');
let age = getProperty(person, 'age');

console.log(name); // Tom
console.log(age); // 20

// 错误使用,会报错,因为属性名不存在
// let wrongProp = getProperty(person, 'address'); // 报错:类型“"address"”不能赋值给类型“"name" | "age"”。

在上述代码中,T extends object 确保 T 是一个对象类型,K extends keyof T 确保 KT 对象的属性名,这样就保证了 getProperty 函数能够安全地获取对象的属性值。

  1. 示例二:设置对象属性值 类似地,我们可以实现一个设置对象属性值的函数:
interface Product {
    title: string;
    price: number;
}

function setProperty<T extends object, K extends keyof T>(obj: T, prop: K, value: T[K]): void {
    obj[prop] = value;
}

let product: Product = { title: 'Book', price: 10 };
setProperty(product, 'title', 'New Book');
setProperty(product, 'price', 15);

console.log(product); // { title: 'New Book', price: 15 }

// 错误使用,会报错,因为属性名不存在或值类型不匹配
// setProperty(product, 'quantity', 5); // 报错:类型“"quantity"”不能赋值给类型“"title" | "price"”。
// setProperty(product, 'title', 123); // 报错:类型“number”不能赋值给类型“string”。

这里,通过索引类型和泛型约束,确保了我们只能设置对象中存在的属性,并且设置的值类型与属性类型匹配。

递归泛型约束

递归泛型基础

递归泛型是指在泛型定义中使用自身来创建更复杂的类型结构。例如,我们可以定义一个表示树结构的类型:

interface TreeNode<T> {
    value: T;
    children?: TreeNode<T>[];
}

let tree: TreeNode<number> = {
    value: 1,
    children: [
        { value: 2 },
        { value: 3, children: [ { value: 4 } ] }
    ]
};

在上述代码中,TreeNode<T> 类型中 children 属性又是一个 TreeNode<T> 数组,这就是递归泛型的体现。

递归泛型约束的应用

  1. 示例一:深度克隆 假设我们要实现一个深度克隆函数,对于具有递归结构的对象(如上述的树结构)进行克隆。我们可以使用递归泛型约束来确保克隆的准确性:
function deepClone<T>(obj: T): T {
    if (typeof obj === 'object' && obj!== null) {
        if (Array.isArray(obj)) {
            return obj.map(deepClone) as T;
        } else {
            let clone: any = {};
            for (let key in obj) {
                if (Object.prototype.hasOwnProperty.call(obj, key)) {
                    clone[key] = deepClone((obj as any)[key]);
                }
            }
            return clone as T;
        }
    }
    return obj;
}

let clonedTree = deepClone(tree);
console.log(clonedTree);

在这个深度克隆函数中,泛型 T 适用于各种类型,包括具有递归结构的类型。通过递归调用 deepClone 函数,确保了对象内部的嵌套结构也能被正确克隆。

  1. 示例二:递归验证树结构 假设我们有一个函数,用于验证树结构是否符合特定的规则,例如每个节点的值必须大于 0。我们可以使用递归泛型约束来实现:
interface TreeNode<T> {
    value: T;
    children?: TreeNode<T>[];
}

function validateTree<T extends number>(node: TreeNode<T>): boolean {
    if (node.value <= 0) {
        return false;
    }
    if (node.children) {
        for (let child of node.children) {
            if (!validateTree(child)) {
                return false;
            }
        }
    }
    return true;
}

let validTree: TreeNode<number> = {
    value: 1,
    children: [
        { value: 2 },
        { value: 3, children: [ { value: 4 } ] }
    ]
};

let invalidTree: TreeNode<number> = {
    value: -1,
    children: [
        { value: 2 },
        { value: 3, children: [ { value: 4 } ] }
    ]
};

console.log(validateTree(validTree)); // true
console.log(validateTree(invalidTree)); // false

在上述代码中,T extends number 确保了树节点的值类型为 number,并且通过递归调用 validateTree 函数,对整个树结构进行验证,确保每个节点的值都大于 0。

泛型约束与函数重载

函数重载基础

函数重载允许我们为同一个函数定义多个不同的签名,根据传入参数的不同类型和数量来选择合适的实现。

function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: any, b: any): any {
    if (typeof a === 'number' && typeof b === 'number') {
        return a + b;
    } else if (typeof a ==='string' && typeof b ==='string') {
        return a + b;
    }
    return null;
}

let numberResult = add(1, 2);
let stringResult = add('Hello ', 'World');

console.log(numberResult); // 3
console.log(stringResult); // Hello World

在上述代码中,我们定义了两个函数重载签名,一个用于处理数字相加,一个用于处理字符串拼接,然后有一个统一的实现函数。

泛型约束与函数重载结合

  1. 示例一:根据泛型类型选择重载 假设我们有一个函数,它接收一个数组和一个值,如果数组元素类型是字符串,我们将值添加到数组末尾并返回新数组;如果数组元素类型是数字,我们将值与数组所有元素相加并返回结果。我们可以结合泛型约束和函数重载来实现:
function processArray<T extends string[]>(arr: T, value: string): T;
function processArray<T extends number[]>(arr: T, value: number): number;
function processArray<T extends any[]>(arr: T, value: any): any {
    if (typeof arr[0] ==='string') {
        return [...arr, value] as T;
    } else if (typeof arr[0] === 'number') {
        return arr.reduce((acc, num) => acc + num, value);
    }
    return null;
}

let stringArray: string[] = ['a', 'b'];
let newStringArray = processArray(stringArray, 'c');

let numberArray: number[] = [1, 2];
let sumResult = processArray(numberArray, 3);

console.log(newStringArray); // ['a', 'b', 'c']
console.log(sumResult); // 6

在这个例子中,通过泛型约束 T extends string[]T extends number[] 分别定义了不同的函数重载签名,根据数组元素的类型来选择合适的实现。

  1. 示例二:更复杂的重载与泛型约束 假设我们有一个函数,它接收一个对象和一个操作类型。如果操作类型是 'get',并且对象具有 name 属性,返回 name 属性值;如果操作类型是 'set',并且对象具有 name 属性,设置 name 属性值并返回更新后的对象。我们可以这样实现:
interface HasName {
    name: string;
}

function operateOnObject<T extends HasName>(obj: T, action: 'get'): string;
function operateOnObject<T extends HasName>(obj: T, action:'set', value: string): T;
function operateOnObject<T extends HasName>(obj: T, action: 'get' |'set', value?: string): any {
    if (action === 'get') {
        return obj.name;
    } else if (action ==='set' && typeof value ==='string') {
        obj.name = value;
        return obj;
    }
    return null;
}

let person: HasName = { name: 'Mike' };

let getName = operateOnObject(person, 'get');
let setName = operateOnObject(person,'set', 'New Name');

console.log(getName); // Mike
console.log(setName); // { name: 'New Name' }

这里,通过泛型约束 T extends HasName 和不同的函数重载签名,根据操作类型和对象的属性结构来执行不同的操作。

泛型约束在类中的应用

类的泛型约束基础

在类中使用泛型约束可以确保类的实例化对象满足特定的类型要求。例如,假设我们有一个简单的栈类,我们希望栈中存储的元素具有特定的类型:

class Stack<T extends number | string> {
    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);
let poppedNumber = numberStack.pop();

let stringStack = new Stack<string>();
stringStack.push('Hello');
let poppedString = stringStack.pop();

// 错误使用,会报错,因为 boolean 类型不满足约束
// let wrongStack = new Stack<boolean>(); // 报错:类型“boolean”的参数不能赋给类型“number | string”的参数。类型“boolean”不能分配到类型“string”。

在上述代码中,T extends number | string 确保了 Stack 类实例化时,T 只能是 numberstring 类型,从而限制了栈中存储元素的类型。

基于类的泛型约束高级应用

  1. 示例一:继承与泛型约束 假设我们有一个基类 Animal,以及两个子类 DogCat。我们有一个类 AnimalContainer,用于存储 Animal 及其子类的实例,并且根据存储的具体类型执行不同的操作。我们可以使用泛型约束结合继承来实现:
class Animal {
    speak() {
        console.log('I am an animal');
    }
}

class Dog extends Animal {
    bark() {
        console.log('Woof!');
    }
}

class Cat extends Animal {
    meow() {
        console.log('Meow!');
    }
}

class AnimalContainer<T extends Animal> {
    private animal: T;

    constructor(animal: T) {
        this.animal = animal;
    }

    operate() {
        this.animal.speak();
        if (this.animal instanceof Dog) {
            this.animal.bark();
        } else if (this.animal instanceof Cat) {
            this.animal.meow();
        }
    }
}

let dogContainer = new AnimalContainer(new Dog());
dogContainer.operate();

let catContainer = new AnimalContainer(new Cat());
catContainer.operate();

在这个例子中,T extends Animal 确保了 AnimalContainer 类只能存储 Animal 及其子类的实例。通过 instanceof 检查具体的子类类型,我们可以执行特定子类的方法。

  1. 示例二:静态方法与泛型约束 类的静态方法也可以使用泛型约束。假设我们有一个工具类 MathUtils,它有一个静态方法用于对数组中的数字进行操作。我们可以使用泛型约束确保传入的数组元素类型为数字:
class MathUtils {
    static sumArray<T extends number[]>(arr: T): number {
        return arr.reduce((acc, num) => acc + num, 0);
    }
}

let numbers = [1, 2, 3];
let sum = MathUtils.sumArray(numbers);
console.log(sum); // 6

// 错误使用,会报错,因为数组元素不是数字类型
// let wrongArray: string[] = ['a', 'b'];
// let wrongSum = MathUtils.sumArray(wrongArray); // 报错:类型“string[]”的参数不能赋给类型“number[]”的参数。类型“string”不能分配到类型“number”。

这里,T extends number[] 确保了 sumArray 静态方法只能接收元素类型为 number 的数组,从而保证了方法的正确性。

通过以上详细的讲解和丰富的代码示例,我们全面地探讨了 TypeScript 泛型约束的高级用法。从基于接口、类型别名的约束,到与条件类型、索引类型、递归泛型等的结合,以及在函数重载和类中的应用,希望这些内容能帮助你在前端开发中更灵活、准确地使用泛型约束,编写出更健壮的 TypeScript 代码。