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

TypeScript泛型约束的原理及应用案例

2022-01-236.4k 阅读

泛型约束的基本概念

什么是泛型约束

在TypeScript中,泛型允许我们在定义函数、接口或类时使用类型参数,这样可以使代码更加通用,适用于多种不同的类型。然而,有时候我们需要对这些类型参数进行限制,确保它们满足某些条件,这就是泛型约束的作用。

泛型约束本质上是一种类型断言,它告诉TypeScript编译器,类型参数必须满足特定的类型条件。通过这种方式,我们可以在保持代码通用性的同时,增加类型安全性和可预测性。

简单的泛型约束示例

假设我们要编写一个函数,它接受两个值并返回其中较大的一个。如果不使用泛型约束,代码可能如下:

function findLarger(a, b) {
    return a > b? a : b;
}

但是,这个函数存在问题,它没有类型检查,传入非数字类型的值也不会报错,例如 findLarger('a', 'b')。这时候就可以使用泛型约束来解决。

function findLarger<T extends number>(a: T, b: T): T {
    return a > b? a : b;
}

这里使用了 T extends number 来约束类型参数 T,表示 T 必须是 number 类型或其子类型。这样,当我们调用 findLarger(1, 2) 时是合法的,而调用 findLarger('a', 'b') 时编译器就会报错。

泛型约束的原理

类型系统的匹配机制

TypeScript的类型系统基于结构类型系统。当我们定义一个泛型约束 T extends U 时,TypeScript编译器会检查 T 的结构是否与 U 兼容。这里的兼容并非严格的类型相等,而是 T 至少包含 U 所定义的所有属性和方法。

例如,定义一个接口 Animal

interface Animal {
    name: string;
}

然后定义一个类 Dog 实现 Animal 接口:

class Dog implements Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
}

如果我们定义一个泛型约束 T extends Animal,那么 Dog 类型是满足这个约束的,因为 Dog 包含了 Animal 接口定义的 name 属性。

类型推断与约束的协同工作

TypeScript的类型推断机制与泛型约束紧密配合。当我们调用一个带有泛型约束的函数时,编译器会根据传入的参数类型来推断类型参数。如果传入的参数类型满足泛型约束,那么类型推断会成功,否则会报错。

例如:

function printProperty<T extends { prop: string }>(obj: T) {
    console.log(obj.prop);
}
let myObj = { prop: 'Hello', otherProp: 123 };
printProperty(myObj);

在这个例子中,编译器根据 myObj 的类型推断出 T 的类型,并且由于 myObj 包含 prop 属性,满足 T extends { prop: string } 的约束,所以代码可以正常运行。

泛型约束在函数中的应用

约束函数参数类型

在实际开发中,经常需要对函数的参数类型进行约束。例如,编写一个函数,它接受一个数组和一个索引,返回数组中对应索引位置的元素。我们希望确保传入的索引是有效的,即不超过数组的长度。

function getElement<T>(arr: T[], index: number & { >= 0 } & { < T['length'] }): T | undefined {
    if (index >= 0 && index < arr.length) {
        return arr[index];
    }
    return undefined;
}
let numbers = [1, 2, 3];
let result = getElement(numbers, 1);

这里使用了交叉类型 number & { >= 0 } & { < T['length'] } 来约束索引的类型,确保它是一个非负且小于数组长度的数字。

约束函数返回值类型

除了约束参数类型,也可以对函数的返回值类型进行约束。比如,编写一个函数,它接受一个对象和一个属性名,返回该对象中指定属性的值。我们希望确保返回值的类型与对象中该属性的类型一致。

function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}
let person = { name: 'John', age: 30 };
let age = getValue(person, 'age');

这里 K extends keyof T 确保 KT 对象的有效属性名,T[K] 则表示返回值的类型是 T 对象中 K 属性的类型。

泛型约束在接口中的应用

接口的泛型约束定义

在定义接口时,也可以使用泛型约束。例如,定义一个接口 Mapper,它接受一个类型参数 TU,并且要求 UT 的子类型。

interface Mapper<T, U extends T> {
    map: (value: T) => U;
}

这个接口定义了一个 map 方法,它接受一个类型为 T 的值,并返回一个类型为 U 的值,而 U 必须是 T 的子类型。

实现带有泛型约束的接口

假设有一个类 StringMapper 实现上述 Mapper 接口:

class StringMapper implements Mapper<string, string> {
    map(value: string): string {
        return value.toUpperCase();
    }
}

这里 StringMapper 实现了 Mapper<string, string>,因为 string 类型是自身的子类型,满足 U extends T 的约束。

泛型约束在类中的应用

类的泛型约束声明

在定义类时,同样可以使用泛型约束。比如,定义一个 Stack 类,用于模拟栈的数据结构。我们希望栈中存储的元素类型满足一定的约束,例如具有 toString 方法。

class Stack<T extends { toString(): string }> {
    private items: T[] = [];
    push(item: T) {
        this.items.push(item);
    }
    pop(): T | undefined {
        return this.items.pop();
    }
    print() {
        console.log(this.items.map(item => item.toString()).join(', '));
    }
}

