映射类型通过遍历一组键来构建新的对象类型。{ [K in keyof T]: boolean } 的意思是“对于 T 的每个键 K,都有一个名为 K、类型为 boolean 的属性”:
给 Features 添加一个键,Flags<Features> 也会要求这个键。映射类型只在编译时存在:它们描述对象,而不会创建对象。
语法
映射类型由三部分组成:一个键变量,一个要遍历的键的联合类型,以及属性类型(属性类型中可以使用键变量)。
type MappedType = {
[K in Keys]: PropertyType; // K takes each member of Keys in turn
};
Keys 可以是任何由字符串、数字或 symbol 组成的联合类型,不一定要来自 keyof:
这正是 Record<Size, number> 所做的事;Record 就是一个一行的映射类型。属性类型可以使用键:{ [K in keyof T]: T[K] } 通过索引访问复制每个属性的类型,而 { [K in keyof T]: T[K] | null } 让每个属性都可以为 null。
修饰符:带加号和减号的 readonly 与 ?
映射类型可以一次性给所有属性添加或移除 readonly 和可选(?)修饰符。前缀 + 表示添加(什么都不写时默认就是添加),- 表示移除:
最后一行说明 readonly 只是编译时的规则:这次赋值被报告了(这里用 @ts-expect-error 屏蔽),但生成的 JavaScript 照样执行了它。-? 不只是去掉问号:它还会从属性类型中去掉 undefined,所以 AllRequired<Account> 会拒绝 { id: 1, email: undefined },报错 error TS2322: Type 'undefined' is not assignable to type 'string'。
Partial、Readonly、Pick 和 Record 是怎么构建的
用来改造对象结构的内置工具类型都是映射类型。下面是 TypeScript 自带的 lib.es5.d.ts 中的定义:
type Partial<T> = { [P in keyof T]?: T[P] };
type Required<T> = { [P in keyof T]-?: T[P] };
type Readonly<T> = { readonly [P in keyof T]: T[P] };
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
type Record<K extends keyof any, T> = { [P in K]: T };
读懂它们是很好的练习:Pick 只遍历你传入的键,Record 不需要源类型,并给每个键相同的值类型。Omit 本身不是映射类型,而是 Pick<T, Exclude<keyof T, K>>。工具类型参考列出了全部工具类型。
用 as 重映射键
键后面的 as 子句可以改变属性名。和模板字面量类型结合使用,就能从旧名字生成新名字:
之所以要写 string & K,是因为 keyof T 可能包含 number 和 symbol 键,而 Capitalize 只接受字符串。这个交叉类型保留字符串键,去掉其余的键。
用 never 过滤键
如果 as 子句对某个键得到 never,这个键就会被删除。结合条件类型,你就可以根据属性的类型来保留或丢弃属性:
DataOnly 这样的类型很适合描述经过 JSON.stringify 之后还剩下什么,或者一个表单编辑哪些内容:只有字段,没有方法。
哪些修饰符会被保留
对某个类型 T 基于 keyof T 的映射类型称为同态映射类型,它会从 T 复制每个属性的 readonly 和 ? 修饰符。基于普通键联合类型的映射类型则一开始没有任何修饰符:
type Account = { readonly id: number; email?: string };
type Copy<T> = { [K in keyof T]: T[K] };
type A = Copy<Account>;
// { readonly id: number; email?: string } (modifiers kept)
type B = { [K in "id" | "email"]: Account[K] };
// { id: number; email: string | undefined } (email is required now)
B 的 email 类型中仍然有 undefined,因为 Account["email"] 包含它,但这个属性本身已经不再是可选的:{ id: 1 } 会被拒绝。这就是为什么 Partial<T> 等工具类型都基于 keyof T 做映射:它们保留所有没有明确修改的东西。
泛型的同态映射类型应用到数组或元组上时,得到的是数组或元组,而不是带数字键的对象。Readonly<string[]> 是 readonly string[];对于 type Box<T> = { [K in keyof T]: { value: T[K] } },Box<[string, number]> 是 [{ value: string }, { value: number }]。
映射类型与运行时代码
由于映射类型只是描述对象,构建这种对象的函数需要自己的运行时循环,再由类型来描述它的结果:
回调从映射类型中得到参数类型(一个的 v 是 string,另一个是 number),不需要任何标注。如果你要找的是 Map 类而不是类型映射,请看 TypeScript 中的 Map。
常见问题
TypeScript 中的映射类型是什么?
通过遍历一个键的联合类型来创建对象类型的类型:{ [K in Keys]: SomeType }。键最常见的来源是 keyof T,这样新类型就拥有和 T 相同的键,只是属性类型被转换了,比如 type Flags<T> = { [K in keyof T]: boolean }。
映射类型中的 +readonly、-readonly、+? 和 -? 是什么意思?
它们用来添加或移除修饰符。readonly 或 +readonly 让每个属性都变成只读,-readonly 去掉只读。? 或 +? 让每个属性都变成可选,-? 让它们变成必选,并且从类型中去掉 undefined。Required<T> 就是用 -? 写的。
如何在映射类型中重命名键?
在键后面使用 as 子句:{ [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] } 会把 name 变成 getName。在 as 子句中把键映射为 never 就会删除它,这就是过滤属性的方法。
映射类型和 TypeScript 中的 Map 是一回事吗?
不是。映射类型是编译时的类型转换,不会产生任何代码。Map<K, V> 是 JavaScript 的 Map 类,是运行时的键值对集合。搜索“typescript map type”的人,两者都有可能是想找的。
Partial 和 Readonly 是怎么实现的?
它们在标准库中就是映射类型:type Partial<T> = { [P in keyof T]?: T[P] } 和 type Readonly<T> = { readonly [P in keyof T]: T[P] }。Pick 和 Record 也是映射类型;Omit 则是 Pick 与 Exclude 的组合。