从前面的学习可以知道,泛型(<T>)就像一个万能的容器,无论你丢进去什么类型的数据,它都能完美地承接并推断出来。但这种绝对的自由,在很多业务场景下往往会带来灾难。
想象一下,如果我们写了一个泛型函数,然后在函数内部调用了参数的 “.length” 属性。某一天某个马大哈同事给这个函数传了一个 number 类型的参数,由于数字是没有 length 属性的,程序在运行时就会瞬间崩溃。
为了避免这种悲剧的发生,TypeScript 为泛型提供了一种防御机制:泛型约束(Generic Constraints)。
TypeScript 泛型太自由带来的痛点
我们先来看一个非常经典的反面教材。假设需要写一个泛型函数,用于打印传入参数的长度,并原封不动地返回该参数。
示例 1:泛型的 “无法无天”
// 定义泛型函数
function printLength<T>(arg: T): T {
// 报错:类型 “T” 上不存在属性 “length”。
console.log("当前长度:", arg.length);
return arg;
}分析:
为什么会报错呢?因为泛型 “T” 实在是太自由了。TypeScript 编译器在编译时,根本不知道别人会传什么类型进来。
万一别人传了个数字 printLength(2077),或者是传了个布尔值 printLength(true) 呢?这些类型身上压根就没有 length 属性!为了保证绝对的安全,TypeScript 毫不留情地拦截了你试图访问 “.length” 的行为。
TypeScript 使用 extends 实现泛型约束
既然 “T” 太过于自由奔放了,那我们就要给它定个规矩:你传入的类型随便是什么都可以,但你 “必须” 拥有 length 属性!
在 TypeScript 中,我们使用 extends 关键字来给泛型施加约束。
示例 2:使用泛型约束
// 定义接口:规定必须包含 length 属性
interface Lengthwise {
length: number;
}
// 使用泛型约束
function printLength<T extends Lengthwise>(arg: T): T {
console.log("当前长度是:", arg.length);
return arg;
}
// 字符串
const strRes = printLength("绿叶网");
// 数组
const arrRes = printLength(["苹果", "香蕉", "橘子"]);
// 对象(包含 length 属性)
const objRes = printLength({ length: 10, name: "Jack", age: 20 });运行结果如下。
当前长度是:3
当前长度是:3
当前长度是:10分析:
在这个例子中,我们首先定义一个接口(相当于一个 “契约”),规定必须包含 length 属性。然后在定义泛型函数时,使用 <T extends Lengthwise> 来对泛型进行约束。其中,<T extends Lengthwise> 意思是:泛型 “T” 必须继承(实现) Lengthwise 接口。
加上了这个约束之后,在 printLength() 函数内部,TypeScript 已经知道 “T” 必定包含 length 属性,因此就也不会报错了。
其中,不管你传入的是内置了 length 属性的对象(比如字符串、数组等),还是自定义了 length 属性的对象,都是合法的参数。但如果我们传入一个数字,则 TypeScript 会直接报错:
// 错误调用:数字没有 length 属性,直接在外部调用时就被拦截!
printLength(2026);
// 报错:类型“number”的参数不能赋给类型“Lengthwise”的参数。这里肯定有小伙伴会问:既然只是想要保证参数有 length 属性,那我直接把参数的类型写成 Lengthwise 接口不就行了吗?为什么非要脱裤子放屁,绕一圈去搞个泛型约束呢?比如写成下面这样不行吗?
interface Lengthwise {
length: number;
}
// 不使用泛型,直接用接口约束参数
function PrintLength(arg: Lengthwise): Lengthwise {
console.log("当前长度是:", arg.length);
return arg;
}这种写法是有问题的。原因在于:如果直接用接口作为类型,就会彻底丢失参数原本具体的类型推断。
示例 3:使用普通接口来约束
// 定义接口
interface Lengthwise {
length: number;
}
// 定义函数:使用普通接口约束
function fn(arg: Lengthwise): Lengthwise {
return arg;
}
const str = "lvyenet";
const res = fn(str);
res.toUpperCase();
// 报错:类型 “Lengthwise” 上不存在属性 “toUpperCase”。分析:
在上面例子中,我们使用了普通接口类型对函数参数进行约束,此时返回值类型被直接 “降维” 成了 Lengthwise。因此不管你传入的是字符串还是数组,TypeScript 都会提示 Lengthwise,而失去了参数原本具体的类型推断。
示例 4:使用泛型来约束
// 定义接口
interface Lengthwise {
length: number;
}
// 定义函数:使用泛型约束
function fn<T extends Lengthwise>(arg: T): T {
return arg;
}
const str = "lvyenet";
const res = fn(str);
console.log(res.toUpperCase()); 运行结果如下。
LVYENET分析:
函数参数使用 “泛型约束” 就不一样了,此时 TypeScript 不仅会检查是否有 length 属性,还能完美保留了你所有的原始类型信息。
TypeScript 泛型约束的应用
在真实的后端开发中,泛型约束是编写底层 Base 类的一个必备技巧。
假设我们要写一个通用的 “打印数据库实体 id” 的函数。不管是 User 表还是 Article 表,只要传进来的实体对象有 id,该函数都可以处理。
示例 5:泛型约束实战
// 定义接口
interface BaseEntity {
id: string;
}
// 泛型约束:传入的对象必须包含 id 属性
function updateEntity<T extends BaseEntity>(entity: T): T {
console.log("正在同步数据库,id:", entity.id);
// ...执行更新逻辑
return entity;
}
const user = {
id: "U-1001",
username: "Jack",
role: "Admin"
};
// 运行完美:updatedUser 保留了 username 和 role 属性的类型推断
const updatedUser = updateEntity(user);
console.log("更新完成,当前角色是:", updatedUser.role);运行结果如下。
正在同步数据库,id:U-1001
更新完成,当前角色是:Admin分析:
最后需要清楚的是,extends 关键字其实是个 “劳模”,它在不同的场景下有不同的含义:
- 在类(class)或接口(interface)中:它表示 “继承”。子类继承父类,子接口继承父接口。
- 在泛型(<T extends X>)中 :它表示 “约束” 或 “兼容”。即 T 必须满足 X 的结构要求(鸭子类型)。只要你长得像 X(包含了 X 所有的属性),就能通过约束,并不需要你真的去认 X 做干爹。
TypeScript 泛型约束用于基础类型
很多初学的小伙伴学到这里,可能会产生一个误区:以为 extends 后面只能跟着对象或接口。
实际上,泛型约束同样也适用于基础类型(如 string、number)的联合类型。这在封装一些简单但要求严格的工具函数时非常有用。
假设要封装一个处理用户 id 的函数,但在我们的系统中,id 只能是 string 或者 number,绝对不能是布尔值或数组。
示例 6:约束泛型为特定的基础类型
// 泛型约束:T 只能是 string 或 number 中的一种
function processId<T extends string | number>(id: T): T {
console.log("正在处理 id:", id);
return id;
}
// 正常调用
const strId = processId("U-1001");
const numId = processId(2026);
// 错误调用:传入布尔值,直接被拦截
// processId(true);
// 报错:类型“boolean”的参数不能赋给类型“string | number”的参数。分析:
在这个例子中,我们使用了 <T extends string | number>。无论我们传入什么,TypeScript 都会检查它是否能 “兼容” 字符串或数字。
通过这种方式,我们既限制了参数的范围,又完美保留了 strId(推断为 string)和 numId(推断为 number)的精确类型,可谓一举两得。