这里 T extends { toString(): string } 确保了栈中存储的元素都具有 toString 方法,这样在 print 方法中就可以安全地调用 toString 方法。

实例化带有泛型约束的类

class Person {
    constructor(public name: string) {}
    toString() {
        return this.name;
    }
}
let stack = new Stack<Person>();
stack.push(new Person('Alice'));
stack.push(new Person('Bob'));
stack.print();

这里实例化了 Stack<Person>,由于 Person 类满足 T extends { toString(): string } 的约束,所以可以正常使用 Stack 类的方法。

多重泛型约束

多个约束条件的组合

有时候,我们需要对类型参数施加多个约束条件。例如,定义一个函数,它接受一个对象和两个属性名,返回这两个属性值的和。我们希望确保这两个属性名都是对象的有效属性,并且属性值都是数字类型。

function sumProperties<T, K1 extends keyof T, K2 extends keyof T>(
    obj: T,
    key1: K1,
    key2: K2
): number | undefined {
    let value1 = obj[key1];
    let value2 = obj[key2];
    if (typeof value1 === 'number' && typeof value2 === 'number') {
        return value1 + value2;
    }
    return undefined;
}
let data = { num1: 10, num2: 20 };
let resultSum = sumProperties(data, 'num1', 'num2');

这里使用了 K1 extends keyof TK2 extends keyof T 确保 key1key2obj 的有效属性名,同时在函数内部进行了类型检查,确保属性值是数字类型。

交叉类型与联合类型在约束中的应用

交叉类型和联合类型在泛型约束中也经常用到。例如,定义一个函数,它接受一个值,这个值要么是字符串,要么是数字,并且具有 length 属性(对于字符串来说是长度,对于数组类型的数字(如 Int8Array 等具有 length 属性的数字类型视图)来说也适用)。

function printLength<T extends (string | { length: number })>(value: T) {
    console.log(value.length);
}
printLength('Hello');
let int8Array = new Int8Array([1, 2, 3]);
printLength(int8Array);

这里 T extends (string | { length: number }) 使用联合类型来定义泛型约束,使得函数可以接受字符串或具有 length 属性的对象。

泛型约束与类型兼容性

子类型与泛型约束的关系

在TypeScript中,子类型关系与泛型约束密切相关。当我们定义 T extends U 时,如果 AU 的子类型,那么 A 也满足 T 的约束。

例如:

interface Shape {
    color: string;
}
interface Rectangle extends Shape {
    width: number;
    height: number;
}
function draw<T extends Shape>(shape: T) {
    console.log(`Drawing a ${shape.color}`);
}
let rect: Rectangle = { color: 'blue', width: 10, height: 20 };
draw(rect);

这里 RectangleShape 的子类型,所以 Rectangle 类型的变量 rect 满足 T extends Shape 的约束,可以作为参数传递给 draw 函数。

泛型约束对类型兼容性的影响

泛型约束会影响类型之间的兼容性判断。例如,对于两个泛型类型 A<T>B<U>,如果 TU 满足不同的泛型约束,即使它们的结构相似,也可能不兼容。

interface A<T extends number> {
    value: T;
}
interface B<U extends string> {
    value: U;
}
let a: A<number> = { value: 10 };
// 下面这行代码会报错,因为A和B的泛型约束不同
let b: B<string> = a as any;

这里 AB 虽然都有一个 value 属性,但由于泛型约束不同,它们是不兼容的类型。

高级泛型约束应用

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

条件类型可以与泛型约束一起使用,实现更复杂的类型逻辑。例如,定义一个类型 IfString,如果类型参数是字符串,则返回字符串的长度类型,否则返回 never

type IfString<T> = T extends string? number : never;
let strLength: IfString<string> = 'Hello'.length;
// 下面这行代码会报错,因为number类型不满足IfString<number>的条件
let numLength: IfString<number>;

这里通过 T extends string 这个泛型约束条件,结合条件类型实现了根据类型参数动态返回不同类型的功能。

映射类型与泛型约束

映射类型也可以与泛型约束结合使用。例如,定义一个映射类型 ReadOnlyProperties,它将一个对象类型的所有属性转换为只读属性,并且只对满足特定约束的属性进行转换。

interface User {
    name: string;
    age: number;
    email: string;
}
type ReadOnlyProperties<T, K extends keyof T> = {
    readonly [P in K]: T[P];
};
let readonlyUser: ReadOnlyProperties<User, 'name' | 'email'> = {
    name: 'John',
    email: 'john@example.com'
};

这里 K extends keyof T 确保了我们只对 User 对象中存在的属性进行只读转换。

通过以上对TypeScript泛型约束的原理及各种应用案例的详细介绍,希望能帮助开发者更深入地理解和运用泛型约束,编写出更健壮、更通用的TypeScript代码。无论是在函数、接口还是类的定义中,泛型约束都为我们提供了强大的类型控制能力,使得代码在保持灵活性的同时,具备更高的类型安全性。