在真实的项目开发中,除了封装通用的工具函数,我们绝大多数的时间都是在和各种 “数据结构” 打交道(比如接收并处理 API 数据)。
如果我们在定义数据结构时,也能像调用函数一样 “动态传入类型”,那代码的复用率将呈指数级提升。这就需要用到 TypeScript 的 “泛型接口” 与 “泛型类型别名” 了。
TypeScript 接口为什么要使用泛型?
假设我们在开发一个现代化的前端项目(Vue 或 React),并且需要对接后端的 API。其中,后端一般会返回一种固定格式的 JSON 数据,包含:
code:状态码。message:提示信息。data:具体数据。
如果没有泛型,面对 “请求用户信息” 和 “请求文章列表” 这两个接口,我们只能被迫写出两个非常冗余的接口定义,比如:
// 定义用户接口
interface UserRes {
code: number;
message: string;
data: { // data 是用户对象
id: number;
name: string
};
}
// 定义文章列表接口
interface ArticleListRes {
code: number;
message: string;
data: string[]; // data 是字符串数组
}分析:
从这两个接口可以看出,除了 data 字段的类型不同之外,code 和 message 完全是重复的。如果一个大型项目有上百个接口,按照这种 “写死” 的方式,我们就要写上百个几乎一模一样的接口(interface),这绝对是架构上的灾难。
TypeScript 泛型接口
为了解决上面 “外壳相同、内核不同” 的数据结构问题,我们可以在 “接口名” 后面加上 “<T>”,将其改造成一个泛型接口。
泛型接口与泛型函数使用起来是非常类似的,小伙伴们可以对比理解一下。
示例 1:定义泛型接口
// 定义泛型接口
interface ApiResponse<T> {
code: number;
message: string;
// 将变化的 data 字段的类型,绑定为泛型 T
data: T;
}
// 定义具体的业务数据类型
interface User {
id: number;
name: string;
}
// 场景 1:获取用户详情
const userRes: ApiResponse<User> = {
code: 200,
message: "请求成功",
data: {
id: 1,
name: "Jack"
}
};
// 场景 2:获取商品名称列表
const productRes: ApiResponse<string[]> = {
code: 200,
message: "请求成功",
data: ["机械键盘", "高刷显示器", "电竞鼠标"]
};
console.log(userRes.data.name);
console.log(productRes.data[1]);运行结果如下。
Jack
高刷显示器分析:
在这个例子中,ApiResponse<T> 就像是我们封装好的一张图纸。当写下 ApiResponse<User> 时,TypeScript 会在底层自动把接口中所有的 “T” 替换为 User。
这样一来,我们就可以只通过一个接口,就可以完美兜底全站几百个 API 的返回类型。
TypeScript 泛型别名
在 TypeScript 中,除了接口(interface)可以使用泛型之外,类型别名(type)同样也支持泛型语法。
其中,我们使用 “泛型别名” 结合 “联合类型”,还能玩出十分高级且优雅的花样来。
假设我们想要定义一个描述 “操作结果” 的数据结构。它只有 2 种可能:要么成功(带有核心数据),要么失败(带有错误原因)。
示例 2:使用泛型 type 封装 “请求结果”
// 定义泛型类型别名(结合联合类型)
type Result<T> =
| { status: "success"; data: T }
| { status: "error"; errorMsg: string };
// 使用泛型别名:传入 string
const result1: Result<string> = {
status: "success",
data: "文件上传完毕"
};
// 使用泛型别名:即使发生错误,类型依然能够完美校验
const result2: Result<number> = {
status: "error",
errorMsg: "网络超时"
};
if (result1.status === "success") {
console.log(result1.data);
}运行结果如下。
文件上传完毕分析:
在这个例子中,我们使用 type 结合联合类型(|),定义了一个非常优雅的 Result<T>。这在 TypeScript 中被称为 “可辨识联合(Discriminated Unions)”。
它的强大之处在于非常智能的 “类型收窄”。当我们写出 if (result1.status === "success") 这个判断后,TypeScript 会在底层自动明白:“既然状态是成功,那么这个对象里必然存在 data 属性,且绝对不可能存在 errorMsg 属性。”
这种配合泛型的严格类型推断,在处理复杂的异步请求或表单提交结果时,能够将代码的错误率降到最低。
TypeScript 接口的多泛型参数 (<K, V>)
和泛型函数一样,泛型接口也完全支持接收多个泛型参数。在实际开发中,我们见得最多的多泛型接口,一般是用来描述字典、缓存或者键值对(key-value)的数据结构。
示例 3:多泛型参数定义键值对
// 包含 2 个泛型变量:K (Key) 和 V (Value)
interface KeyValuePair<K, V> {
key: K;
value: V;
}
// 实例化:必须依次传入 2 个具体的类型
const item1: KeyValuePair<number, string> = {
key: 404,
value: "Not Found"
};
const item2: KeyValuePair<string, boolean> = {
key: "isLogin",
value: true
};
console.log(`${item1.key}: ${item1.value}`);运行结果如下。
404: Not Found分析:
很多小伙伴会问:“泛型接口(interface)和泛型别名(type)这两个,到底应该用哪个呢?” 我们应该遵循下面规则:
- 首选 interface:如果你的目的是描述一个 “标准的面向对象模型” 或者 “单纯的对象结构”(比如上面的 API 返回值封装),并且可能需要被 implements 或 extends,那么毫无疑问应该优先使用 interface。
- 必须用 type:如果你需要使用高级类型运算,比如联合类型(如 A | B)、交叉类型(A & B),或者是后续会讲到的工具类型提取,那么必须使用 type,因为 interface 做不到。
