在使用原生 JavaScript 时,如果想要基于一个旧数组来生成一个新数组,我们会很自然地使用数组的 map() 方法来遍历它。
而 TypeScript 敏锐地借用了这个思维,并将其引入到了 “类型” 的世界中,这就是咱们在进阶阶段中绝对绕不开的高级技巧:映射类型(Mapped Types)。
TypeScript 映射类型是什么?
在 TypeScript 中,映射类型允许我们遍历一个已知的对象类型(interface 或 type),并以此为基础,动态地生成一个全新的对象类型。
语法:
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 等类型,本质上全部都是使用今天学的 “映射类型” 写出来的。掌握了映射类型,小伙伴们就拥有了读懂、甚至重写这些内置工具的能力了。
