十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TypeScript内置工具类型Partial、Pick、Omit实战详解

TypeScript内置工具类型Partial、Pick、Omit实战详解 1. 先说一个让我印象深刻的场景很多写了半年 TypeScript 的朋友不一定会用 Partial、Pick、Omit。这三个内置工具类型属于那种“看文档秒懂写代码想不起来”的知识点。但一旦你开始封装接口请求、写表单组件、做权限过滤和视图模型转换你会发现它们几乎是每天都离不开的东西。先说一个特别常见的痛点。后端返回一个 User 对象里面有 id、name、email、password、age、avatar 这些字段。前端要写编辑表单需要把 password 排除掉列表页只需要 id 和 name编辑请求可能只传改动的几个字段所以类型必须全部可选。如果不用工具类型你只能这样interface UserDTO { id: number; name: string; email: string; password: string; age: number; avatar: string; } interface UserListVO { id: number; name: string; } interface UpdateUserParams { id?: number; name?: string; email?: string; age?: number; avatar?: string; }你会发现这里有大量重复的字段定义。等后端加一个 phone 字段你得跟着改三四个 interface。这种维护体验非常痛苦而且很容易漏改。TypeScript 内置的 Partial、Pick、Omit 就是专门治这个问题的它们允许你从一个基础类型“派生”出新的类型而不是重新定义一遍。这篇文章我把这三个工具类型从用法、原理到实战组合完整拆解一遍顺便把新手经常踩的坑也列出来。不管你是刚接触 TS 还是已经在项目里用了一段时间这篇都值得存一下。2. 先搞清楚三个工具类型分别在解决什么问题这节先把核心概念聊透别看它们简单很多人的误区恰恰出在“觉得自己懂了”上面。2.1 Partial把必填变成可选Partial 的作用是接收一个类型 T然后把 T 中所有的属性都变为可选。最典型的使用场景就是更新类的接口请求参数。比如你有一个用户实体interface User { id: number; name: string; email: string; age: number; }正常新增用户时这些字段大部分都是必填的。但更新用户时可能用户只改了昵称只传了name字段其他字段都不传。这时候更新接口的参数类型就可以写成type UpdateUserParams PartialUser;这个UpdateUserParams等价于interface UpdateUserParams { id?: number; name?: string; email?: string; age?: number; }注意到没有你不需要手动加任何问号Partial 会帮你把所有属性都“松绑”。这也是为啥我经常跟团队里同学说如果你发现自己给某个接口写请求参数时几乎每个字段后面都加了问号那大概率可以用 Partial 重构一下。另一个常见场景是测试数据的构造。你在写单元测试时可能只想覆盖一两个字段其他字段用默认值。这时候用PartialUser可以非常方便地做一个“覆盖式”合并。2.2 Pick只挑你想要的Pick 的含义是从类型 T 中挑选出指定属性 K组成一个新的类型。type UserBasicInfo PickUser, id | name;这个UserBasicInfo就等于interface UserBasicInfo { id: number; name: string; }使用 Pick 时第二个参数是一个联合类型用单引号把属性名包起来多个属性用|连接。这里有一个细节你只能在keyof T里面选属性。如果你写了一个不存在的属性TS 会直接在编译阶段报错这是 Pick 非常安全的一个原因。实际业务里我经常拿 Pick 来定义“视图模型”。后端返回整个用户对象但前端展示列表时只需要其中两三个字段那就在前端定义type UserListVO PickUser, id | name | avatar。这样做的好处是列表组件永远不会意外读取到不该读取的字段比如 password。2.3 Omit排除你不想要的Omit 与 Pick 正好相反它是从类型 T 中排除指定的属性 K保留剩下所有属性。type PublicUser OmitUser, password;等价于interface PublicUser { id: number; name: string; email: string; age: number; }这个工具类型最适合用来处理敏感字段。比如后端的 User 实体里有 password、token 这样的字段向前端返回数据时希望类型层面就不出现它们。用 Omit 之后前端代码里想访问 password 属性会直接报类型错误从编译期就杜绝了“误拿敏感数据”的情况。Omit 的第二个参数也是联合类型可以一次排除多个字段type SafeUser OmitUser, password | token;2.4 Pick 和 Omit 明明是反着来的为什么还都要有这是面试里经常被问到的一个问题。既然 Pick 可以挑选字段Omit 可以排除字段两者肯定能互相表达对方。事实上 Omit 内部就是用 Pick 实现的。但为什么还要单独提供 Omit直接看代码如果你需要排除一个字段用 Pick 来写type WithoutPassword PickUser, Excludekeyof User, password;虽然能实现但读起来很绕。Omit 就是给这个常见操作一个更直观的语法糖type WithoutPassword OmitUser, password;在实际项目里字段多的类型往往用 Omit 更自然。因为你更清楚“不需要什么”而不是“需要什么”。这就是工具类型的取舍——API 的设计总是在可读性和抽象能力之间做平衡。3. 从源码出发彻底搞懂 TypeScript 内置工具类型的原理很多教程讲到这就不往下走了但如果不看源码你对这三个工具类型的理解还是停留在“会用”层面。面试被深挖的时候、或者遇到非常规类型需求的时候往往会卡住。3.1 三个工具类型的官方源码解析TypeScript 内置工具类型的源码其实是公开的就几十行。Partial、Pick、Omit 的实现分别是这样的type PartialT { [P in keyof T]?: T[P]; }; type PickT, K extends keyof T { [P in K]: T[P]; }; type OmitT, K extends keyof any PickT, Excludekeyof T, K;看起来很短但你如果逐行拆解它会帮你打通 TypeScript 类型系统最核心的几个概念。Partial 用到了映射类型Mapped Type。keyof T拿到 T 的所有属性名组成的联合类型in负责遍历联合类型每遍历一个属性 P用在[P in keyof T]这个语法里定义新对象键?:给属性加可选标志T[P]是索引访问类型表示属性 P 对应的原始类型。整体意思是遍历 T 的所有属性把每个属性保留原类型但全部变成可选。Pick 的关键在于K extends keyof T。extends在这里是泛型约束它保证了 K 只能是 T 的属性名之一。如果你尝试PickUser, id | notExistTS 会报错“Type notExist is not assignable to type keyof User”。之后同样是用映射类型遍历 K取出 T 中对应属性的类型。Omit 最巧妙。K extends keyof any表示 K 可以是任意字符串、数字或者 symbol因为对象键的类型就是这三种。Excludekeyof T, K先从 T 的属性集合里剔除 K得到剩余属性名的联合类型然后再交给 Pick。所以 Omit Pick Exclude 的组合本质是对 Pick 能力的一次扩展。3.2 为什么 Partial 会这么设计“可选”不是运行时去掉的是类型层面加问号理解 TS 类型操作的关键是记住“类型操作永远只在编译期发生”。Partial 并没有真的把数据里的字段删掉它只是让这些字段在类型系统中变成可选。运行时你拿到的对象该有什么字段还是什么字段。这也是很多新手混淆的地方。有人觉得PartialUser的类型应该“只包含被修改的字段”其实不对。Partial 是说“所有字段都可以不传”而不是“只准传部分字段”。区别在哪看这个例子interface User { id: number; name: string; } type PartialUser PartialUser; // 合法 const a: PartialUser {}; // 合法 const b: PartialUser { id: 1 }; // 合法 const c: PartialUser { id: 1, name: 小明 }; // 报错属性 age 不存在 const d: PartialUser { age: 20 };因为 Partial 不会修改原有属性集合它只能让原有属性变成可选不能凭空增加属性。如果你要“排除某些字段”应该用 Omit要“只保留某些字段”应该用 Pick。三个工具类型的边界是这样划分的。3.3 顺带认识 Exclude 和映射类型Omit 源码里的Exclude也是一个工具类型实现是type ExcludeT, U T extends U ? never : T;它的含义是从 T 联合类型中剔除掉属于 U 的部分。比如Excludea | b | c, a的结果是b | c。这个条件类型的逻辑可以理解为“对联合类型里的每一项逐一判断如果属于 U 就过滤掉否则保留”。如果你工作中还需要处理更复杂的类型变形映射类型和条件类型这两个能力几乎是必考的。映射类型负责“遍历对象的键并构造新对象”条件类型负责“按条件挑选类型分支”。Partial、Pick、Omit 全部建立在这两个基础能力上。搞懂了这几行的实现你还能自己写出很多组合工具后面我会演示几个。4. 实战场景三个工具类型在业务代码中怎么组合到这里原理层面的东西已经说完。下面进入真正有用的部分——我在真实项目里总结出来的几种组合方式和用例每个都有对应的代码。4.1 表单编辑 局部更新Partial 的黄金搭档写 React 或者 Vue 的时候最常见的表单逻辑是“编辑后提交修改字段”。后端如果用的是 PATCH 语义部分更新那前端请求体就应该允许只传改动的字段。我的典型写法interface User { id: number; name: string; email: string; age: number; avatar: string; } type UpdateUserBody PartialOmitUser, id; async function updateUser(id: number, body: UpdateUserBody) { return request(/api/users/${id}, { method: PATCH, data: body, }); } // 调用时只传改动的字段 updateUser(1, { name: 新名字 }); updateUser(1, { name: 新名字, age: 30 });这里还组合了 Omit先把 id 排除掉再做 Partial。因为 id 通常是 URL 路径参数或 query 参数不应该出现在请求体里。你看这个类型就表达得非常精确请求体里可以包含 name、email、age、avatar 中的任意子集但绝对不能有 id。如果后端返回的是整个更新后的对象你也完全不需要新定义一个返回类型直接用User就行。这一套下来一个接口只需要一行类型声明表达能力却拉满。4.2 视图模型拆分Pick 让列表页和详情页各取所需一个后端 DTO 传到前端不同页面关注的字段往往是不同的。列表页只需要 id、name、头像、评分详情页需要所有字段编辑页不能出现 password。用 Pick 做视图模型字段来源非常清晰interface ProductDTO { id: number; name: string; description: string; price: number; rating: number; stock: number; createdAt: string; updatedAt: string; } // 列表页视图模型 type ProductListItem PickProductDTO, id | name | price | rating; // 详情页视图模型 type ProductDetail OmitProductDTO, stock;这样做还有一个额外的好处当你重构 ProductDTO 时比如把price改成priceInCentsTS 会在所有用到ProductListItem的地方直接报错逼你把列表页也一起改。这种“数据血缘”的追踪能力是类型系统带来的巨大收益而不是运行时框架能给你的。4.3 敏感字段拦截Omit 做类型层的“脱敏”前端项目经常处理用户信息。如果基础类型里包含了password、token、secretKey这样的字段最好的做法其实是在后端就不返回。但如果后端暂时没有做脱敏前端可以在类型层面先用 Omit 挡住interface UserAccount { id: number; username: string; password: string; token: string; profile: { nickname: string; avatar: string; }; } type SafeUserAccount OmitUserAccount, password | token; function renderUser(user: SafeUserAccount) { // 这里如果要访问 user.password会直接报类型错误 console.log(user.id, user.username, user.profile); }注意这只保证了“类型系统里访问不到”并不会真的把对象里多余的键删掉。如果你要把对象塞进表单或者 JSON.stringify仍然可能把 password 带出去。要真正脱敏还是得在运行时做一层处理比如解构排除function toSafeUser(user: UserAccount): SafeUserAccount { const { password, token, ...safeUser } user; return safeUser; }这里也用到了对象解构的 rest 特性它才是真正在运行时去掉了敏感字段。4.4 更进阶的组合玩法Pick、Omit、Partial 一起用有了这三个工具类型你可以在一个基础上衍生出很多“变体类型”。比如// 只允许修改用户的基础资料且全部可选 type UpdateProfileBody PartialPickUser, name | email | avatar; // 管理员创建用户时需要手动传密码但 id 是自动生成的 type CreateUserBody OmitUser, id | createdAt; // 某些接口返回时时间字段可空 type EventItem OmitEventDTO, startTime | endTime { startTime: string | null; endTime: string | null; };第三种写法很有意思先用 Omit 去掉原类型里非空的startTime和endTime再用交叉类型接上一个“可空”版本。这样既保留了其他字段的类型又对特定字段做了调整。我在写日历类应用时经常用这种模式因为后端确实可能返回 null 表示“未定时间”。还有一点值得注意Pick 和 Omit 的第二个参数支持 keyof T 的联合类型所以你可以根据组件 props 动态约束。比如写一个通用表格组件interface TablePropsT extends Recordstring, unknown, K extends keyof T { data: ArrayT; columns: K[]; }这个不是工具类型直接的内容但它能帮你理解extends keyof T这种泛型约束的价值后面面试常考。5. 新手高频报错与排查技巧我在各种技术群里看到过很多针对这三个工具类型的提问翻来覆去其实就是那几个坑。这里整理一份速查表配合实际报错信息讲希望能帮你少走一些弯路。5.1 最常见的三个错误及解法错误现象一般原因解决方案Type xxx does not satisfy the constraint keyof TPick 的第二个参数写了一个 T 上没有的属性去基础类型里确认字段名注意拼写和大小写Type xxx is not assignable to type PartialUser尝试给 Partial 类型赋一个“多出来的属性”Partial 只允许原属性可选不能加新属性要用交叉类型扩展使用了 Omit 之后访问排除了的字段却不报错你用的是OmitT, K但 K 拼错了或者 K 不存在于 T检查 K 是否真的是 keyof T 的合法值联合类型里写没写对第一种错误我见得最多。很多同学会写成PickUser, name | email | create_time但接口里定义的字段明明叫createdAt下划线和中划线没对上就会报错。这种报错其实是好事因为它在编译期就拦住了你。5.2 Partial 的“浅层特性”嵌套对象不会自动可选直接看代码interface User { id: number; profile: { nickname: string; avatar: string; }; } type PartialUser PartialUser; const user: PartialUser { id: 1, profile: { nickname: xiao, // avatar 没写这里不会报错实际上会报错 }, };这个问题很多人踩过。PartialUser只会让profile本身变成可选但profile内部的nickname和avatar依然是必填的。因为 Partial 只遍历了第一层属性它不会递归地处理嵌套对象。如果你确实需要一个“深层可选”的类型可以自己定义一个递归版 Partialtype DeepPartialT { [P in keyof T]?: T[P] extends object ? DeepPartialT[P] : T[P]; };注意这里要判断T[P]是否是一个对象类型。如果是就递归往下走如果不是保留原类型。不过这个实现有一个边界当属性是数组时递归处理数组项并不合适你可以加一个数组分支或直接用unknown处理。实际写的时候也要注意过于深的递归会导致类型实例化开销变大一般业务场景浅用就好。5.3 Omit 不会在运行时删除字段这个前面讲过但值得再强调一次。Omit 是纯类型层面的操作JS 运行时完全感知不到它。如果你执行type PublicUser OmitUser, password; function getPublicUser(user: User): PublicUser { return user; }返回的对象仍然带着 password 属性只是类型上被“伪装”成了没有。真要脱敏需要配合解构function getPublicUser({ password, ...rest }: User): PublicUser { return rest; }这种 ES 对象解构的 rest 语法是运行时真正去除属性Omit 只是类型上帮你标记。两者往往是一起用的。5.4 小心索引签名类型如果类型里有索引签名比如interface Dict { [key: string]: string; }对它使用 Pick 和 Omit 时语义会和普通 interface 不太一样。keyof Dict得到的是string而不是具体属性名列表所以PickDict, foo的结果可能不如预期。遇到这种情况建议把类型定义为联合类型加索引签名的组合或者直接用 Record 工具类型。如果工作中遇到具体项目再展开这里先提醒一下。6. 面试角度这三道题你得能答上来这三个工具类型被面试官点名的概率很高尤其是前端岗位。我整理了几个高频问法你可以拿来自测6.1 Omit 和 Pick 的第二个参数有什么区别为什么 Omit 的约束是 keyof anyPick 要求 K 必须是keyof T的子集而 Omit 只要求 K 是keyof any也就是任意合法的属性键。为什么因为 Omit 要支持排除一个 T 上可能不存在的属性。排除不存在的属性时Excludekeyof T, K会直接返回完整的keyof T所以结果仍然是合法类型。这个设计很实用。举个例子一个公共工具函数要接收“可排除字段列表”如果你把 K 约束成keyof T那调用方就必须保证传来的字段全都存在反而限制了灵活性。6.2 如何从源码层面解释 Partial 和 Pick 的关系两者都用到了映射类型[P in keyof T]或[P in K]。Partial 遍历 T 的所有属性并加可选标志Pick 遍历指定的 K 属性集合保持原类型不变。可以说 Partial 几乎等价于PickT, keyof T并且每个属性加上了问号但语义上一个是“全选可选化”一个是“部分挑选”。6.3 让你实现一个 DeepReadonly你会怎么设计这个是我面试别人时长出的题。实现思路和 DeepPartial 一致核心就是递归type DeepReadonlyT { readonly [P in keyof T]: T[P] extends object ? DeepReadonlyT[P] : T[P]; };如果你能把 Partial、Pick、Omit 的源码背下来并讲清楚这种变体题基本都能现场推出来。它们考察的核心不是“会不会用”而是“有没有真正理解映射类型、条件类型、extends 约束、keyof 运算”。7. 一些个人经验与项目习惯最后分享几个我自己在项目里的习惯算是对这篇内容的一个回顾式收尾。第一个习惯是能用工具类型派生的类型就绝不手动重复定义。以前项目里有很多长这样的代码interface UpdateUserRequest { id?: number; name?: string; email?: string; age?: number; }现在我会先看看有没有基础类型如果有就直接一行Pick或Omit出去。改一个基础类型所有派生类型自动更新比“全局搜索替换”舒服太多也少了很多低级错误。第二个习惯是类型命名一定要能表达“派生来源”。比如UserListItem、PublicUser、CreateUserBody不要起模糊的PartialUser这种名字。读代码的人看到OmitUser, password就能立刻知道安全边界但看到PartialUser还得去猜。第三个习惯是写公共工具函数时我会主动用extends keyof来做泛型约束。比如封装列表组件、表单组件时props 里往往会有一个字段列表用keyof T约束就可以让编辑器提供完整的自动补全用错了字段也马上红波浪线。这不是三件套的直接用法但理解了它们的源码之后这种写法几乎是自然延伸。关于这三个工具类型网上已经有很多教程但真正能帮到日常开发的还是“理解原理 带着业务场景去用”。如果你看完感觉有些模糊建议自己动手做一件事把 User 接口定义出来分别用 Partial、Pick、Omit 写出更新、列表、脱敏三种类型再去项目里找一个接口把它们换上去。跑一遍比看十遍文档都管用。
返回列表