TypeScript 泛型约束 (Generic Constraints) 是泛型机制中一个至关重要的概念。它允许我们限制泛型类型参数可以表示的类型范围。通过泛型约束,我们可以在泛型代码内部安全地访问泛型类型参数的特定属性或方法,从而编写出既通用又具备类型安全性的代码。

核心思想:泛型约束的本质是使用 extends 关键字来声明一个类型参数必须是某个特定类型或实现某个接口的子类型。这为编译器提供了足够的类型信息,使其能够在泛型函数、类或接口内部进行更精确的类型检查。


一、为什么需要泛型约束?

在上一篇泛型详解中,我们了解到泛型允许我们编写处理任何类型的代码。但有时,我们希望泛型处理的类型具有某种共同的特性。

考虑一个场景:我们想编写一个函数,它接受一个列表,并返回列表中元素的长度之和。

问题示例

1
2
3
4
5
6
7
8
9
10
11
12
13
function sumLengths<T>(items: T[]): number {
let totalLength = 0;
for (let item of items) {
// 报错: Property 'length' does not exist on type 'T'.
// 编译器不知道 T 类型是否有 length 属性
totalLength += item.length;
}
return totalLength;
}

// 假设我们希望这样调用:
// sumLengths(["hello", "world"]); // 期望返回 10
// sumLengths([[1, 2], [3, 4, 5]]); // 期望返回 5

分析问题

  • 当我们定义 sumLengths<T>(items: T[]) 时,T 可以是任何类型。
  • 编译器在编译阶段无法确定 T 是否具有 length 属性。例如,如果 Tnumber,那么 item.length 显然是错误的。
  • 为了保证类型安全,TypeScript 拒绝了这种不安全的访问。

为了解决这个问题,我们需要告诉编译器:“嘿,T 不可以是任意类型,它必须是那些具有 length 属性的类型!” 这就是泛型约束的用武之地。

二、泛型约束的基本语法

泛型约束通过在类型参数后使用 extends 关键字来指定。

function functionName<T extends ConstraintType>(arg: T): ReturnType { ... }

或者应用于类、接口:

class ClassName<T extends ConstraintType> { ... }
interface InterfaceName<T extends ConstraintType> { ... }

这里的 ConstraintType 可以是一个接口、一个类、一个字面量类型或任何其他可以作为类型约束的类型。

2.1 1. 约束到接口

最常见的泛型约束是约束到接口。我们可以定义一个包含所需属性的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 定义一个接口,要求类型必须有 length 属性
interface Lengthwise {
length: number;
}

// 使用泛型约束:T 必须是 Lengthwise 的子类型
function sumLengths<T extends Lengthwise>(items: T[]): number {
let totalLength = 0;
for (let item of items) {
// 现在,编译器知道 T 类型肯定有 length 属性
totalLength += item.length;
}
return totalLength;
}

// 测试函数
console.log(sumLengths(["hello", "world"])); // OK. string 实现了 Lengthwise
console.log(sumLengths([[1, 2], [3, 4, 5]])); // OK. array 实现了 Lengthwise

// console.log(sumLengths([10, 20, 30])); // 报错:类型 'number' 不可分配给类型 'Lengthwise'。
// console.log(sumLengths([{ id: 1 }, { id: 2 }])); // 报错:类型 '{ id: number; }' 不可分配给类型 'Lengthwise'。
console.log(sumLengths([{ length: 5 }, { length: 10 }])); // OK,只要有 length 属性即可

在这个例子中:

  • 我们定义了 Lengthwise 接口,它强制实现者必须拥有 length 属性。
  • sumLengths<T extends Lengthwise> 告诉 TypeScript,任何作为 T 的类型都必须满足 Lengthwise 接口。
  • 这样,在函数体内部通过 item.length 访问 length 属性就是类型安全的了。

2.2 2. 约束到字面量类型或联合类型

泛型约束也可以是字面量类型或联合类型,这使得泛型参数只能是这些特定类型之一。

1
2
3
4
5
6
7
8
9
10
type AllowedColors = "red" | "green" | "blue";

function pickColor<T extends AllowedColors>(color: T): T {
console.log(`Picking color: ${color}`);
return color;
}

pickColor("red"); // OK
pickColor("blue"); // OK
// pickColor("yellow"); // 报错:类型 '"yellow"' 不可分配给类型 'AllowedColors'。

2.3 3. 约束到类或构造函数

