ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

TypeScript 工具类型精讲:Partial、Pick、Omit 源码与应用

2026/9/15 4:14:51 拓冰建站 浏览量
TypeScript 工具类型精讲:Partial、Pick、Omit 源码与应用 先问大家一个场景你在写接口联调代码的时候后端返回的实体有二十多个字段但编辑接口只允许提交其中五个或者你做一个更新操作希望所有字段都变成可选参数但又要保留具体的类型提示。如果每个接口都重新手写一遍完整的类型声明时间一长光是把那些重复的字段名复制粘贴一遍就足以让人血压上来了。这就是 TypeScript 工具类型Utility Types存在的意义而在这些工具类型里出场率最高、也最容易被混淆的三兄弟就是Partial、Pick和Omit。这篇文章就从它们各自解决的问题出发把源码实现、应用场景、组合玩法一次讲透。不管你是刚接触 TypeScript 的新手还是写了几年类型但还是靠“试出来”的老手这篇文章都能帮你把这几个工具类型彻底焊死在脑子里。1. 接口复用的最大痛点类型代码也需要“DRY 原则”很多前端项目在起步阶段接口类型定义得非常随意后端返回什么前端就照着写一个 interface等到后面要做编辑、做筛选、做部分更新又另起炉灶写一个几乎一模一样的 interface。代码量一大问题就来了。1.1 后端实体的“全量”与“可编辑”从来不是一回事这是最典型的例子。后端返回一个用户详情接口类型可能是这样的interface User { id: number; username: string; email: string; phone: string; avatar: string; password: string; createdAt: string; updatedAt: string; status: active | disabled; }这个类型在设计的时候是“全量”的也就是数据库里有什么字段它就暴露哪些字段。但前端在调用“更新个人资料”的接口时通常只需要提交username、email、phone、avatar这四个字段。如果照着再写一遍interface UpdateUserPayload { username?: string; email?: string; phone?: string; avatar?: string; }这就是典型的重复劳动。更麻烦的是哪天后端在User里加了一个nickname字段前端这个UpdateUserPayload也要跟着改漏改一个地方类型就开始出现不一致。这就是集成接口时最常见的隐患类型定义没有跟着“单一数据源”走而是各自维护各自的版本。1.2 手写多个版本的 interface 带来的连锁问题手写多个 interface 不仅仅是“多敲几个字”的问题它会在三个层面引发连锁反应第一字段遗漏。当User有二十个字段时你很难保证手写的PartialUser和原始User字段一一对得上少写一个字段TS 不会报错等到上线之后请求参数缺字段才被发现排查成本极高。第二字段类型漂移。比如后端的email从string变成了string | null手写的UpdateUserPayload里还是旧的string类型TS 在编译期完全不会主动告诉你“这里的类型需要同步更新”直到运行时拿到null才发现问题。第三修改成本呈指数级上涨。一个接口类型改动要手动同步到三四个地方每漏一处就是一颗定时炸弹。工具类型的核心价值就是基于已有类型通过变换生成新类型让类型之间保持派生关系。源类型变了变换出来的新类型跟着变这就消除了手工维护的负担。下面逐个拆解这三个最常用的变换工具。2. Partial把所有字段变成可选的“一键操作”PartialT是使用频率最高的工具类型之一它的作用简单直白把类型T中所有的属性都变成可选的。2.1 从源码拆解它的底层实现原理直接看 TypeScript 内置的源码定义type PartialT { [P in keyof T]?: T[P]; };这行代码初看可能有点唬人拆开来看其实就是两个东西的组合keyof和in。keyof T拿到的是T的所有键的联合类型。以User为例keyof User就是id | username | email | phone | avatar | password | createdAt | updatedAt | status。[P in keyof T]是一种映射类型Mapped Type的写法意思是“遍历keyof T中的每一个键P逐个生成新属性”。?符号表示属性可选。T[P]是索引访问类型表示“取T类型中P这个键对应的值的类型”。所以PartialUser最终生成的新类型等价于interface PartialUser { id?: number; username?: string; email?: string; phone?: string; avatar?: string; password?: string; createdAt?: string; updatedAt?: string; status?: active | disabled; }2.2 典型使用场景更新接口的入参类型定义Partial最适合的场景就是处理“只需要部分字段更新但无法确定调用方会传哪几个字段”的接口入参。假设我们要实现一个更新用户信息的函数async function updateUserInfo(userId: number, patch: PartialUser) { // 在这里把 patch 里存在的字段逐个更新到数据库 // 只有传入的字段才会被更新 }调用方可以只传{ username: newName }也可以传{ email: ab.com, phone: 123456 }类型系统都能接受因为PartialUser已经把所有字段都变成了可选。这比传一个User全量类型安全得多也比传any安全得多——至少传入的键名是被约束过的。2.3 容易踩的坑嵌套对象不受 Partial 影响PartialT的“可选化”只发生在最顶层如果User里面有一个对象类型的字段例如interface User { profile: { bio: string; website: string; social: { twitter: string; github: string; }; }; }那么PartialUser会把profile变成可选但profile内部的bio、website并不会变成可选而social也不会变。也就是说如果你写了下面这种代码const patch: PartialUser { profile: { bio: new bio, }, };TS 会直接报错因为profile一旦存在它就必须是一个完整的{ bio: string; website: string; social: {...} }对象。这就是Partial的“浅层”特性。如果需要深层可选化通常需要自己写一个递归版本比如type DeepPartialT { [K in keyof T]?: T[K] extends object ? DeepPartialT[K] : T[K]; };这种递归工具类型在实战中出现在表单状态管理、复杂配置覆盖等场景但要注意在绝大多数接口入参场景浅层Partial已经足够。如果前端提交的 payload 里压根不允许嵌套缺字段直接用PartialT反而更贴近后端真实校验逻辑这个要视后端约束而定不是越深越好。3. Pick按需挑选而不是硬啃整个类型和Partial不同PickT, K的目标是从类型T中挑出若干指定的键生成一个只包含这些键的新类型。它也接受两个泛型参数第一个是源类型第二个是要挑出来的键的联合类型。3.1 Pick 的源码同样短小精悍type PickT, K extends keyof T { [P in K]: T[P]; };这里多了一个泛型约束K extends keyof T意思是K必须是T的键之一。也就是说PickUser, username | email合法但PickUser, notExist会在编译期直接报错这比手动写 interface 的时候写错字段名要安全得多。[P in K]遍历的是K这个联合类型不是整个keyof T因此生成的新类型只包含被选中的键属性保持原类型不变同时属性也不是可选的。用数学的话说Partial是对所有属性做“可选化”操作Pick是对属性做“筛选”操作一个是变换属性修饰符一个是压缩属性集合二者的本质方向是不同的。3.2 什么时候该用 Pick接口返回裁剪一个典型场景是列表页与详情页。列表接口可能只需要id、username、avatar、status这几个字段而详情接口才返回全部字段。前端如果想定义一个“列表项”的类型完全不必新敲一个 interface直接派生type UserListItem PickUser, id | username | avatar | status;这样写的好处显而易见当User的id类型从number变成string比如分布式系统普遍用雪花 ID 字符串UserListItem自动同步变化不需要单独改第二处。另一个场景是在 React 组件中对接 props 类型。如果你的组件接收的数据恰好只是某个实体类型的一部分直接用Pick定义 props 既清晰又不会把多余字段暴露给子组件interface UserCardProps { user: PickUser, id | username | avatar; }这能杜绝子组件因为拿到了整个User而无意间访问password字段的情况。从设计角度来说类型层面的“最小可见面”有助于代码结构的边界维护。3.3 注意 Pick 选中的属性和可选性没有关系很多人会误以为Pick出来的属性是“必选的”就必须传但实际上Pick不会改变属性的可选性。如果源类型中某个属性本身是可选的经过Pick之后这个属性依然是可选的interface Product { id: number; name: string; discount?: number; } type ProductBasic PickProduct, name | discount; // ProductBasic 等价于 { name: string; discount?: number }另外要注意K extends keyof T约束的边界Pick不支持从嵌套属性中挑选。比如你想从User[profile]里挑出bio不能直接在Pick里表达需要先通过索引访问类型再包装一层type ProfileBio PickUser[profile], bio;这个写法很多人不知道但它在处理深层次的字段裁剪时非常有用。4. Omit干掉不需要的字段比 Pick 更常用OmitT, K的作用和Pick正好相反它是从类型T中排除指定的键剩余部分构成新类型。Pick是“只留这些”Omit是“去掉这些其他都要”。4.1 Omit 的源码揭秘它不是“独立发明”而是 Pick 的补集直接看内置定义type OmitT, K extends keyof any PickT, Excludekeyof T, K;这里Excludekeyof T, K的作用是从T的所有键中去掉K中包含的那些键得到剩下的键集合然后交给Pick来生成新类型。所以OmitUser, password | createdAt等价于{ id: number; username: string; email: string; phone: string; avatar: string; updatedAt: string; status: active | disabled; }从语义上讲Omit特别适合做“隐藏敏感字段”或者“去掉不适合对外暴露的元信息”的操作。比如把用户实体返回给前端的时候密码绝对不能暴露type PublicUser OmitUser, password;如果后端再加一个新的敏感字段privateKey只需要在Omit的第二个参数里加上它如果后端的User本身新增了一个好奇的internalRemark字段你不想让前端看到但是接口返回的时候又用了一个统一的数据结构那PublicUser就需要持续维护排除列表。这提示我们Omit适合在源的字段相对稳定、且需要排除的字段数量较少时使用。4.2 什么时候该用 Omit排除不需要的是更省事的反向表达实战中很多类型操作是“反向选择”型的。比如管理员创建一个用户时提交的数据不能包含id自增生成、不能包含createdAt和updatedAt后端生成那么type CreateUserPayload OmitUser, id | createdAt | updatedAt;这种写法的好处在于当User新增了业务字段比如nicknameCreateUserPayload会自动带上新字段不需要额外维护。这在快速迭代的项目中能减少大量无意识的遗漏。同样地如果要实现“编辑用户资料”的 payload可以组合Omit和Partialtype UpdateUserPayload PartialOmitUser, id | createdAt | updatedAt | status;这表示除了这几个系统管理字段其他业务字段都可以选填、至少要传一个才合法。这个组合在真实项目中出现的频率极高也是面试官最喜欢的考察点之一因为它考查的并不是两个工具类型的单独用法而是“能否理解工具类型之间的组合推导关系”。4.3 用 Omit 改写的经典场景把接口返回里的冗余字段去掉在对接后端的时候经常遇到一种情况通用的分页结果类型里什么都带包括前端根本用不到的deletedAt、createdBy。前端可以基于总类型构建一个“页面展示专用类型”interface ApiResponseT { code: number; data: T; message: string; traceId: string; serverTime: number; } type PageResponseT OmitApiResponseT, traceId | serverTime;这样在接口封装层丢掉前端不在乎的调试字段同时code、data、message的语义依然保留对调用方非常友好。很多人一开始会把traceId直接改成string但通过Omit派生更符合 DRY 原则而且将来如果后端要去掉traceId前端这一层不需要任何改动。5. 组合拳Partial、Pick、Omit 一起上才能解决真实业务问题单独使用某个工具类型往往解决不了复杂场景但把它们组合起来就能实现非常精准的类型变换。5.1 场景一表单编辑页的类型声明以用户管理后台为例编辑页面的表单提交数据要求是不能传id由路由参数决定编辑哪个用户不能传password必须单独走密码修改流程每次提交可能只修改部分字段所以是可选的type UserEditPayload PartialOmitUser, id | password | createdAt | updatedAt;这个类型读起来非常“顺”先去掉系统字段和敏感字段然后把剩下的业务字段全部变成可选。如果后端的User新增了一个业务字段bio这个类型自动支持传入bio不需要修改任何代码如果后端新增了一个系统字段lastLoginAt这里也不受影响——除非你想让前端在编辑时改它那就需要加进Omit的排除列表。5.2 场景二创建接口与更新接口共用一套基础类型在多数业务中创建和编辑的可用字段高度一致。假如用户的创建接口允许传username、email、phone、avatar、status而编辑接口允许传同样的字段只是status在某些角色下不允许修改type UserWritableFields PickUser, username | email | phone | avatar | status; type CreateUserPayload UserWritableFields; // 创建时这些字段全必填 type UpdateUserPayload PartialOmitUserWritableFields, status; // 编辑时status不允许改其他可选这里逻辑就非常清楚了UserWritableFields作为“可写字段”的单一数据源创建和编辑都从它派生。以后要加一个字段只改Pick的参数即可两处定义自动同步。个人经验是在真实项目里Pick和Omit经常成对出现一个定义“允许写入的字段”一个定义“展示时需要隐藏的字段”。如果发现某个工具类型被反复单独使用往往意味着业务模型本身就需要抽象出中间类型。5.3 场景三在函数重载与组件 props 中控制可见面除了接口层组件设计中也能用上组合。假设有一个UserTable组件它需要的 props 是“用户列表项中的姓名、头像、状态”但排序、分页需要另外传入type UserTableItem PickUser, id | username | avatar | status; interface UserTableProps { data: UserTableItem[]; onSortChange?: (key: keyof UserTableItem) void; onRowClick?: (user: UserTableItem) void; }keyof UserTableItem就是id | username | avatar | status这个类型约束了排序字段只能是这四者之一想排别人传也传不进来。相比直接传User这能让组件使用者一眼看清“这个组件只需要什么”大幅降低组件的理解成本。组合工具类型还有一个思路先 Pick 再 Omit或者先 Omit 再 Pick结果不一定等价。比如type A OmitPickUser, id | username | email, email; // { id; username } type B PickOmitUser, email, id | username; // { id; username }这两个结果是一致的但阅读的侧重点不同A强调的是“从几个字段里去掉一个”B强调的是“排除掉一个之后挑出几个”。具体用哪一个取决于想表达的语义更贴近哪种思考路径。代码的可读性有时候就是在这些细节里拉开的差距。6. 进阶源码级理解 keyof、in、索引访问一通百通前面看三个工具类型源码的时候反复出现的keyof、in、索引访问类型是理解一切工具类型的地基。把这三个概念吃透了后面看Required、Readonly、Record、Exclude、Extract都会轻松很多。6.1 keyof获取对象类型的所有键的联合类型keyof操作符拿到的是“对象类型的所有键的联合类型”。它和 JavaScript 的Object.keys()有本质区别Object.keys()是运行时拿值keyof是编译期拿类型。interface Person { name: string; age: number; address?: string; } type PersonKeys keyof Person; // name | age | address注意address虽然是可选属性但keyof仍然会把它算进去。如果你想要“必选键的联合类型”有另一个内置操作叫RequiredT配合排除或者直接使用第三方工具类型库里的RequiredKeysT这类实现。6.2 in遍历联合类型的“循环”in在类型层面的作用类似于 JavaScript 里的for...in对联合类型中的每一项进行操作。它只能在类型映射Mapped Type里使用不能单独出现在 interface 的普通属性声明里。type FlagT { [K in keyof T]: boolean; }; // FlagPerson { name: boolean; age: boolean; address?: boolean }顺带一提address?: boolean这里的可选性是从T中继承的因为在映射类型中如果源属性有?修饰符默认传递到新类型。如果想要强制去掉可选性可以这样写type FlagT { [K in keyof T]-?: boolean; };-?是 TypeScript 提供的“修饰符操作”类似的还有?、-readonly、readonly。熟悉这套语法之后自定义工具类型的能力会大幅提升。6.3 索引访问类型T[K] 不是运行时下标访问T[K]在类型层面表示“取类型T中键K对应的值类型”。K可以是一个具体的字符串字面量也可以是一个泛型参数甚至可以是一个联合类型type NameType Person[name]; // string type NameOrAge Person[name | age]; // string | number注意如果K不是keyof T的子集TS 会直接报错。这和Pick的约束逻辑如出一辙因为Pick本身就是用索引访问类型实现的。当你看到PartialT的[P in keyof T]?: T[P]时心里默念这句话“遍历T的所有键对于每个键P取出它原来的值类型T[P]加上可选标记。”就这么简单。三个工具类型背后的原理其实都是同一个keyof拿到键的联合in遍历联合T[P]取原类型。抓住了这条主线就不需要死记硬背Partial、Pick、Omit各自的源码了因为特殊场景下自己定义变体也是分分钟的事。7. 实用指南与避坑清单最后把实战中用这三个工具类型最容易遇到的几个坑汇总一下都是我在代码审查里反复看到的典型问题。7.1 常见错误和不推荐写法对照表错误/不推荐写法问题原因推荐改法PickUser, username | notExistnotExist不在keyof User里编译直接报错修正字段名或先检查源类型是否有该字段OmitUser, password之后依然访问User[password]Omit只生成了新类型不会修改原User类型确保函数参数类型使用OmitUser, password而不是User用PartialUser当创建接口的入参创建接口通常要求必填字段Partial会让必填名存实亡对必填字段使用完整类型对允许缺省的字段再单独Partial对嵌套对象直接套Partial只对最外层属性生效内层对象依然是全量必填需要DeepPartial等自定义递归版本在Omit里排除不存在的键TypeScript 3.5 之后K extends keyof any不强制K是keyof T所以不会报错但可能不是你本意明确排除列表中不要写错字段名最好用keyof T的提示辅助7.2 lint 层面能帮上忙的配置“在Omit里排除不存在的键”是一个隐蔽的问题。因为Omit的定义是K extends keyof any这导致OmitUser, passwordd拼写错误不会报错TS 会认为它等价于User本身。这在语义上很危险尤其当排除的键名比较长时很容易写错而不自知。一种缓解办法是定义自己的严格版Omittype StrictOmitT, K extends keyof T PickT, Excludekeyof T, K;这样K必须是T的真正键写错就会编译报错。我自己在实际项目中就经常定义这个StrictOmit因为它把Omit的宽松约束变成严格约束能提前拦截一批拼写错误。7.3 “cherry pick” 与 Pick 的关系顺带一提很多开发者听说“cherry pick”这个词第一反应是 Git 里把某个 commit 挑到其他分支的操作。实际上在 JavaScript/TypeScript 社区里Pick这个词本身也承载了“挑选”的语义。PickT, K就是在类型层面“挑出”若干字段和 Git cherry-pick 挑 commit 的思路同源在大而全的东西里选出一部分你真正需要的放到一个新的上下文里使用。如果你已经知道 Git 的 cherry-pick 是什么那理解Pick就很容易Git 是挑提交记录Pick是挑类型属性。如果你不知道 Git 这个概念也没关系Pick的用法本身足够直观——第二个参数里写哪些键新类型就包含哪些键。7.4 什么时候不该用工具类型工具类型虽好也不是银弹。有三种情况我更推荐手写 interface而不是强行套用工具类型一是语义命名要求比较高的时候。比如一个 DTOData Transfer Object“用户创建请求”这个名字本身比“PickUser, ...”更直观尤其在团队协作中读代码的人不一定熟悉工具类型语法。如果类型被多处引用一个具名的 interface 能让意图更清晰。二是类型变换导致结构严重漂移的时候。比如从User派生出来的类型字段含义已经和源结构大相径庭了。这时候再强行用Pick/Omit表达反而把本来可以直接读懂的 interface 变成了“需要推导才能理解”的谜题。三是接口边界明确、不可能复用源类型的时候。比如创建接口的字段和后端实体完全无关纯粹是前端表单字段那就没有必要为了“复用”而复用。工具类型是手段可读性和可维护性才是目的。7.5 最后的套路总结按场景选型按照实际业务场景可以建立一套相对固定的选择流程需要把所有字段变成可选 - 用PartialT。只需要保留若干指定字段 - 用PickT, K。需要去掉若干指定字段、保留其余 - 用OmitT, K。需要先排除系统字段/敏感字段、再全部可选 - 用PartialOmitT, K1 | K2。需要只保留若干字段、且这些字段可选 - 用PartialPickT, K。嵌套对象深层可选 - 自定义DeepPartialT递归映射。需要保证排除的键名必须存在 - 用自定义StrictOmitT, K extends keyof T。这三兄弟贯穿了我几乎所有 TypeScript 项目的接口层和组件层定义。刚开始可能觉得工具类型“不过是一个语法糖”但用久了会发现它真正改变的其实是写类型的方式从“照着接口文档手敲”变成“从已有实体类型派生”这一小步决定了类型维护成本和接口字段遗漏的概率下降了一个量级。如果看完这篇文章你觉得有点启发就打开手头的项目把那些手写的冗余 interface 翻出来替换成对应的工具类型试试。第一次跑通类型检查的那一刻你就能体会到什么叫“类型也能像组件一样复用”。