映射类型

2026-09-09 00:00    #typescript  

映射类型遍历一个类型的所有键,生成新类型。它是 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] }✗✗

关键结论:

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>见上批量加前缀

相关