TypeScript 泛型默认值

在实际开发中,我们可能会遇到这样一种场景:假设你封装了一个带有泛型的通用类型。但在全站的 100 次调用中,有 90 次传入的泛型其实都是 string,只有 10 次是其他复杂对象。如果要求业务小弟们每次调用都必须老老实实地写上 <string>,那么他们一定会抱怨这个工具库太难用了。

为了提升代码的开发体验,TypeScript 提供了一个非常贴心的功能:泛型默认值。

TypeScript 泛型默认值的定义

TypeScript 泛型默认值的语法非常简单,它和 JavaScript 中函数的默认参数是一样的。

语法:

interface 接口名<T = 默认类型> {
    ……
}

class 类名<T = 默认类型> {
    ……
}

function 函数名<T = 默认类型>(...) {
    ……
}

说明:

我们使用等号 “=” 就可以为泛型变量指定一个用于兜底的默认类型。

示例 1:重构 API 响应接口

// 给泛型设置一个默认值:any
interface ApiResponse<T = any> {
    code: number;
    message: string;
    data: T;
}

// 场景 1:不显式传入泛型,则触发默认值
const res1: ApiResponse = {
    code: 200,
    message: "请求成功",
    data: "欢迎来到绿叶网"
};
console.log(res1.data);

// 场景 2:显式传入具体的类型,则覆盖默认值
const res2: ApiResponse<{ id: number; name: string }> = {
    code: 200,
    message: "请求成功",
    data: { 
        id: 1001, 
        name: "Jack" 
    }
};
console.log(res2.data);

运行结果如下。

欢迎来到绿叶网
{ id: 1001, name: 'Jack' }

分析:

如果不显式传入类型,那么 data 字段被自动推导为 any,此时该字段想赋什么值都可以。但如果显式传入具体的类型,此时 data 字段就会被严格限制为只包含 id 和 name 的对象。

此外,给接口加上 <T = any> 或 <T = string> 之后,我们在调用时就可以直接写成 ApiResponse,这样连尖括号都省了。

这种方式在编写 Vue 的 ref() 或 reactive() 等底层响应式 API 时使用频率非常高,因为它完美做到了 “常规场景极简、复杂场景可控”。

TypeScript 多泛型参数的默认值

当类或函数有多个泛型参数(比如 <K, V>)时,我们也可以给它们设置默认值。

但是,这里有一条严格的铁律:带有默认值的泛型参数,必须放在所有没有默认值的泛型参数的后面。这一点就和普通函数中 “可选参数必须放在必传参数后面” 的逻辑是一样的。

示例 2:字典类的泛型默认值

// V 有默认值,它必须排在没有默认值的 K 后面
class Dictionary<K, V = string> {
    private _data: Map<K, V> = new Map();

    public set(key: K, value: V): void {
        this._data.set(key, value);
    }
}

// 只传 1 个泛型
const dict1 = new Dictionary<number>();
dict1.set(1, "第一名");
console.log(dict1);

// 传入 2 个泛型
const dict2 = new Dictionary<string, boolean>();
dict2.set("isLogin", true);
console.log(dict2);

运行结果如下。

Dictionary { _data: Map(1) { 1 => '第一名' } }
Dictionary { _data: Map(1) { 'isLogin' => true } }

分析:

对于 Dictionary 这个泛型类来说,在实例化对象的时候,如果只传入 1 个泛型,那么第 2 个泛型 V 就自动使用默认值 string;如果传入 2 个泛型,那么就会完全覆盖默认值。

需要注意的是,带有默认值的泛型不能放在前面,否则 TypeScript 会报错拦截:

// 报错:必选类型参数不能在可选类型参数之后。
class BadDictionary<K = string, V> { ... } 

TypeScript 终极组合:extends 约束 + 默认值

在顶级的开源框架源码中,我们经常会看到泛型约束(extends)和泛型默认值(=)被组合在一起使用。其语法格式是:

<T extends 约束类型 = "默认类型">

这行代码的意思是:你传进来的类型必须满足我的约束条件;如果你没有传,我就默认给你一个最常用的、且满足该约束的类型。

比如在前端开发中,获取 DOM 节点是一个非常高频的操作。不同的节点有不同的类型(比如 div 是 HTMLDivElement、canvas 是 HTMLCanvasElement),但它们都继承自顶层的 HTMLElement。

接下来,我们尝试来封装一个用于获取 DOM 元素的通用函数。

示例 3:封装获取 DOM 元素的通用函数

// 定义函数:extends 约束 + 默认值
function getDomElement<T extends HTMLElement = HTMLDivElement>(id: string): T | null {
    const el = document.getElementById(id);
    return (el as T) || null;
}

// 没有显式传入类型
const container = getDomElement("app");

// 显式传入类型
const myCanvas = getDomElement<HTMLCanvasElement>("my-canvas");
if (myCanvas) {
    // 因为指定了具体类型,所以可以安全地调用 canvas 专属方法
    const ctx = myCanvas.getContext("2d");
}

分析:

上面这个例子,可以说是企业级工具类封装的典范。安全与优雅,全都要!

  • 安全:通过 “extends HTMLElement” 堵住了外部乱传类型(比如传个 string 进来)的漏洞。
  • 优雅:通过 “= HTMLDivElement” 免去了 80% 常见场景下的冗余代码。

TypeScript 类型推断与默认值的优先级(重点)

这里有一个经常在面试中被问到的细节:如果泛型既有默认值,我们在调用时又传入了实际参数,此时 TypeScript 到底听谁的呢?

结论是:类型推断的优先级永远高于默认类型。

如果在泛型函数中,TypeScript 可以通过你传入的 “真实参数” 完美推断出泛型类型,那么这个推断出的类型会直接覆盖掉默认值。默认类型只有在 “既没有显式传入尖括号、又无法从参数中推断出类型” 的绝境下,才会作为最后的救命稻草出场。

示例 4:类型推断覆盖默认值

// 定义函数:T 默认值为 string
function createArray<T = string>(length: number, value: T): T[] {
    let result: T[] = [];
    for (let i = 0; i < length; i++) {
        result.push(value);
    }
    return result;
}

// 场景 1:完全不传参数,也无法推断。T 采用默认值 string
// (注意:这里假设允许不传参数,仅为演示默认值生效的极端情况)
// let arr1: string[]

// 场景 2:传入数字。TypeScript 能够推断出 T 是 number
// 此时 number 会无情地覆盖掉默认值 string
const arr2 = createArray(3, 2026);
console.log(arr2);

运行结果如下。

[ 2026, 2026, 2026 ]

分析:

在场景 2 中,虽然我们定义了 <T = string>,但由于传入的实参是 2026,TypeScript 会推断出这里需要的是 number。因此,arr2 的最终类型被推导为 number[],而不是 string[]。

给站长反馈

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

邮箱:lvyenet@vip.qq.com

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