TypeScript 泛型约束的高级用法
泛型约束基础回顾
在深入探讨 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
必须是一个数组类型。这就是泛型约束的基本应用,它确保了函数的输入类型符合我们预期的结构。
基于接口的泛型约束
简单接口约束
- 示例一:对象属性约束
在实际开发中,我们经常需要处理具有特定属性的对象。假设我们有一个函数,它接收一个对象,并打印该对象的
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
的对象。这样就通过接口约束了泛型的类型结构。
- 示例二:函数接口约束 我们还可以对函数类型进行泛型约束。例如,假设我们有一个函数,它接收另一个函数作为参数,并调用这个函数。我们希望传入的函数具有特定的参数和返回值类型。我们可以定义一个函数接口来进行约束:
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
必须同时满足 HasName
和 HasAge
接口的约束,即对象必须同时具有 name
和 age
属性。
基于类型别名的泛型约束
简单类型别名约束
类型别名也可以用于定义泛型约束。例如,假设我们定义一个类型别名表示数字或字符串类型,然后我们有一个函数,它接收这种类型的值并打印:
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
只能是 number
或 string
类型。
联合与交叉类型别名约束
- 联合类型别名约束
我们可以基于联合类型别名创建更复杂的约束。例如,假设我们有一个类型别名表示具有
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
为数字或字符串。
- 交叉类型别名约束
类似地,我们可以使用交叉类型别名进行约束。假设我们有一个类型别名表示既是
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
交叉类型的约束,即对象同时具有 name
和 age
属性。
条件类型与泛型约束的结合
条件类型基础
条件类型是 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
条件类型用于泛型约束
- 示例一:根据类型选择操作 假设我们有一个函数,它接收一个值,如果这个值是字符串类型,我们将其转换为大写;如果是数字类型,我们将其平方。我们可以结合条件类型和泛型约束来实现:
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
只能是 string
或 number
类型。而 T extends string? string : number
是条件类型,根据 T
的实际类型来决定函数的返回类型。
- 示例二:更复杂的条件约束
假设我们有一个类型
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
索引类型用于泛型约束
- 示例一:获取对象属性值 假设我们有一个函数,它接收一个对象和一个属性名,返回该对象对应属性的值。我们可以使用索引类型和泛型约束来确保属性名在对象中存在:
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
确保 K
是 T
对象的属性名,这样就保证了 getProperty
函数能够安全地获取对象的属性值。
- 示例二:设置对象属性值 类似地,我们可以实现一个设置对象属性值的函数:
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>
数组,这就是递归泛型的体现。
递归泛型约束的应用
- 示例一:深度克隆 假设我们要实现一个深度克隆函数,对于具有递归结构的对象(如上述的树结构)进行克隆。我们可以使用递归泛型约束来确保克隆的准确性:
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
函数,确保了对象内部的嵌套结构也能被正确克隆。
- 示例二:递归验证树结构 假设我们有一个函数,用于验证树结构是否符合特定的规则,例如每个节点的值必须大于 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
在上述代码中,我们定义了两个函数重载签名,一个用于处理数字相加,一个用于处理字符串拼接,然后有一个统一的实现函数。
泛型约束与函数重载结合
- 示例一:根据泛型类型选择重载 假设我们有一个函数,它接收一个数组和一个值,如果数组元素类型是字符串,我们将值添加到数组末尾并返回新数组;如果数组元素类型是数字,我们将值与数组所有元素相加并返回结果。我们可以结合泛型约束和函数重载来实现:
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[]
分别定义了不同的函数重载签名,根据数组元素的类型来选择合适的实现。
- 示例二:更复杂的重载与泛型约束
假设我们有一个函数,它接收一个对象和一个操作类型。如果操作类型是
'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
只能是 number
或 string
类型,从而限制了栈中存储元素的类型。
基于类的泛型约束高级应用
- 示例一:继承与泛型约束
假设我们有一个基类
Animal
,以及两个子类Dog
和Cat
。我们有一个类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
检查具体的子类类型,我们可以执行特定子类的方法。
- 示例二:静态方法与泛型约束
类的静态方法也可以使用泛型约束。假设我们有一个工具类
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 代码。