TypeScript 映射类型

在使用原生 JavaScript 时,如果想要基于一个旧数组来生成一个新数组,我们会很自然地使用数组的 map() 方法来遍历它。

而 TypeScript 敏锐地借用了这个思维,并将其引入到了 “类型” 的世界中,这就是咱们在进阶阶段中绝对绕不开的高级技巧:映射类型(Mapped Types)

TypeScript 映射类型是什么?

在 TypeScript 中,映射类型允许我们遍历一个已知的对象类型(interfacetype),并以此为基础,动态地生成一个全新的对象类型。

语法:

type MappedType<T> = {
    [K in keyof T]: T[K];
};

说明:

映射类型的语法非常特别,它使用了 [K in keyof T] 这样的结构,看起来就像是在类型里写了一个 for...in 循环。

假设有一个基础的 User 接口,现在我们要用映射类型把它一模一样地 “克隆” 出来。请看下面例子:

示例 1:克隆类型

// 定义接口
interface User {
    id: number;
    name: string;
    age: number;
}

// 使用映射类型遍历 User
type CloneUser = {
    [K in keyof User]: User[K];
};

const myUser: CloneUser = {
    id: 1001,
    name: "Jack",
    age: 20
};

console.log(myUser.name);

运行结果如下。

Jack

分析:

在 [K in keyof User]: User[K] 这句看似火星文的代码中,User[K] 叫做 “索引访问类型”,它能准确获取到对应键的值类型。TypeScript 会在底层执行遍历,自动帮你生成与原接口完全一致的新类型。

TypeScript 操控修饰符(+ 与 -)

如果映射类型只是原封不动地克隆,那它毫无意义。映射类型最强大的地方在于:在遍历的过程中,我们可以对属性的 “修饰符” 进行暴力的改造。

在 TypeScript 中,最常见的修饰符有两个:

  • ?:将属性变为可选。
  • readonly:将属性变为只读。

在映射类型中,我们可以使用 “+”(添加修饰符)和 “-”(移除修饰符)来操控它们。如果不写符号,则默认就是 “+”。

示例 2:手动实现 Partial 类型

type MyPartial<T> = {
    // 遍历 T 的所有键
    // 在键名前面加上 ?(等价于 +?),将所有属性强行变成可选
    [K in keyof T]?: T[K];
};

interface Article {
    title: string;
    content: string;
}

const updatePayload: MyPartial<Article> = {
    title: "TypeScript 教程"
    // content 缺失也不会报错
};

console.log(updatePayload);

运行结果如下。

{ title: 'TypeScript 教程' }

分析:

在前面的章节中,我们学过了 Partial<T> 类型。现在有了映射类型,完全可以自己手写一个。

示例 3:剥夺可选属性(-?)

// 定义一个松散的配置接口
interface AppConfig {
    theme?: string;
    timeout?: number;
    debug?: boolean;
}

// 使用 “-?” 强行删掉原有接口里的可选修饰符
type StrictConfig = {
    [K in keyof AppConfig]-?: AppConfig[K];
};

// 报错:类型 "{ theme: string; }" 中缺少属性 "timeout" 和 "debug"
const config: StrictConfig = {
    theme: "dark"
};

分析:

有时候,我们会拿到一个所有属性都是可选(?)的接口。但后续的业务可能会变得非常严苛,比如要求所有字段必须必填。此时,我们就可以用 “-?” 把问号强行剔除。

示例 4:剥夺只读属性(-readonly)

// 定义一个所有属性都被锁死的接口
interface LockedUser {
    readonly id: number;
    readonly name: string;
}

// 使用 "-readonly" 强行移除只读修饰符
type MutableUser = {
    -readonly [K in keyof LockedUser]: LockedUser[K];
};

const user: MutableUser = {
    id: 1001,
    name: "Jack"
};

// 完美解锁,现在可以随意修改了
user.name = "Rose";
console.log(user.name);

运行结果如下。

Rose

分析:

通过使用 -readonly,我们创造了一个名为 Mutable(可变的)的自定义工具类型,这在处理第三方库提供得过于严格的数据类型时,简直是 “救命稻草”。

TypeScript 键名重映射 (as)

在 TypeScript 4.1 之后,映射类型迎来了一次真正意义上的 “史诗级加强”。以前我们只能改变属性的 “值” 的类型(比如把可选变必填)。而现在,我们可以使用 as 关键字,在遍历的过程中,对生成的 “键名(Key)” 进行修改和重命名。

假设我们有一个包含状态的接口,然后希望根据这个接口,自动生成一套对应状态的 Getter 函数接口(比如把 name 变成 getName)。如果手动写,一旦状态增加,getter 也得跟着手动加,非常痛苦。

这里我们将结合 TypeScript 内置的字符串工具类型 Capitalize(将首字母大写)来实现这个高级体操。

示例 5:自动生成 getter接口

interface Person {
    name: string;
    age: number;
    email: string;
}

type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type PersonGetters = Getters<Person>;

const p: PersonGetters = {
    getName: () => "Jack",
    getAge: () => 20,
    getEmail: () => "jack@leafcoding.com"
};

console.log(p.getName());

运行结果如下。

Jack

分析:

type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

上面这段代码是非常硬核的 “类型体操”。我们可以把它像洋葱一样一层层剥开:

  • [K in keyof T]:表示正常遍历键名。
  • as `get${Capitalize<string & K>}`:使用 as 关键字配合模板字符串,表示将键名重写为 “get + 后面的内容”。其中,Capitalize 是 TypeScript 内置的字符串工具,用于将首字母转换为大写。那么为什么不直接写 Capitalize<K> 呢? 这里因为一个对象的键(K)理论上可能是 “string | number | symbol” 三种类型,而 Capitalize 规定它只接收字符串。所以我们用 string & K 告诉 TypeScript:“请强制过滤出字符串类型的键给我用”。
  • () => T[K]:这是生成的新属性的 “值” 的类型。它表示把原本直接返回的基础值,变成一个无参的 Getter 函数,并且这个函数的返回值类型与原属性(T[K])保持绝对一致。

对于初学的小伙伴来说,这个例子看着有点像 “天书”,但它正是 Vuex、Pinia 等状态管理库底层源码中频繁使用的技术。使用 as 关键字来实现重映射,我们可以在类型层面实现真正的 “全自动代码生成”,非常的高效。

提示: 前面 “TypeScript 内置泛型工具类型” 这一节中介绍的 Partial、Pick、Omit、Record 等类型,本质上全部都是使用今天学的 “映射类型” 写出来的。掌握了映射类型,小伙伴们就拥有了读懂、甚至重写这些内置工具的能力了。

给站长反馈

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

邮箱:lvyenet@vip.qq.com

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