文章

内置工具类型手写深度解析

内置工具类型手写深度解析

一句话概括

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 类型系统的三个核心能力:

  1. 映射类型(Mapped Types):遍历联合类型的每个成员,生成新类型
  2. 索引类型(Indexed Types)T[K] 获取属性类型
  3. 条件类型(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 必须是合法的属性键类型。这防止了用户传入 booleanobject 作为属性名。

4.2 extends 的两种语义

extends 在 TypeScript 中有两种截然不同的含义:

  1. 泛型约束(Generic Constraint)
    1
    2
    
    function getProperty<T, K extends keyof T>(obj: T, key: K) { ... }
    // K 被约束为 T 的键
    
  2. 条件类型(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 的对偶性

PartialRequired 在类型层面是对偶的:

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> 时:

  1. 解析 Partial<User> → 模板 { [P in keyof T]?: T[P] } 绑定 T = User
  2. 展开 keyof User'name' | 'age' | 'email' | 'id'
  3. 生成新类型的属性:遍历联合类型
    • P = 'name': name?: string
    • P = 'age': age?: number
    • P = 'email': email?: string
    • P = 'id': id?: number
  4. 结果类型:{ 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 的实现差异

注意 PickOmit 在约束上的差异:

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> 中的只读属性变为可变。

问题分析Readonlyreadonly 修饰符,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; }

八、总结与扩展

手写工具类型的价值

  1. 模式识别:所有映射类型都遵循 [P in keyof T] 的模式,差异仅在修饰符
  2. 组合优于继承:工具类型通过组合实现更复杂的类型
  3. 递归类型可以处理嵌套结构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” 章节:最权威的参考
本文由作者按照 CC BY 4.0 进行授权