内置工具类型手写深度解析
一句话概括
TypeScript 内置工具类型(Partial、Required、Readonly、Pick、Omit、Record)本质上是用映射类型(Mapped Types)、条件类型(Conditional Types)和索引类型(Indexed Types)组合而成的类型体操模板,手写一遍就能从”会用”进阶到”理解类型编程”。
一、背景与意义
1.1 为什么需要工具类型?
TypeScript 的静态类型系统能捕获大量运行时错误,但现实中的类型需求千变万化——同一个接口可能需要”全部可选”、”全部只读”、”只取几个字段”等不同变体。如果为每个变体都手动声明一个接口:
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
interface User {
id: number;
name: string;
email: string;
age: number;
address: string;
}
interface PartialUser { // User 所有字段可选
id?: number;
name?: string;
email?: string;
age?: number;
address?: string;
}
interface ReadonlyUser { // User 所有字段只读
readonly id: number;
readonly name: string;
readonly email: string;
readonly age: number;
readonly address: string;
}
interface UserNameAndEmail { // 只取两个字段
name: string;
email: string;
}
这显然不可维护——每个新接口都要重写所有字段,修改源接口后所有变体也需要同步修改。工具类型(Utility Types)完美解决这个问题:
1
2
3
type PartialUser = Partial<User>;
type ReadonlyUser = Readonly<User>;
type UserNameAndEmail = Pick<User, 'name' | 'email'>;
1.2 TypeScript 内置工具类型全景
截至 TypeScript 5.x,内置工具类型超过 20 个,可分为几类:
| 类别 | 工具类型 |
|---|---|
| 属性变换 | Partial<T>、Required<T>、Readonly<T> |
| 属性选择 | Pick<T, K>、Omit<T, K> |
| 结构构造 | Record<K, T> |
| 函数提取 | Parameters<T>、ReturnType<T>、ThisParameterType<T> |
| 模板字面量 | Uppercase<T>、Lowercase<T>、Capitalize<T>、Uncapitalize<T> |
| 联合类型提取 | Exclude<T, U>、Extract<T, U>、NonNullable<T> |
本文聚焦前六种最基础也最常用的工具类型。
1.3 为什么要手写?
手写工具类型不是为了重复造轮子,而是为了理解 TypeScript 类型系统的三个核心能力:
- 映射类型(Mapped Types):遍历联合类型的每个成员,生成新类型
- 索引类型(Indexed Types):
T[K]获取属性类型 - 条件类型(Conditional Types):
T extends U ? X : Y类型级别的条件判断
掌握了这三个能力,就可以自己创造任何业务场景需要的类型变换。
二、概念与定义
2.1 映射类型(Mapped Types)
映射类型是工具类型的基石,语法为:
1
type Mapped<T> = { [P in keyof T]: T[P] };
keyof T:获取 T 的所有属性名组成的联合类型(如'name' | 'age')P in keyof T:遍历联合类型的每个成员T[P]:索引访问,获取每个属性的类型
2.2 加修饰符
映射类型支持在属性上加修饰符:
1
2
3
4
5
6
7
type Mapped<T> = {
readonly [P in keyof T]?: T[P]; // 全部只读且可选
};
// 修饰符前缀:+ 添加(默认)、- 移除
type DefaultPartial = { [P in keyof T]?: T[P] }; // 加可选
type RemoveReadonly = { -readonly [P in keyof T]: T[P] }; // 移除只读
2.3 索引类型(Indexed Types)
通过 T[K] 访问属性类型,K 可以是字面量、联合类型或 keyof T:
1
2
3
4
5
6
7
8
9
interface User {
name: string;
age: number;
roles: string[];
}
type NameType = User['name']; // string
type NameOrAge = User['name' | 'age']; // string | number
type AllValues = User[keyof User]; // string | number | string[]
2.4 关键运算符
| 运算符 | 含义 | 示例 |
|---|---|---|
keyof T | 获取 T 的所有键的联合 | keyof User → 'name'\|'age'\|'roles' |
T[K] | 索引访问,获取 T 在 K 处的类型 | User['name'] → string |
[P in K] | 映射,遍历联合类型 K | [P in 'a'\|'b']: number → {a: number; b: number} |
as 重映射 | 重命名属性 | [P in K as NewName]: T[P] |
? / -? | 可选/必选修饰 | [P in K]?: T 或 [P in K]-?: T |
readonly / -readonly | 只读/可变修饰 | readonly [P in K]: T |
三、最小示例:六种工具类型手写 + 验证
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// ========== 1. Partial<T> ==========
// 将 T 的所有属性变为可选
type MyPartial<T> = {
[P in keyof T]?: T[P];
};
// ========== 2. Required<T> ==========
// 将 T 的所有属性变为必选(移除可选)
type MyRequired<T> = {
[P in keyof T]-?: T[P]; // -? 表示移除可选修饰符
};
// ========== 3. Readonly<T> ==========
// 将 T 的所有属性变为只读
type MyReadonly<T> = {
readonly [P in keyof T]: T[P];
};
// ========== 4. Pick<T, K> ==========
// 从 T 中选取一组属性 K
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
// ========== 5. Omit<T, K> ==========
// 从 T 中排除一组属性 K
type MyOmit<T, K extends keyof any> = {
[P in Exclude<keyof T, K>]: T[P];
};
// 也可以不依赖 Exclude,直接使用条件类型:
type MyOmit2<T, K extends keyof any> = {
[P in keyof T as P extends K ? never : P]: T[P];
};
// ========== 6. Record<K, T> ==========
// 创建一个类型,属性名为 K,属性值为 T
type MyRecord<K extends keyof any, T> = {
[P in K]: T;
};
// ========== 验证代码 ==========
interface User {
name: string;
age: number;
email: string;
readonly id: number;
}
// Partial 验证
type PartialUser = MyPartial<User>;
// 等价于: { name?: string; age?: number; email?: string; id?: number; }
const p1: PartialUser = {}; // OK
const p2: PartialUser = { name: 'Alice' }; // OK
// Required 验证
type RequiredUser = MyRequired<PartialUser>;
// 等价于: { name: string; age: number; email: string; id: number; }
const r1: RequiredUser = { name: 'Bob', age: 25, email: 'b@test.com', id: 1 }; // OK
// const r2: RequiredUser = { name: 'Bob' }; // ❌ Error: age, email, id missing
// Readonly 验证
type ReadonlyUser = MyReadonly<User>;
const ro: ReadonlyUser = { name: 'Charlie', age: 30, email: 'c@test.com', id: 2 };
// ro.name = 'Changed'; // ❌ Error: Cannot assign to 'name' because it is a read-only property
// Pick 验证
type NameAndEmail = MyPick<User, 'name' | 'email'>;
const pk: NameAndEmail = { name: 'David', email: 'd@test.com' }; // OK
// const pke: NameAndEmail = { name: 'David', age: 28 }; // ❌ Error: age 不在 Pick 中
// Omit 验证
type WithoutEmail = MyOmit<User, 'email'>;
const om: WithoutEmail = { name: 'Eve', age: 22, id: 3 }; // OK
// const ome: WithoutEmail = { name: 'Eve', email: 'e@test.com' }; // ❌ Error: email excluded
// Record 验证
type PageInfo = MyRecord<'home' | 'about' | 'contact', { title: string; url: string }>;
const pages: PageInfo = {
home: { title: '首页', url: '/' },
about: { title: '关于', url: '/about' },
contact: { title: '联系', url: '/contact' },
}; // OK
四、核心知识点拆解
4.1 keyof any 的含义
在 Record<K, T> 和 Omit<T, K> 中,约束条件 K extends keyof any 频繁出现。keyof any 是什么?
1
2
3
type KeyOfAny = keyof any;
// 在 TypeScript 中,any 可被索引的类型是 string | number | symbol
// 所以 keyof any = string | number | symbol
这意味着 K extends keyof any 等价于 K extends string | number | symbol——K 必须是合法的属性键类型。这防止了用户传入 boolean 或 object 作为属性名。
4.2 extends 的两种语义
extends 在 TypeScript 中有两种截然不同的含义:
- 泛型约束(Generic Constraint):
1 2
function getProperty<T, K extends keyof T>(obj: T, key: K) { ... } // K 被约束为 T 的键
- 条件类型(Conditional Type):
1 2
type IsString<T> = T extends string ? true : false; // 判断 T 是否可赋值给 string
在工具类型实现中,两种语义都用到了。
4.3 as 重映射(TypeScript 4.1+)
TypeScript 4.1 引入了 as 子句,允许重命名映射类型的属性。Omit 的实现可以用更优雅的方式表达:
1
2
3
4
// 使用 as 重映射的 Omit
type MyOmit<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P];
};
never 在这里有特殊含义——映射类型会将值为 never 的属性直接移除。这是 TypeScript 4.1 引入的”模板字面量 + 重映射”中的关键机制。
4.4 修饰符的加减
修饰符前可以加 +(添加)或 -(移除):
| 写法 | 含义 |
|---|---|
[P in K]: T | 默认:非只读、必选 |
[P in K]?: T | 可选 |
[P in K]-?: T | 移除可选 → 必选 |
readonly [P in K]: T | 只读 |
-readonly [P in K]: T | 移除只读 → 可变 |
同时添加和移除的效果:
1
2
3
type MutableRequired<T> = {
-readonly [P in keyof T]-?: T[P]; // 移除只读 + 移除可选
};
4.5 Partial 与 Required 的对偶性
Partial 和 Required 在类型层面是对偶的:
1
2
3
4
5
6
7
8
9
10
type RequireAll<T> = { [P in keyof T]-?: T[P] };
// 验证对偶性:
type P<T> = Partial<T>;
type R<T> = Required<T>;
// P<R<T>> = R<P<T>> 吗?不成立
// P<R<T>>: 先必选再可选 → 全部可选
// R<P<T>>: 先可选再必选 → 全部必选
// 两者不等价!
4.6 泛型默认值
大多数工具类型没有默认值,但 Record 可以用默认值增加实用性:
1
2
3
type DeepRecord<K extends keyof any, T = undefined> = {
[P in K]: T;
};
五、实战案例:API 响应类型系统
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// ========== 场景:前后端 API 类型管理 ==========
// 基础数据模型
interface ApiUser {
id: number;
username: string;
nickname: string;
email: string;
phone: string;
avatar: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string;
updatedAt: string;
lastLoginAt: string | null;
deletedAt: string | null;
isActive: boolean;
settings: {
theme: 'light' | 'dark';
language: 'zh' | 'en';
notifications: boolean;
};
tags: string[];
loginCount: number;
departmentId: number;
}
// ---------- 1. 创建用户请求 —— Omit 掉自动生成的字段 ----------
type CreateUserRequest = Omit<ApiUser, 'id' | 'createdAt' | 'updatedAt' | 'lastLoginAt' | 'deletedAt' | 'loginCount' | 'departmentId'>;
// 使用 Partial 允许部分字段为可选
type CreateUserInput = Partial<CreateUserRequest> & Pick<CreateUserRequest, 'username' | 'email'>;
// ---------- 2. 更新用户请求 —— 全部可选 ----------
type UpdateUserRequest = Partial<Omit<ApiUser, 'id' | 'createdAt' | 'updatedAt'>>;
// ---------- 3. 用户列表响应 —— 只选必要字段 ----------
type UserListItem = Pick<ApiUser, 'id' | 'username' | 'nickname' | 'avatar' | 'role' | 'isActive'>;
// ---------- 4. 用户详情响应 —— 只读(服务器端返回的不可变数据) ----------
type UserDetailResponse = Readonly<ApiUser>;
// ---------- 5. 用户统计信息 —— Record 构造 ----------
type UserStats = Record<ApiUser['role'], number>; // { admin: number; editor: number; viewer: number }
// ---------- 6. 灵活的配置对象:只有部分配置字段需要 Readonly ----------
type ApiConfig = {
baseUrl: string;
timeout: number;
retryCount: number;
headers: Record<string, string>;
interceptors: {
request: Function[];
response: Function[];
};
};
// 运行时不可变的配置
type RuntimeConfig = Readonly<ApiConfig>;
// ---------- 7. 级联使用:多个工具类型组合 ----------
// 用户公开资料(只读且只取公开字段)
type UserPublicProfile = Readonly<Pick<ApiUser, 'id' | 'username' | 'nickname' | 'avatar'>>;
// 用户编辑表单(所有字段可选,排除只读字段)
type UserEditForm = Partial<Omit<ApiUser, 'id' | 'createdAt' | 'updatedAt' | 'lastLoginAt' | 'deletedAt'>>;
// ---------- 验证 ----------
function testApiTypes() {
// CreateUserInput 验证
const input: CreateUserInput = {
username: 'newuser',
email: 'new@test.com',
nickname: 'New User',
// 其他字段可选
};
// UserListItem 验证
const listItem: UserListItem = {
id: 1,
username: 'alice',
nickname: 'Alice',
avatar: '/avatars/1.png',
role: 'admin',
isActive: true,
};
// RuntimeConfig 验证
const config: RuntimeConfig = {
baseUrl: 'https://api.example.com',
timeout: 5000,
retryCount: 3,
headers: { 'Content-Type': 'application/json' },
interceptors: { request: [], response: [] },
};
// config.timeout = 10000; // ❌ Error: readonly
// UserStats 验证
const stats: UserStats = { admin: 5, editor: 12, viewer: 45 };
console.log('All type checks passed!');
}
// ---------- 进阶:动态构建 API 端点类型 ----------
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
// 用 Record 构建 API 端点配置
type EndpointConfig = Record<string, {
method: HttpMethod;
path: string;
auth: boolean;
rateLimit?: number;
}>;
const apiEndpoints = {
getUserList: { method: 'GET' as const, path: '/users', auth: true, rateLimit: 100 },
createUser: { method: 'POST' as const, path: '/users', auth: true },
} satisfies EndpointConfig;
六、底层原理
6.1 TypeScript 编译器如何处理映射类型
了解工具类型背后的编译原理,有助于理解为什么某些写法有效、某些不行。
TypeScript 编译器处理映射类型的过程:
阶段 1:类型实例化
当编译器遇到 Partial<User> 时:
- 解析
Partial<User>→ 模板{ [P in keyof T]?: T[P] }绑定T = User - 展开
keyof User→'name' | 'age' | 'email' | 'id' - 生成新类型的属性:遍历联合类型
P = 'name':name?: stringP = 'age':age?: numberP = 'email':email?: stringP = 'id':id?: number
- 结果类型:
{ name?: string; age?: number; email?: string; id?: number; }
阶段 2:同态 vs 异态映射类型
1
2
3
4
5
6
7
// 同态映射类型(Homomorphic)——保留原类型的结构信息
type Homomorphic<T> = { [P in keyof T]: T[P] };
// Partial<T>, Readonly<T>, Pick<T, K> 都是同态映射
// 异态映射类型(Heteromorphic)——创建全新结构
type Heteromorphic<K extends keyof any> = { [P in K]: boolean };
// Record<K, T> 是异态映射(它不依赖输入类型)
同态映射类型保留原类型的修饰符(可选、只读)和泛型参数结构。异态映射类型则完全重建。
这在继承关系中非常重要:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface Base {
name: string;
}
interface Extended extends Base {
age: number;
}
// 同态:Partial<Extended> 包含 name 和 age
type PartialEx = Partial<Extended>; // { name?: string; age?: number; }
// 异态:Record<keyof Extended, boolean> 包含 name 和 age
type RecordEx = Record<keyof Extended, boolean>; // { name: boolean; age: boolean; }
// ↑ 注意:这里没有 optional 修饰符,因为 Record 是异态
阶段 3:延迟与立即求值
有些映射类型被延迟(deferred)求值,直到实际使用时才展开:
1
2
3
4
5
6
7
8
9
function processUser<T extends User>(user: T): Readonly<T> {
// Readonly<T> 在这里没有完全展开——T 还是泛型
return Object.freeze(user);
}
// 只有在调用时,Readonly<T> 才会实例化
const result = processUser({ name: 'Alice', age: 30, email: 'a@test.com', id: 1 });
// result 的类型是 Readonly<{ name: string; age: number; email: string; id: number; }>
// 展开为 { readonly name: string; ... }
6.3 Pick 与 Omit 的实现差异
注意 Pick 和 Omit 在约束上的差异:
1
2
3
4
5
// Pick 的约束
type Pick<T, K extends keyof T> // K 必是 T 的键
// Omit 的约束
type Omit<T, K extends keyof any> // K 只需是合法属性键
为什么 Omit 的约束更宽松?因为 Omit 内部通过 Exclude<keyof T, K> 排除了 K,即使 K 中包含 T 中没有的属性,也不会影响结果(Exclude 直接移除不存在的成员)。
但如果让 K 约束为 keyof T,当你想一次性 Omit 多个属性时,如果某个属性不存在于 T 中就会报错——这限制了灵活性。
所以两个工具类型采用了不同的约束策略。
七、高频面试题解析
面试题 1:手写 DeepPartial<T>——深度可选,支持嵌套对象的属性也变为可选。
问题分析:不仅要标记外层属性可选,还要递归标记嵌套对象、数组等所有层级。
深度解答:
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
33
34
35
36
37
38
39
40
41
42
43
type DeepPartial<T> = T extends object
? T extends Array<infer U>
? Array<DeepPartial<U>> // 数组类型递归处理
: T extends Map<infer K, infer V>
? Map<K, DeepPartial<V>> // Map 类型
: T extends Set<infer U>
? Set<DeepPartial<U>> // Set 类型
: {
[P in keyof T]?: DeepPartial<T[P]>; // 普通对象递归
}
: T; // 基础类型保持不变
// 验证
interface NestedObject {
name: string;
address: {
city: string;
street: string;
zip: number;
};
tags: string[];
metadata: Map<string, { value: number }>;
}
type DeepPartialNested = DeepPartial<NestedObject>;
// 结果结构:
// {
// name?: string;
// address?: {
// city?: string;
// street?: string;
// zip?: number;
// };
// tags?: DeepPartial<string>[];
// metadata?: Map<string, DeepPartial<{ value: number }>>;
// }
// 使用
const partial: DeepPartialNested = {
name: 'Test',
address: { city: 'Beijing' }, // 只提供 city,其他可选
tags: ['a', 'b'],
}; // OK
关键点:
- 递归的终止条件:非对象类型直接返回自身
- 数组类型处理:递归处理数组元素类型
- 特殊容器类型:Map、Set、Promise 等需要单独处理
面试题 2:Record<K, T> 中的 K 为什么必须约束为 keyof any?直接写 K extends string 会怎样?
深度解答:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 当前实现
type Record<K extends keyof any, T> = { [P in K]: T };
// keyof any = string | number | symbol
// 如果改为
type Record2<K extends string, T> = { [P in K]: T };
// 则 K 只能接受字符串类型的联合
// 但 JavaScript 的对象属性也可以是 Symbol 或数字
const sym = Symbol('key');
type MyRecord = Record2<typeof sym, string>; // ❌ Error: Type 'typeof sym' does not satisfy 'string'
// 实际上 TypeScript 允许数字和 Symbol 作为属性
const obj: Record<1 | 2 | 3, number> = { 1: 10, 2: 20, 3: 30 }; // OK
结论:K extends keyof any 是最通用的约束,保留了最大的灵活性。
面试题 3:实现 Mutable<T>,将 Readonly<T> 中的只读属性变为可变。
问题分析:Readonly 用 readonly 修饰符,Mutable 需要用 -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
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
// 验证
interface ReadonlyConfig {
readonly apiKey: string;
readonly endpoint: string;
timeout: number; // 注意:这里本来是可变属性
}
type MutableConfig = Mutable<ReadonlyConfig>;
// { apiKey: string; endpoint: string; timeout: number; }
const config: MutableConfig = { apiKey: 'key', endpoint: '/api', timeout: 5000 };
config.apiKey = 'new-key'; // OK — 移除了 readonly
// 进阶:同时移除 readonly 和 optional
type Concrete<T> = {
-readonly [P in keyof T]-?: T[P];
};
// 验证
type LooseConfig = Partial<ReadonlyConfig>;
// { readonly apiKey?: string; readonly endpoint?: string; timeout?: number; }
type ConcreteConfig = Concrete<LooseConfig>;
// { apiKey: string; endpoint: string; timeout: number; }
扩展思考:能否实现条件性的 Mutable(只对某些属性移除 readonly)?可以结合条件类型:
1
2
3
4
5
6
7
8
9
10
11
12
type ConditionalMutable<T, K extends keyof T> = {
-readonly [P in keyof T]: P extends K ? T[P] : T[P];
// 此写法有语法问题——需要在映射类型中区分 readonly 的处理
};
// 正确实现:使用两个映射类型叠加
type ConditionalMutable<T, K extends keyof T> =
Omit<T, K> & Mutable<Pick<T, K>>;
// 验证:只让 apiKey 可变
type PartiallyMutable = ConditionalMutable<ReadonlyConfig, 'apiKey'>;
// { apiKey: string; readonly endpoint: string; readonly timeout: number; }
八、总结与扩展
手写工具类型的价值
- 模式识别:所有映射类型都遵循
[P in keyof T]的模式,差异仅在修饰符 - 组合优于继承:工具类型通过组合实现更复杂的类型
- 递归类型可以处理嵌套结构:
DeepPartial的例子展示了类型递归的能力
从内置工具类型到类型编程
掌握了这六种工具类型的手写,你已经具备了编写更复杂工具类型的能力:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 组合示例:可编辑的 API 返回类型
type EditableResponse<T> = Mutable<Partial<T>>;
// 既不是只读也不是完整对象——适用于表单编辑场景
// 进阶:按条件选择属性
type FunctionKeys<T> = {
[K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
// 提取 T 中所有方法名(值为 Function 的类型)
type NonFunctionKeys<T> = {
[K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
// 提取 T 中所有非函数属性的名称
推荐 TypeScript 类型体操资源
- type-challenges(GitHub 10.5K stars):从 easy 到 extreme 的渐进式题型
- TypeScript Playground:在线试验各种类型操作
- TS 官方文档的 “Mapped Types” 章节:最权威的参考