TypeScript 泛型接口与泛型别名

在真实的项目开发中,除了封装通用的工具函数,我们绝大多数的时间都是在和各种 “数据结构” 打交道(比如接收并处理 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 返回值封装),并且可能需要被 implementsextends,那么毫无疑问应该优先使用 interface。
  • 必须用 type:如果你需要使用高级类型运算,比如联合类型(如 A | B)、交叉类型(A & B),或者是后续会讲到的工具类型提取,那么必须使用 type,因为 interface 做不到。
给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号