你可以约束泛型类型参数为一个类,这意味着传入的类型必须是该类或其子类。
更进一步,你可以约束泛型参数为一个构造函数签名,这在需要使用泛型类型来创建实例时非常有用。

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
// 约束 T 必须是 HasName 类或其子类
class HasName {
name: string;
constructor(name: string) {
this.name = name;
}
}

function processWithName<T extends HasName>(obj: T): string {
return `Processing: ${obj.name}`;
}

class User extends HasName {
age: number;
constructor(name: string, age: number) {
super(name);
this.age = age;
}
}

const user = new User("Alice", 30);
console.log(processWithName(user)); // OK,User extends HasName

const anonymous = new HasName("unknown");
console.log(processWithName(anonymous)); // OK

// console.log(processWithName({ name: "Bob" })); // 报错: 通常情况下字面量对象不能直接赋值给类类型,除非结构完全匹配且类是抽象的或构造函数是私有的。
// 但如果约束只是一个接口,那么 { name: 'Bob' } 是可以通过的。

// 约束 T 为构造函数签名:{ new(): T }
function createInstance<T>(constructor: { new(): T }): T {
return new constructor();
}

class Product {
id: number = 0;
name: string = "Default Product";
}

let product = createInstance(Product);
console.log(product.name); // Output: Default Product

// class Config {
// private constructor() {} // private 构造函数不允许外部直接 new
// }
// createInstance(Config); // 报错:类型 'typeof Config' 的参数不能赋给类型 'new () => T' 的参数。

三、约束类型参数 (Type Parameter Constraints)

泛型约束不仅可以约束外部传入的类型,还可以让一个类型参数约束另一个类型参数。这通常用于我们希望确保两个泛型类型之间存在某种关系时。

场景:获取一个对象的属性值,确保属性名存在于对象中。

如果我们直接这样写:

1
2
3
// function getProperty<T, K>(obj: T, key: K): T[K] {
// return obj[key]; // 报错:Type 'K' cannot be used to index type 'T'.
// }

编译器不知道 KT 之间有什么关系,K 可能是任何类型,不一定能作为 T 的属性名。

解决方案:使用 keyof 结合 extends 来约束 K

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 从对象中获取指定键的属性值
* @param obj 任何对象
* @param key 对象的属性名,必须是 obj 的 key 的联合类型
* @returns 对应属性的值
*/
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

let user = { id: 1, name: "Alice", age: 30 };

console.log(getProperty(user, "name")); // OK, K 是 "name" (它 extends keyof T => "id" | "name" | "age")
console.log(getProperty(user, "age")); // OK

// console.log(getProperty(user, "address")); // 报错:Argument of type '"address"' is not assignable to parameter of type '"id" | "name" | "age"'.

解释 K extends keyof T

  • keyof T 是一个索引类型查询操作符 (Index Type Query Operator)。它会生成一个联合类型,包含类型 T 所有公共属性的字符串字面量。
    • 例如,如果 T{ id: number; name: string; },那么 keyof T 就是 "id" | "name"
  • K extends keyof T 的含义是:类型参数 K 必须是 T 的某个属性名称的子类型(或其本身)。这确保了 key 参数的值在编译时始终是 obj 参数的有效属性名。

四、泛型约束与 instanceof

在某些场景下,你可能需要根据类型参数来决定运行时行为,并希望在类型层面保持安全。instanceof 可以用于运行时检查,但如果结合泛型,需要注意类型推断。

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
class Animal {
constructor(public name: string) {}
eat() { console.log(`${this.name} is eating.`); }
}

class Dog extends Animal {
bark() { console.log(`${this.name} is barking.`); }
}

class Cat extends Animal {
meow() { console.log(`${this.name} is meowing.`); }
}

// 泛型函数处理不同类型的动物
function handleAnimal<T extends Animal>(animal: T) {
animal.eat(); // OK, 因为 T extends Animal

// 运行时检查,结合类型守卫
if (animal instanceof Dog) {
animal.bark(); // 在此作用域内, animal 被收窄为 Dog 类型
} else if (animal instanceof Cat) {
animal.meow(); // 在此作用域内, animal 被收窄为 Cat 类型
}
}

handleAnimal(new Dog("Buddy"));
handleAnimal(new Cat("Whiskers"));
handleAnimal(new Animal("Generic Animal"));

五、泛型约束的实践案例

