文章

映射类型的原理深度解析

深入解析TypeScript映射类型的keyof操作符、in关键字、索引访问类型及readonly/optional修饰符

映射类型的原理深度解析

一句话概括

映射类型是 TypeScript 提供的一种在类型层面遍历已有类型的所有键,对其逐一进行变换后生成新类型的编译时工具,是类型系统里的”循环结构”。

背景

在日常前端开发中,我们经常需要写这样的代码:把某个接口的每个属性变成可选、把某个类型的值变成只读、把一组 API 响应类型统一加上 loading 状态……如果每个类型都手写一遍,费时且容易出错,维护成本极高。

回顾前文——条件类型的使用 让我们学会了用 extends 做类型分支判断。但如果我们想在每个属性上都做一次条件判断,条件类型本身无法遍历键。映射类型正是为此而生,它配合 keyofin,让类型也拥有了”循环”的能力。

在面试中,映射类型几乎是 TypeScript 高阶面试的必考题——从手写 PartialRequired,到实现 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 遍历每一个键,绑定到变量 P
  • T[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单目索引查询操作符,接受一个类型,返回该类型所有可枚举属性名的联合类型(stringnumbersymbol)。
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 的返回值在数组类型上会包含数组方法相关的键以及 lengthnumber(数值索引)和 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 代码。

关键实现机制

  1. 联合类型迭代[P in Union] 的工作方式类似于对联合类型的每个元素执行一次映射,生成结果的并集
  2. 属性修饰符的合并-?+? 是在映射过程中叠加在现有修饰符上的操作,而非替换
  3. 结构化类型兼容:映射类型产生的是新结构,与原始类型通过结构兼容性(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 对无限递归的检测机制

相关主题

本文由作者按照 CC BY 4.0 进行授权