映射类型遍历一个类型的所有键,生成新类型。它是 Partial、Pick、Record 等工具类型的实现机制,也是"批量改写类型"的主要手段。
修饰符
1// 映射类型的修饰符
2
3interface User {
4 readonly id: number;
5 name: string;
6 email?: string;
7}
8
9// +? / ? 添加可选;-? 移除可选
10type Optional<T> = { [K in keyof T]?: T[K] };
11type Required2<T> = { [K in keyof T]-?: T[K] };
12
13// +readonly / readonly 添加只读;-readonly 移除只读
14type Immutable<T> = { readonly [K in keyof T]: T[K] };
15type Mutable<T> = { -readonly [K in keyof T]: T[K] };
16
17// 组合:去掉只读 + 去掉可选
18type MutableRequired<T> = { -readonly [K in keyof T]-?: T[K] };
19
20// 验证:Required2 去掉了 ?
21type R = Required2<User>;
22const r: R = { id: 1, name: "a", email: "b" }; // email 变成必需
23
24// 验证:Mutable 去掉了 readonly
25type M = Mutable<User>;
26const m: M = { id: 1, name: "a" };
27m.id = 2; // ✓ 不再是只读
28
29// 验证:Optional 全部可选
30type O = Optional<User>;
31const o: O = {}; // ✓ 全部可选
32
33// ---- 键重映射 as ----
34// 加前缀
35type Getters<T> = {
36 [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
37};
38type G = Getters<{ id: number; name: string }>;
39// { getId: () => number; getName: () => string }
40
41// 去前缀
42type RemovePrefix<T, P extends string> = {
43 [K in keyof T as K extends `${P}${infer Rest}` ? Rest : K]: T[K];
44};
45
46// 按值类型过滤
47type PickByType<T, V> = {
48 [K in keyof T as T[K] extends V ? K : never]: T[K];
49};
50type OnlyStrings = PickByType<User, string>; // { name: string; email?: string }
51
52// 排除某些键
53type OmitByType<T, V> = {
54 [K in keyof T as T[K] extends V ? never : K]: T[K];
55};
56
57// 按键名过滤
58type OnlyKeysStartingWith<T, P extends string> = {
59 [K in keyof T as K extends `${P}${string}` ? K : never]: T[K];
60};
61
62// 转成 getter/setter 对
63type Accessors<T> = {
64 [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
65} & {
66 [K in keyof T as `set${Capitalize<string & K>}`]: (v: T[K]) => void;
67};
68
69// ---- 从联合生成对象 ----
70type Flags = "a" | "b";
71type FlagMap = { [K in Flags]: boolean }; // { a: boolean; b: boolean }
72
73// 用 Record 等价
74type FlagMap2 = Record<Flags, boolean>;
75
76// ---- 同态 vs 非同态(修饰符保留)----
77interface U { readonly a: number; b?: string }
78
79// 同态:直接 keyof T,保留修饰符
80type Homo<T> = { [K in keyof T]: T[K] };
81type H1 = Homo<U>; // readonly a, b? 都保留
82
83// 同态:as 重映射也保留修饰符(实测)
84type HomoAs<T> = { [K in keyof T as K]: T[K] };
85type H2 = HomoAs<U>; // 修饰符也保留
86
87// 非同态:对 keyof T 做运算,修饰符丢失
88type NonHomo<T> = { [K in keyof T & string]: T[K] };
89type H3 = NonHomo<U>; // readonly 和 ? 都丢失
90
91// 验证 H1 保留 readonly
92const h1: H1 = { a: 1 };
93// @ts-expect-error TS2540: H1 保留了 readonly
94h1.a = 2;
95
96// 验证 H3 丢失 readonly 和 ?:b 从可选变成必需,a 从只读变成可写
97const h3: H3 = { a: 1, b: "x" }; // b 现在必须提供
98h3.a = 2; // ✓ 不再是只读
99
100const g: G = { getId: () => 1, getName: () => "a" };
101const onlyStr: OnlyStrings = { name: "a" };
102const withoutStr: OmitByType<User, string> = { id: 1 };
103const flags: FlagMap = { a: true, b: false };
104const acc: Accessors<{ x: number }> = { getX: () => 1, setX: (v) => { void v; } };
105const mp: MutableRequired<User> = { id: 1, name: "a", email: "b" };
106
107console.log(r, m, o, g.getId(), onlyStr.name, withoutStr.id, flags.a, acc.getX(), mp.id, h1.a, h3.a);
1{ id: 1, name: 'a', email: 'b' } { id: 2, name: 'a' } {} 1 a 1 true 1 1 2 2
| 修饰符 | 作用 |
|---|---|
? 或 +? | 添加可选 |
-? | 移除可选 |
readonly 或 +readonly | 添加只读 |
-readonly | 移除只读 |
+ 是默认值,所以 ? 和 +? 等价。可以组合:
1// 去掉只读 + 去掉可选
2type MutableRequired<T> = { -readonly [K in keyof T]-?: T[K] };
键重映射 as(TS 4.1+)
as 子句可以改名或过滤键:
1// 加前缀
2type Getters<T> = {
3 [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
4};
5type G = Getters<{ id: number; name: string }>;
6// { getId: () => number; getName: () => string }
string & K 是必需的:K 的类型是 string | number | symbol,而模板字面量只接受能转成字符串的类型。
用 as 过滤键
as 返回 never 时,该键被移除:
1// 按值类型过滤
2type PickByType<T, V> = {
3 [K in keyof T as T[K] extends V ? K : never]: T[K];
4};
5type OnlyStrings = PickByType<User, string>; // 只保留 string 值的键
6
7// 按值类型排除
8type OmitByType<T, V> = {
9 [K in keyof T as T[K] extends V ? never : K]: T[K];
10};
11
12// 按键名前缀过滤
13type OnlyKeysStartingWith<T, P extends string> = {
14 [K in keyof T as K extends `${P}${string}` ? K : never]: T[K];
15};
去前缀
1type RemovePrefix<T, P extends string> = {
2 [K in keyof T as K extends `${P}${infer Rest}` ? Rest : K]: T[K];
3};
生成成对的 getter/setter
1type Accessors<T> = {
2 [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
3} & {
4 [K in keyof T as `set${Capitalize<string & K>}`]: (v: T[K]) => void;
5};
从联合生成对象
1type Flags = "a" | "b";
2type FlagMap = { [K in Flags]: boolean }; // { a: boolean; b: boolean }
3
4// 等价写法
5type FlagMap2 = Record<Flags, boolean>;
注意这里遍历的是联合而不是 keyof T——这是映射类型的通用形式 [K in SomeUnion]。
同态映射:修饰符保留规则
这是实现工具类型时最容易出错的地方。实测结论(U = { readonly a: number; b?: string }):
| 写法 | 保留 readonly | 保留 ? |
|---|---|---|
{ [K in keyof T]: T[K] } | ✓ | ✓ |
{ [K in keyof T as K]: T[K] } | ✓ | ✓ |
{ [K in keyof T & string]: T[K] } | ✗ | ✗ |
关键结论:
- 直接用
keyof T或keyof T as ...都是同态的,修饰符保留 - 一旦对
keyof T做运算(如keyof T & string),就不再同态,修饰符全部丢失
as 重映射不破坏同态性这点容易记反。{ [K in keyof T as K]: T[K] } 看起来像"变换了键",但它仍然是同态的,修饰符照常保留。真正破坏同态性的是对 keyof T 本身做运算。
实测验证:NonHomo<U> 的 a 不再只读、b 不再可选:
1type NonHomo<T> = { [K in keyof T & string]: T[K] };
2const h3: NonHomo<U> = { a: 1, b: "x" }; // b 现在必须提供
3h3.a = 2; // 不再是只读
常用映射类型一览
| 名称 | 实现 | 作用 |
|---|---|---|
Partial<T> | { [K in keyof T]?: T[K] } | 全部可选 |
Required<T> | { [K in keyof T]-?: T[K] } | 全部必需 |
Readonly<T> | { readonly [K in keyof T]: T[K] } | 全部只读 |
Pick<T, K> | { [P in K]: T[P] } | 保留指定键 |
Record<K, V> | { [P in K]: V } | 构造键值对象 |
Getters<T> | 见上 | 批量加前缀 |
相关
- 高级类型 —— 映射类型的基础
- 条件类型与 infer —— 与条件类型组合
- 模板字面量类型 ——
as子句里的字符串变换 - 内置工具类型 —— 这些映射的实际应用