内置工具类型手写深度解析
Partial、Required、Readonly、Pick、Omit、Record 六个内置工具类型,底层全是映射类型加索引访问的组合体操。 手写一遍就能从查文档复制进阶到随手造自己的类型变换,面试中能现场推导出任意工具类型的实现。
一句话概括
TypeScript 的 Partial、Required、Readonly、Pick、Omit、Record 六个工具类型,底层全是映射类型(Mapped Types)+ 索引访问(Indexed Access)的组合体操——手写一遍,你就从”查文档复制粘贴”进阶到”随手造自己需要的类型变换”。
核心知识点
1. 映射类型:所有工具类型的基石
1
2
3
4
5
6
7
8
9
10
11
12
// 最简映射:遍历 T 的所有 key,保持原样
type Identity<T> = { [P in keyof T]: T[P] };
// ——— 三部曲:加上可选、必选、只读 ———
type MyPartial<T> = { [P in keyof T]?: T[P] }; // 所有属性 → 可选
type MyRequired<T> = { [P in keyof T]-?: T[P] }; // -? 移除可选,全部必填
type MyReadonly<T> = { readonly [P in keyof T]: T[P] }; // +readonly(+ 可省略)
// 验证
interface User { id: number; name: string; email?: string }
type A = MyPartial<User>; // { id?: number; name?: string; email?: string }
type B = MyRequired<User>; // { id: number; name: string; email: string } ← email? 被 -? 干掉了
关键细节:-? 不仅能把 ? 去掉,还能把本来就必填的属性也保持必填(不会报错)。-readonly 同理。
2. Pick 和 Omit:一对互补的”字段筛选器”
1
2
3
4
5
6
7
8
9
10
11
12
13
// Pick:选出你想要的
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
// Omit:排除你不想要的(两种写法)
type MyOmit1<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P] };
type MyOmit2<T, K extends keyof any> = {
[P in keyof T as P extends K ? never : P]: T[P]; // as 重映射:匹配到的变 never → 删除
};
// 使用
interface User { id: number; name: string; password: string; email: string }
type PublicUser = MyOmit2<User, 'password'>; // { id: number; name: string; email: string }
type UserPreview = MyPick<User, 'id' | 'name'>; // { id: number; name: string }
面试追问:
MyOmit2的as重映射是 TS 4.1 引入的。P extends K ? never : P的意思是:如果当前 key 在 K 里,返回never删除该属性,否则保留。这是条件类型在映射类型 key 位置的应用。
3. Record:用联合类型批量造对象
1
2
3
4
5
6
7
8
9
type MyRecord<K extends keyof any, T> = { [P in K]: T };
// 典型用法
type Role = 'admin' | 'editor' | 'viewer';
type Permissions = MyRecord<Role, boolean>;
// { admin: boolean; editor: boolean; viewer: boolean }
// 进阶:值的类型也可以是函数
type EventMap = MyRecord<'click' | 'focus' | 'blur', (e: Event) => void>;
Record 和 { [key: string]: T } 的区别:Record 的 key 是具体联合类型,IDE 能自动补全;索引签名 [key: string] 允许任意 key,不限制具体属性名。
4. 组合拳:用基础工具类型拼出业务类型
1
2
3
4
5
6
7
8
9
10
11
12
// 场景:API 返回完整 User,表单只需要部分字段且全部可选
interface User { id: number; name: string; email: string; age: number; avatar: string }
type UserForm = MyPartial<MyPick<User, 'name' | 'email' | 'age'>>;
// { name?: string; email?: string; age?: number }
// 场景:列表项的只读预览(排除 id)
type UserListItem = MyReadonly<MyOmit1<User, 'id'>>;
// { readonly name: string; readonly email: string; readonly age: number; readonly avatar: string }
// 场景:配置对象的类型安全
type Env = 'dev' | 'staging' | 'prod';
type AppConfig = MyRecord<Env, { baseURL: string; timeout: number }>;
5. 类型运算的”四则运算”思维
把工具类型当作类型级别的运算符来思考:
1
2
3
4
5
6
7
8
9
10
// 交(&):取两类型所有属性
// 并(|):联合类型
// 差:Exclude<T, U> / Omit<T, K>
// 选:Pick<T, K> / Extract<T, U>
// 就像你写 JavaScript 用 map/filter/reduce,写 TypeScript 用这些
type FormFields<T> = MyPartial<MyPick<T, {
[K in keyof T]: T[K] extends Function ? never : K // 排除方法,只留数据字段
}[keyof T]>>;
// 用了 3 个内置工具类型 + 条件类型映射,一行搞定"把所有数据字段变可选"
其实你每天都在用
- React 的
setState就是Partial<State>——this.setState({ name: 'Alice' })不需要提供完整 State,因为setState的类型参数就是Partial<S>。Class Component 时代如此,Hooks 的setUser(prev => ({ ...prev, name }))也是同样的模式。 - Prisma / TypeORM 的
update方法——prisma.user.update({ where: { id }, data: { name: 'Bob' } }),data参数类型就是Partial<User>(或深层的Partial<Prisma.UserUpdateInput>),因为更新时只需要传要改的字段。 - Vue3 的
defineProps配合Pick——子组件只从父组件接收部分 props 时,type ChildProps = Pick<ParentProps, 'title' | 'onClose'>。 - API 响应类型的分层——后端返回
User(含 password hash)、传给前端的SafeUser = Omit<User, 'passwordHash' | 'refreshToken'>、表单使用的UserFormData = Partial<Pick<User, 'nickname' | 'bio'>>,同一个业务实体用工具类型切出三个安全层。 - 环境变量类型——
type EnvConfig = Record<'DATABASE_URL' | 'REDIS_URL' | 'JWT_SECRET', string>让process.env有智能补全,而不是any满天飞。
常见误解(FAQ)
❌ 误区:「
Record<string, T>和{ [key: string]: T }完全一样」 真相:类型层面上行为相同,但 Record 在 IDE 中会有属性名提示(根据你传入的 key 类型),索引签名则无。更关键的是 Record 的 key 可以是字面量联合(Record<'a' | 'b', number>),索引签名做不到。❌ 误区:「
Omit<T, K>要求 K 必须是 T 的 key」 真相:不要求!看源码Omit<T, K extends keyof any>,K 只需要是合法的 key 类型(string | number | symbol),你完全可以Omit<User, 'nonexistent'>,不报错。这和Pick<T, K extends keyof T>不同。❌ 误区:「
Required<T>会把undefined也干掉」 真相:Required只移除?可选修饰符,不影响T | undefined联合类型。{ x?: string }变成{ x: string },但{ x: string | undefined }不变。要同时去掉?和undefined,需要额外一步:{ [P in keyof T]-?: NonNullable<T[P]> }。❌ 误区:「
Partial<Partial<T>>等于Partial<T>」 真相:对的,幂等。但Required<Partial<T>>不等于T——因为Partial<T>先把?加上了,Required再把?去掉,理论上回到原样。但如果 T 里有原本就可选的属性(x?: number),Partial不影响它(已经是可选的),Required会把它变成必选。所以Required<Partial<T>>=Required<T>。
一句话总结
六个工具类型的源码加起来不到 20 行,但它们背后是 TypeScript 类型系统的三剑客——映射类型、索引访问、条件类型——掌握这三样,你就从 TypeScript 的”使用者”变成了”建造者”,任何业务类型变换都能信手拈来。