映射类型的原理深度解析
映射类型让 TS 类型系统有了'循环':[P in keyof T] 遍历对象的每一个属性并对其类型进行变换。 一行代码即可定义 Partial、Pick、Readonly 等内置工具类型的核心逻辑。
一句话概括
映射类型让 TS 的类型系统有了”循环”——[P in keyof T] 遍历对象的每一个属性并对其类型进行变换,一行代码定义完 Partial、Pick、Readonly 等所有内置工具类型的核心逻辑。
核心知识点
1. 三元素:keyof、in、T[P]
1
2
3
4
5
6
7
8
9
10
11
12
// ① keyof:提取对象所有键名 → 联合类型
type UserKeys = keyof { id: number; name: string }; // "id" | "name"
// ② in:遍历联合类型中的每个成员
type Coord = { [K in "x" | "y"]: number }; // { x: number; y: number }
// ③ T[P]:索引访问 —— 从 T 中取出键 P 对应的类型
type User = { id: number; name: string; };
type IdType = User["id"]; // number
// 三者组合 = 映射类型的基本形态
type Clone<T> = { [P in keyof T]: T[P] }; // 一个和 T 完全一样的类型
2. 修饰符:? / -? / readonly / -readonly
1
2
3
4
5
6
7
8
9
10
11
// Partial:全部变可选(加 ?)
type MyPartial<T> = { [P in keyof T]?: T[P] };
// Required:全部移除可选(减 ?)
type MyRequired<T> = { [P in keyof T]-?: T[P] };
// Readonly:全部只读
type MyReadonly<T> = { readonly [P in keyof T]: T[P] };
// Mutable:移除所有只读修饰符
type Mutable<T> = { -readonly [P in keyof T]: T[P] };
关键:- 前缀移除修饰符,+ 显式添加(可省略)。在 -? 中,- 移除可选,? 指代可选修饰符本身。
3. as 重映射——过滤和改名(TS 4.1+)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 只保留值为 string 类型的属性
type StringProps<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface User { id: number; name: string; email: string; }
type OnlyStrings = StringProps<User>; // { name: string; email: string }
// 给所有键加 get 前缀 + 首字母大写
type Getters<T> = {
[K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};
type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }
as never = 删除该属性。TS 自动抹掉值为 never 的键——这是属性过滤的核心技巧。
4. 递归映射:DeepPartial / DeepReadonly
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? T[P] extends Function
? T[P] // 函数不递归——函数也是 object 但要跳过
: DeepPartial<T[P]>
: T[P];
};
interface Config {
server: { host: string; port: number };
retries: number;
}
type PartialConfig = DeepPartial<Config>;
// { server?: { host?: string; port?: number }; retries?: number }
// 注意:所有层级都变可选了 ✅
// 递归基:原始类型(string/number/boolean/null/undefined)不匹配 extends object
// TypeScript 条件类型有 ~50 层深度限制,日常场景完全够用
5. 映射类型重映射实战:提取事件处理器
1
2
3
4
5
6
7
8
9
10
11
12
13
// 从组件 Props 中只提取 onXxx 事件处理函数
type EventHandlers<T> = {
[K in keyof T as K extends `on${string}` ? K : never]: T[K];
};
interface ButtonProps {
onClick: (e: MouseEvent) => void;
onFocus: (e: FocusEvent) => void;
disabled: boolean;
label: string;
}
type Handlers = EventHandlers<ButtonProps>;
// { onClick: (e: MouseEvent) => void; onFocus: (e: FocusEvent) => void }
其实你每天都在用
Partial<Todo>:编辑表单的所有字段都可选,一个类型搞定Pick<User, "id" | "name">:列表接口只回需要的字段,避免过度返回Omit<Config, "password">:返回端脱敏,移除敏感字段Record<Role, Permission[]>:角色-权限映射字典,键值类型双双限定Readonly<AppConfig>:全局配置对象类型标注,防止误写config.theme = "dark"
常见误解(FAQ)
❌ 误区 1:「in 在类型里和 JS for-in 是一回事」
完全不是。[P in keyof T] 里的 in 是类型运算符,只在 type 定义里有效,遍历的是联合类型的成员,编译后消失。JS 的 for…in 遍历对象可枚举属性,是运行时操作。除了长得像,没有交集。
❌ 误区 2:「映射类型会产生运行时代码」
和所有高级类型一样,映射类型是纯编译时机制。Partial<Todo> 编译后等价于你手写了一个所有字段都带 ? 的 interface,不产生任何 JS 代码。
❌ 误区 3:「as never 就是简单的删属性」
本质是条件类型:不满足条件的键映射为 never,TS 自动从对象类型中移除 never 值的键。等价于先做条件判断,再对满足条件的键做 Pick。理解这一点,才能灵活组合条件类型和映射类型。
❌ 误区 4:「DeepPartial 会无线递归炸掉编译器」
不会。TypeScript 的条件类型有 50 层递归深度限制,且基本类型(string/number/boolean 等)不满足 extends object,形成递归基(base case)。写递归映射类型的关键是:识别什么情况下不该继续递归(如遇到函数类型)。
一句话总结
记住一个公式:[P in keyof T]?: T[P] = Partial。加 ? 变可选,加 -? 变必填,加 readonly 变只读,加 as 能过滤和重命名,套个递归就是深度的——所有内置工具类型都是这个模板的排列组合。