映射类型的原理深度解析
深入解析TypeScript映射类型的keyof操作符、in关键字、索引访问类型及readonly/optional修饰符
一句话概括
映射类型是 TypeScript 提供的一种在类型层面遍历已有类型的所有键,对其逐一进行变换后生成新类型的编译时工具,是类型系统里的”循环结构”。
背景
在日常前端开发中,我们经常需要写这样的代码:把某个接口的每个属性变成可选、把某个类型的值变成只读、把一组 API 响应类型统一加上 loading 状态……如果每个类型都手写一遍,费时且容易出错,维护成本极高。
回顾前文——条件类型的使用 让我们学会了用 extends 做类型分支判断。但如果我们想在每个属性上都做一次条件判断,条件类型本身无法遍历键。映射类型正是为此而生,它配合 keyof 和 in,让类型也拥有了”循环”的能力。
在面试中,映射类型几乎是 TypeScript 高阶面试的必考题——从手写 Partial、Required,到实现 DeepReadonly,再到 as 重映射考察的是否真正理解类型系统的内部工作原理。
概念与定义
什么是映射类型?
映射类型是 TypeScript 2.1 引入的特性,其核心语法如下:
1
2
3
4
// 语法结构
type MappedType<T> = {
[P in keyof T]: T[P]; // 对 T 的每个属性 P 做变换
};
[P in keyof T]:keyof T取出 T 的所有键,in遍历每一个键,绑定到变量PT[P]:索引访问类型,取出键 P 对应的属性类型- 通过在
:前后加修饰符(readonly、?)或做类型变换,得到新类型
简单来说,映射类型 = keyof + in + 索引访问,三者缺一不可。
最小示例
1
2
3
4
5
6
7
8
9
10
11
12
13
// 原始类型
interface User {
id: number;
name: string;
age: number;
}
// 最小映射类型:原样复制属性
type ReadonlyUser = {
readonly [P in keyof User]: User[P];
};
// 等价于:
// { readonly id: number; readonly name: string; readonly age: number; }
这是映射类型的最简形态——只有一个 [P in keyof T],在此基础上加修饰符或做变换,就得到完整能力。
核心知识点拆解
1. keyof 操作符
keyof 是单目索引查询操作符,接受一个类型,返回该类型所有可枚举属性名的联合类型(string | number | symbol)。 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface Point {
x: number;
y: number;
}
// keyof Point 的结果是 "x" | "y"
type Keys = keyof Point; // "x" | "y"
// 配合泛型:约束泛型参数必须有 K 作为键
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const p: Point = { x: 1, y: 2 };
const val = getProperty(p, "x"); // val 类型为 number
// getProperty(p, "z"); // 编译错误:Argument of type '"z"' is not assignable to parameter of type 'keyof Point'
注意:keyof 的返回值在数组类型上会包含数组方法相关的键以及 length、number(数值索引)和 symbol:
1
type ArrKeys = keyof string[]; // number | "length" | "push" | "pop" | ... | symbol
2. in 关键字
in 在映射类型中是类型运算符,负责遍历一个联合类型,对每个元素逐一生成属性。
1
2
3
4
5
6
// in 遍历字符串字面量联合类型
type Direction = "north" | "south" | "east" | "west";
type DirectionMap = {
[D in Direction]: number; // 每个方向对应一个数字
};
// 结果:{ north: number; south: number; east: number; west: number; }
in 只能用在类型别名(type alias)的索引签名中,不能在普通代码里使用:
1
2
3
4
5
6
// ❌ 错误:in 不能用于值层面
for (let k in someObj) { }
// ✅ 正确:in 用于类型层面
type Keys = "a" | "b" | "c";
type Obj = { [K in Keys]: string };
3. 索引访问类型
索引访问类型(Indexed Access Types)使用 T[K] 语法,通过键获取类型中对应属性的类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface Config {
host: string;
port: number;
debug: boolean;
}
// 直接通过字面量键访问
type HostType = Config["host"]; // string
// 通过 keyof 访问所有值的联合类型
type ConfigValues = Config[keyof Config]; // string | number | boolean
// 嵌套访问
interface Nested {
user: { name: string; age: number };
admin: { name: string; role: string };
}
type AdminRole = Nested["admin"]["role"]; // string
在映射类型中,T[P] 的含义是:对于当前遍历到的键 P,从 T 中取出对应的属性类型。这是映射类型实现属性类型复用的关键。
4. readonly 修饰符
在映射类型中,readonly 可以加在属性名前,将新类型的所有属性变为只读:
1
2
3
4
5
6
7
8
9
10
11
12
13
interface MutablePerson {
name: string;
age: number;
}
// 加 readonly
type ReadonlyPerson = {
readonly [P in keyof MutablePerson]: MutablePerson[P];
};
// 验证
const person: ReadonlyPerson = { name: "Alice", age: 25 };
// person.name = "Bob"; // 编译错误:Cannot assign to 'name' because it is a read-only property
5. 可选修饰符 -? 与 +?
TypeScript 默认添加 ? 会将属性变为可选。若要移除可选性(将可选变为必填),使用 -?:
1
2
3
4
5
6
7
8
9
10
11
interface OptionalConfig {
timeout?: number;
retries?: number;
debug?: boolean;
}
// 移除可选性:全部变为必填
type RequiredConfig = {
[P in keyof OptionalConfig]-?: OptionalConfig[P];
};
// 等价于内置类型:Required<OptionalConfig>
同理,显式使用 +? 可以将属性明确标记为可选(与默认行为一致,但在需要移除可选的场景下,-? 更有表达力):
1
2
3
4
5
6
7
8
9
// Partial 的标准实现(TypeScript 内置)
type MyPartial<T> = {
[P in keyof T]?: T[P];
};
// Required 的标准实现
type MyRequired<T> = {
[P in keyof T]-?: T[P];
};
💡 记忆技巧:
+表示添加该修饰符的特性,-表示移除该修饰符的特性。readonly同理,-readonly可移除只读性。
6. 映射类型与条件类型结合(as 重映射)
TypeScript 2.8 引入了重映射(Remapping),使用 as 关键字在映射过程中对键进行二次变换:
1
2
3
4
5
6
7
8
9
10
11
12
13
interface Original {
foo_id: number;
foo_name: string;
bar_value: number;
}
// 将键中的 foo_ 前缀去掉
type RemovePrefix<T> = {
[K in keyof T as K extends `foo_${infer R}` ? R : K]: T[K];
};
type Result = RemovePrefix<Original>;
// { id: number; name: string; bar_value: number; }
as 后面的表达式必须返回 string | number | symbol,最常见的用法包括:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 用条件类型过滤掉某些键(返回 never 则该属性被删除)
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface Mixed {
id: number;
name: string;
age: number;
desc: string;
}
type StringOnly = OnlyStrings<Mixed>;
// { name: string; desc: string; }
as never 是过滤属性的经典技巧——将不符合条件的键映射为 never,TypeScript 会自动将其从结果类型中移除。
实战案例
案例一:实现 DeepReadonly 类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 普通 Readonly 只能处理一层
type ShallowReadonly<T> = {
readonly [P in keyof T]: T[P];
};
// DeepReadonly:递归地将所有嵌套属性变为只读
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? T[P] extends Function
? T[P] // 函数保持不变
: DeepReadonly<T[P]> // 对象递归处理
: T[P];
};
// 验证
interface Company {
name: string;
ceo: { name: string; age: number }; // 嵌套对象
}
const c: DeepReadonly<Company> = {
name: "TechCorp",
ceo: { name: "Alice", age: 40 },
};
// c.name = "NewCorp"; // 编译错误:只读
// c.ceo.name = "Bob"; // 编译错误:嵌套属性也是只读
案例二:实现 Partial / Required / Pick / Readonly
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Partial:所有属性变为可选
type MyPartial<T> = {
[P in keyof T]?: T[P];
};
// Required:所有属性变为必填(移除所有可选标记)
type MyRequired<T> = {
[P in keyof T]-?: T[P];
};
// Pick:从 T 中挑选出 K 指定的属性
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Readonly:所有属性变为只读
type MyReadonly<T> = {
readonly [P in keyof T]: T[P];
};
// 使用示例
interface Article {
id: number;
title: string;
content: string;
author: string;
}
type Draft = MyPartial<Article>; // id? title? content? author?
type Published = MyPick<Article, "id" | "title" | "content">; // 只保留发布所需字段
type Locked = MyReadonly<Article>; // 所有字段只读
type Fixed = MyRequired<Draft>; // Draft 中可选字段全变必填
案例三:实现基于条件的属性过滤
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 需求:挑出所有函数类型的属性
type FunctionProps<T> = {
[P in keyof T as T[P] extends Function ? P : never]: T[P];
};
interface Service {
start: () => void;
stop: () => void;
status: number;
name: string;
}
// 只保留函数类型的属性
type Handlers = FunctionProps<Service>;
// { start: () => void; stop: () => void; }
// 另一个实战场景:基于属性名模式过滤
type Getters<T> = {
[P in keyof T as P extends `get${infer R}` ? R : never]: T[P];
};
interface APIResponse {
getData: () => string;
getList: () => string[];
setData: (val: string) => void;
data: string;
}
// 只保留 get 开头的属性,键名去掉 get 前缀
type ExtractedGetters = Getters<APIResponse>;
// { Data: () => string; List: () => string[]; }
底层原理
编译时行为
映射类型的处理发生在 TypeScript 编译阶段,具体流程如下:
1
2
3
4
5
6
7
8
9
10
源代码(映射类型)
↓
类型解析阶段
├── 1. keyof T → 展开为键的联合类型
├── 2. in 联合类型 → 对每个元素生成一个属性
└── 3. T[P] / 修饰符 → 确定属性类型
↓
生成中间类型(TS内部表示)
↓
擦除为 JavaScript(映射类型不进入运行时)
映射类型是零运行时开销的类型层面抽象——编译后完全消失,不产生任何 JavaScript 代码。
关键实现机制
- 联合类型迭代:
[P in Union]的工作方式类似于对联合类型的每个元素执行一次映射,生成结果的并集 - 属性修饰符的合并:
-?和+?是在映射过程中叠加在现有修饰符上的操作,而非替换 - 结构化类型兼容:映射类型产生的是新结构,与原始类型通过结构兼容性(structural typing)进行比较
映射类型的限制
1
2
3
4
5
6
7
// ❌ keyof 返回的是 string | number | symbol
// 无法直接从中提取"原始字符串"类型
type Wrong = { [P in "a" | "b"]: P }; // 正确
type AlsoWrong = { [P in keyof any]: P }; // keyof any = string | number | symbol
// ✅ 正确的字符串字面量约束
type Correct = { [P in "x" | "y"]: P };
💡 人话总结
映射类型就像是类型系统的”循环语句”——keyof 找出所有键,in 一个个遍历,T[P] 取出对应的值类型。你可以在遍历过程中给属性加 readonly、加 ?、删掉某些键(as never)、或者把键名改掉(as 重映射)。
核心公式:映射类型 = keyof + in + 索引访问。掌握这三条腿,你就掌握了 TypeScript 类型系统里最强大的工具之一。
高频面试题解析
Q1:keyof 和 in 在映射类型中分别起什么作用?
参考答案:
keyof 的作用是提取类型的所有键,返回一个联合类型。例如 keyof { a: string; b: number } 返回 "a" | "b"。
in 的作用是遍历这个联合类型中的每个元素,为每个元素生成一个属性。
两者配合:[P in keyof T] 表示”遍历 T 的每一个键”。keyof 负责”知道有哪些键”,in 负责”一个一个处理键”。
Q2:如何实现一个将所有属性变为可选的类型?
参考答案:
1
2
3
type MyPartial<T> = {
[P in keyof T]?: T[P];
};
核心原理:在映射类型的属性声明前加 ?,将每个属性标记为可选。T[P] 通过索引访问保留原始属性类型。
Q3:映射类型中的 as 重映射是什么?有什么用?
参考答案:
as 是 TypeScript 2.8 引入的重映射关键字,放在映射类型的键位置,对遍历到的每个键做二次变换:
1
2
3
type RemovePrefix<T> = {
[K in keyof T as K extends `_${infer R}` ? R : K]: T[K];
};
主要用途:
- 过滤属性:
[K in keyof T as T[K] extends string ? K : never]——不符合条件的返回never自动删除 - 重命名键:去掉前缀/后缀、驼峰转下划线等
- 结合条件类型做复杂键变换
Q4:手写 DeepPartial 类型
参考答案:
1
2
3
4
5
type DeepPartial<T> = T extends object
? T extends Function
? T
: { [P in keyof T]?: DeepPartial<T[P]> }
: T;
或者更简洁的版本(假设不含函数):
1
2
3
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
思路:用条件类型判断当前属性是否为对象,是则递归调用 DeepPartial,是函数则保持不变,其他类型直接保留。?: 将所有层级的属性都变为可选。
总结与扩展
核心要点
| 概念 | 作用 | 示例 |
|---|---|---|
keyof T | 提取 T 的所有键,返回联合类型 | keyof User → "id" \| "name" |
[P in keyof T] | 遍历 T 的每个键 | 对每个 P 生成一个属性 |
T[P] | 索引访问,取出对应属性类型 | User["id"] → number |
readonly [P in keyof T] | 所有属性加只读 | 不可修改属性值 |
[P in keyof T]?: | 所有属性变可选 | -? 反向移除可选 |
[K as ...] | 重映射键名 | 过滤、重命名、转换 |
延伸学习方向
- 模板字面量类型(Template Literal Types):与
as重映射结合,实现更复杂的键名变换 - 声明文件(.d.ts)与全局类型扩充:利用映射类型为第三方库添加属性
- infer 在映射类型中的应用:从嵌套对象中提取类型信息
- TypeScript 4.1+ 的递归条件类型限制:了解 TypeScript 对无限递归的检测机制