5.1 1. 缓存函数结果

我们可以创建一个泛型函数,用于缓存另一个函数的计算结果。

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
function memoize<T extends (...args: any[]) => any>(
func: T
): (...args: Parameters<T>) => ReturnType<T> {
const cache = new Map<string, ReturnType<T>>();
return function(...args: Parameters<T>): ReturnType<T> {
const key = JSON.stringify(args); // 简单地将参数序列化为键
if (cache.has(key)) {
return cache.get(key)!;
}
const result = func(...args);
cache.set(key, result);
return result;
};
}

function expensiveCalculation(a: number, b: number): number {
console.log(`Calculating ${a} + ${b}...`);
return a + b;
}

const memoizedCalculation = memoize(expensiveCalculation);

console.log(memoizedCalculation(1, 2)); // Calculating 1 + 2... -> 3
console.log(memoizedCalculation(1, 2)); // -> 3 (从缓存获取)
console.log(memoizedCalculation(3, 4)); // Calculating 3 + 4... -> 7
console.log(memoizedCalculation(3, 4)); // -> 7 (从缓存获取)

function greet(name: string, greeting: string = "Hello"): string {
return `${greeting}, ${name}!`;
}

const memoizedGreet = memoize(greet);
console.log(memoizedGreet("Alice")); // Hello, Alice!
console.log(memoizedGreet("Bob", "Hi")); // Hi, Bob!

// Parameters<T> 和 ReturnType<T> 是 TypeScript 的内置工具类型,用于提取函数类型的参数元组类型和返回值类型。
// 这里的 T extends (...args: any[]) => any 约束了 T 必须是一个函数类型。

5.2 2. 类型安全的数据存储

创建一个存储 key-value 对的泛型类,确保键和值在编译期类型安全。

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
interface DataStoreEntry {
id: string;
[key: string]: any; // 允许其他属性
}

class DataStore<T extends DataStoreEntry> {
private data: Map<string, T> = new Map();

add(item: T): void {
if (this.data.has(item.id)) {
console.warn(`Item with ID ${item.id} already exists. Updating.`);
}
this.data.set(item.id, item);
}

get(id: string): T | undefined {
return this.data.get(id);
}

getAll(): T[] {
return Array.from(this.data.values());
}
}

interface UserProfile extends DataStoreEntry {
id: string; // 必须有 id
name: string;
email: string;
isActive: boolean;
}

const userStore = new DataStore<UserProfile>();

userStore.add({ id: "user1", name: "Alice", email: "alice@example.com", isActive: true });
userStore.add({ id: "user2", name: "Bob", email: "bob@example.com", isActive: false });

let user1 = userStore.get("user1");
if (user1) {
console.log(user1.name); // OK, name 属性存在
// console.log(user1.age); // 报错:Property 'age' does not exist on type 'UserProfile'.
}

// userStore.add({ name: "Charlie" }); // 报错:Property 'id' is missing in type '{ name: string; }'

六、与 anyunknown 的区别

  • any:完全放弃类型检查,可以访问任何属性和方法,运行时才可能报错。
  • unknown:比 any 安全,不能直接访问属性或方法,必须先进行类型缩小 (Type Narrowing)。
  • 泛型(无约束):保留类型信息,但不知道具体类型,也不能直接访问特定属性。
  • 泛型(有约束):保留类型信息,并保证类型参数具有特定的形状或行为,从而可以在其内部安全地访问这些特性。

七、总结

泛型约束是 TypeScript 泛型系统中的强大工具,它弥补了纯粹泛型(无约束)的不足,使得开发者能够编写既通用又具备强大类型检查能力的模块。

  • 目的:精细化泛型类型参数的范围,为编译器提供额外的信息,从而在泛型代码内部安全地访问类型参数的特定属性或方法。
  • 语法:使用 extends 关键字,例如 T extends ConstraintType
  • 应用场景
    • 当泛型函数或类需要处理的类型必须具备某些共同特性时(例如 length 属性)。
    • 约束一个类型参数必须是另一个类型参数的属性键 (K extends keyof T)。
    • 在创建工厂函数时,约束泛型为构造函数类型。
  • 优势:在保持代码灵活性的同时,大大增强了类型安全性,减少了运行时错误的可能性,并提高了代码的可读性和维护性。

深入理解并熟练运用泛型约束,是编写高质量、可伸缩和类型安全的 TypeScript 应用的关键。