写后台接口的时候,有个问题一直卡着我:同一个 User 类型,列表页只要 id 和 name,详情页又不想要 email 这种敏感字段,更新接口里所有字段还都得是可选的。以前我的做法就是复制粘贴 interface,改改字段名,一套接口下来能整出三四个长得差不多的类型。说实话越写越恶心,知道 TS 里肯定有更体面的写法,但一直没认真捋。
直到上次项目里类型报错把我逼急了,才抽了半天把 Pick、Omit、Partial、Exclude 这几兄弟从头到尾看了一遍,看完第一个反应是:我之前绕的弯都值了------它们真的不难,就是一开始没人给你把那层纸捅破。
从 Pick 入门:原来就是从类型里"挑字段"
最开始我是从 Pick 下手的,因为它名字最直白。我拿项目里最常见的 User 做了个例子:
typescript
interface User {
id: number;
name: string;
age: number;
email: string;
}
列表页只展示用户 ID 和昵称,我就写了这么一行:
typescript
type UserPreview = Pick<User, 'id' | 'name'>;
const u: UserPreview = {
id: 1,
name: 'xjl',
};
跑了一下没报错。当时我理解的就是:Pick 是一个"挑菜"的工具------User 是一盘满汉全席,第二个参数就像我的筷子,想夹哪几样就夹哪几样,夹出来的就是一个新的小盘。
说实话这个阶段我甚至没多想 'id' | 'name' 为什么是竖线(联合类型),反正照着写能用就行。现在回头看,这种"先会用再说"的心态反而让我后面卡了很久。
Omit 反着来:我只要说"不想要的"
顺着 Pick 的思路往下看 Omit,当时我以为它是另一个独立的工具,作用就是"剔除"。比如列表接口我不想把邮箱带出去,就写:
typescript
// Omit 去掉部分字段
type UserSafe = Omit<User, 'email'>;
const u2: UserSafe = {
id: 1,
name: 'xjl',
age: 18,
};
这个也顺,一个是"挑哪些留下",一个是"说哪些丢掉"。我当时甚至还自负地觉得:这俩不就是正反两面吗,谁还用记啊。
捋到这我突然想通,为啥这俩工具要成对出现------因为项目里真实的场景决定了怎么写舒服。字段少的时候就 Pick,明确挑出来;字段多、只需要去掉一两个的时候就 Omit,省得写一长串 'a' | 'b' | 'c'。就像删文档,删两个字直接划掉就行,删两百字你不如复制一份保留的部分。
跑了个小 demo 把常用的都串了一遍
那天我顺着把 Partial、Record、ReturnType、Exclude 都写了一遍,放在同一个文件里看它们之间的关系。先看 Partial,这玩意儿在更新接口里是真的香:
typescript
// 所有字段全部变成可选
type PartialUser = Partial<User>;
// patch 修改,对象属性很多
const u3: PartialUser = {
name: 'xjl',
age: 18,
};
const emptyObj: PartialUser = {}; // 全空也合法
说白了就是把 User 里每个字段前面都加了一个 ?。做 PATCH 请求的时候最爽,前端想改哪个字段就传哪个,不用再手动定义一遍"全部可空"的 UpdateUser。
然后是 Record,之前我一直以为它是专门给字典对象用的,直到我把它和后端的错误码表对应起来,一下就顺了:
typescript
// json key:value Record<键类型, 值类型>
type Dict = Record<string, number>;
const obj: Dict = { a: 1, b: 2 };
type ErrorMsgMap = Record<number, string>;
// http status code 3xx 跳转 / 4xx 用户错 / 5xx 服务器错
const errorMessage: ErrorMsgMap = {
400: '请求参数错误',
401: '未登录',
403: '没有权限',
404: '资源不存在',
500: '服务器错误',
};
function getErrMsg(code: number): string {
return errorMessage[code] ?? '未知错误';
}
还有 ReturnType,用来拿函数返回值的类型,日常做类型体操的时候会经常碰到:
typescript
function fn() {
return { x: 1, y: 2 };
}
type fnReturn = ReturnType<typeof fn>; // { x: number; y: number }
最后 Exclude,这个我一开始最懵------它跟 Omit 不都是"排除"吗?写了两行对比我才发现根本不是一回事:
typescript
// 联合类型
type All = 'id' | 'name' | 'age' | 'email';
type AfterExclude = Exclude<All, 'email'>; // 'id' | 'name' | 'age'
Exclude 是联合类型 层面的剔除,它连对象都不是,就处理一串 'a' | 'b' | 'c'。而 Omit 处理的是整个 interface / 对象类型。一个管"字符串集合",一个管"整个对象",完全是两个层级的操作。
翻 TS 内部实现我愣了,Omit 居然是个"组合件"
看到上面那个分层关系,我就手贱去翻了 TS 内置类型的实现。说实话我本来以为 Omit 是 TS 团队单独写的一套逻辑,结果一看愣了:
typescript
// TS 内部 Omit 的等价实现
type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
就一行。我盯着这行看了有半分钟,脑子一层层展开:
- 先
keyof T,拿到T所有键的联合类型。比如keyof User就是'id' | 'name' | 'age' | 'email'。 - 然后
Exclude<上面的结果, K>,把要剔除的键从联合类型里删掉------比如 K 是'email',这一步就得到'id' | 'name' | 'age'。 - 最后再
Pick<T, 剩下的键>,就是刚才会用的 Pick 嘛。
之前笔记里写的那串 Omit<T, K> 等价于 Pick<T, Exclude<keyof T, K>> 我一直背不住,翻完源码突然就记牢了------因为它不是公式,就是 TS 作者实际写的代码。我在 2.ts 里手动拆了一遍验证:
typescript
type UserKeys = keyof User; // 联合类型
type KeepKeys = Exclude<UserKeys, 'email'>; // 剔除 email
type MyOmitUser = Pick<User, KeepKeys>; // 再 Pick,结果和 Omit 一模一样
说实话这个瞬间是我当天最有收获的时刻。以前背工具类型就像背单词表,Pick、Omit、Partial 每一个都是独立的新单词;现在突然发现它们其实是有词根的------Exclude 管联合,Pick 管对象,Omit 只是把这俩粘起来的胶水。知道词根以后,真的就不太容易忘。
有几个姿势确实容易写反
这一路写下来我踩了两个最直观的小坑,给你们点一下就够了。
第一个就是 Pick 的第二个参数必须是 keyof T 里面的成员 ,乱写字段名 TS 会直接报错。比如你写 Pick<User, 'phone'> 肯定红,因为 User 里根本没这个键。Omit 其实宽松一点,删掉一个不存在的键有时候不报错,所以有人就喜欢用 Omit 图省事,这点我觉得还是 Pick 更能约束自己。
第二个就是 Exclude 和 Omit 的维度搞混 ,我最开始差点写出 Exclude<User, 'email'> 这种东西------Exclude 只吃联合类型,塞进去一个 interface 它啥也不会干。
Pick 第二参数、Omit 第二参数的类型层级不一样:Pick 要求是
keyof T的子集;Omit 只是把 K 交给 Exclude 去做联合类型运算,所以写错 key 的时候 Pick 会更早地帮你兜底。
还有个不致命但挺膈应人的:Partial 是全部字段都变可选,包括嵌套对象也是整体变可选但内部属性不递归。有时候业务想做深度可选,你自己得再封装个 DeepPartial,别上来就拿 Partial 硬顶。
收获就仨:少写、会拆、不硬背
啃完这一圈我最大的变化不是"多会了几个工具类型",而是写 TS 的心态变了。
以前是遇到复杂类型就怂,赶紧复制一份 interface 改改字段数。现在我会先停一下:这个场景是"只要少数几个字段"还是"就去掉一两个"?前者 Pick,后者 Omit;是不是做更新?那直接 Partial;是不是键值对映射?那就是 Record。按场景选工具,而不是按记忆生搬硬套。
然后就是源码那一层真的值得看一看。TS 工具类型的内置实现很多都只有一两行,全是用更基础的 keyof、in、extends 拼出来的,看懂一层就能串起一串。自己手写一个简化版 Omit,比背十道面试题都管用。
适用边界也想提一句:小项目、一两个人写的 demo 真没必要玩这些,直接写具体类型跑得飞快。但一旦是有几十个 interface、前后端类型要对齐、接口分增删改查多种形态的中大型项目,Pick / Omit / Partial 这几个是真的能显著减少重复代码,改字段的时候改一次源头就够了,不用满项目搜同结构的 interface 一个个改。
顺手捋的核心知识点
写到这顺手把核心的点捋了一遍,我自己回头复盘方便,你们要是懒得翻长文也可以直接看这部分。
1. Pick ------ 从对象类型里按字段白名单挑出新类型
- 核心结论:
Pick<T, K>把对象类型T中属于联合类型K的字段保留,生成新对象类型。 - 易错提醒:
K必须是keyof T的子集,写了不存在的字段会直接类型报错。
2. Omit ------ 从对象类型里按黑名单剔除字段出新类型
- 核心结论:
Omit<T, K>把对象类型T中属于联合类型K的字段去掉,生成新对象类型。 - 易错提醒:Omit 是对象类型层面的操作,它本身不处理联合类型;处理联合类型要用 Exclude。
3. 实现类:Omit 的底层等价写法(3 步拆解)
- 核心结论:
Omit<T, K>本质是Pick<T, Exclude<keyof T, K>>,三步骤:①keyof T取所有键的联合 → ②Exclude<, K>在联合类型层面删黑名单 → ③Pick再把剩下的键从T里挑出来。 - 关键步骤:先
keyof降维成联合 → 联合运算 → 再Pick升维回对象,这是很多 TS 工具类型的通用套路。 - 易错提醒:别记成
Exclude<Pick<T, ...>, K>这种套反的顺序,记住是"先算键再 Pick"。
4. Exclude ------ 联合类型的子集剔除运算
- 核心结论:
Exclude<U, K>在联合类型U里把属于K的成员去掉,得到新联合类型。 - 易错提醒:Exclude 只吃联合类型,往里面塞 interface 得不到你想要的"去掉字段"效果,那是 Omit 的活。
5. Partial ------ 把对象类型的所有字段一次性变可选
- 核心结论:
Partial<T>等价于给T中每个字段前加?,PATCH/部分更新场景最常用。 - 易错提醒:Partial 是浅层可选,嵌套对象内部不会递归变可选;需要深度可选要自己写 DeepPartial。
6. Record ------ 快速定义键值映射的字典类型
- 核心结论:
Record<K, V>生成"键类型为 K、值类型为 V"的对象类型,比如错误码表、枚举映射。 - 易错提醒:键类型
K一般用string | number | symbol,别把整个 interface 塞进来当键。
7. ReturnType ------ 抽取函数返回值的类型
- 核心结论:
ReturnType<typeof fn>可以直接从函数fn拿到它返回值的对象类型,避免手写真值重复。 - 易错提醒:参数里必须是
typeof fn传函数的类型,不能直接写函数名fn,否则是值不是类型。
我目前理解到的程度大概就是这些,有补充的可以评论区唠唠。