ARTICLE DETAIL

资讯详情

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

TypeScript高级类型实战:Record、Partial、Omit如何消灭重复代码

TypeScript高级类型实战:Record、Partial、Omit如何消灭重复代码 前两天 code review 的时候同事把一份用户接口的类型定义贴了出来CreateUserDTO、UpdateUserDTO、UserListItem、UserDetail……每个里面都重复地写着name、email、status这些字段改一个字段名可能要同步四个地方。我当时的第一个念头就是这套代码不是缺类型是缺对 TypeScript 高级类型的理解。Record、Partial、Omit这三个工具类型就是专门用来解决这种重复和僵硬问题的。如果你写过接口对接、状态管理、表单处理或者正在准备 TypeScript 面试这篇文章就是给你写的。我会从这三个工具的本质讲起放到真实业务场景里拆解最后把组合用法、常见报错和避坑心得一起给你尽量做到看完就能用用的时候不心虚。1. 别只会写 interfaceRecord、Partial、Omit 到底解决了什么问题1.1 类型的本质不只是“约束”而是“派生”大多数前端开发者接触 TypeScript 是从interface开始的。interface User { name: string }这种写法很直观它描述了一个数据结构的“长什么样”。但问题是真实业务场景里一个实体往往有多个形态数据库里的完整记录、创建时不需要id的输入、更新时所有字段都可选的补丁、展示给前端时要去掉敏感字段的响应。如果全靠手写 interface就会出现开头那一幕复制粘贴四个结构相似的接口每个还都可能有细微差异。高级类型的价值就在这一步体现出来了类型系统本身可以像函数一样“计算”。Record、Partial、Omit不是语法糖它们是对类型做变换的工具。你只需要维护一份基础类型其他形态都从这个基础类型“派生”出来改基础类型时所有派生结果自动同步不会再出现一处修改、多处遗漏的情况。1.2 一套基础操作keyof、索引访问和映射类型要真正理解这三个工具得先认识 TypeScript 的三个底层能力。keyof拿到类型的所有键名组成的联合类型。keyof User的结果就是id | name | email这类联合类型。索引访问类型用T[K]拿到某个键对应的值类型。比如User[name]就是string。映射类型用{ [P in K]: T }遍历联合类型的每一个键生成一个新对象类型。Record、Partial、Omit本质上都是基于这三个能力封装出来的。理解了底层机制你不仅能看懂官方工具类型的实现还能自己写一个DeepPartial或者其他定制工具。这也是 TypeScript 面试里一个屡见不鲜的考点让你手写某个内置工具类型的实现。后面第六节我会把常见实现贴出来。1.3 学这几个工具收益到底在哪有些同学觉得“类型嘛能过编译就行”这个想法在小型项目里可能不吃亏但一旦项目变大类型就是团队协作的“接口文档”。你用Record约束状态映射就不会有人随手写一个不存在的状态去索引文案你用Omit排除敏感字段就不会有人不小心把passwordHash返回给前端。这些不是“锦上添花”是实实在在减少线上故障的手段。如果你是准备面试那这几个工具属于高频考点如果你就是在业务里写代码的那场景复用收益更大。我见过不少团队把类型工具用起来之后DTO 相关的重复代码直接砍掉一半后面维护的成本降得很明显。2. Record 详解用一致的结构定义映射关系2.1 Record 到底在做什么RecordK, T的官方签名是RecordK extends keyof any, T翻译成人话就是给一组键名K给一个统一的值的类型T生成的类型要求这组键名每个都有一个T类型的值。比如一个接口请求有三种状态每种状态对应一句提示文案type RequestState loading | success | error; const requestText: RecordRequestState, string { loading: 加载中, success: 请求成功, error: 请求失败 };这里RecordRequestState, string锁死了两点第一对象里必须包含loading、success、error这三个键少一个就报错第二每个键的值必须是string类型不匹配也报错。这种精确约束是普通{ [key: string]: string }给不了的。2.2 为什么不该到处用索引签名我经常看到有人用{ [key: string]: string }或者Recordstring, string来定义面板配置、文案映射。这种写法的问题在于keyof会退化成string这意味着你在取某个具体键的时候编译器没法告诉你这个键是否真的存在也失去了自动补全。用一张表看差别写法键的约束是否会有拼写错误提示适合场景{ [key: string]: string }任何字符串都可以不会错键名也能编译过动态场景键名不可预知Recordstring, string任何字符串都可以不会本质还是索引签名不推荐除非键名确实完全不可控RecordUnionType, string必须是联合类型里的键会多写、漏写、错写都报错已知枚举键的映射强烈推荐还有一个经典误区Recordstring, any表面上是“高级类型”实际等于把类型检查全部关掉滥用反而会让项目变成“任何”项目。应该尽量用具体的联合类型让编译器替你操心。2.3 业务场景状态机、错误码、配置表Record最适合处理“有限集合的键”和“统一类型的值”这种结构。状态机就是一个典型type OrderStatus pending | paid | shipped | completed | cancelled; const statusStyle: RecordOrderStatus, { color: string, icon: string } { pending: { color: #f59e0b, icon: clock }, paid: { color: #3b82f6, icon: check }, shipped: { color: #8b5cf6, icon: truck }, completed: { color: #10b981, icon: done }, cancelled: { color: #6b7280, icon: close } };以后再往OrderStatus里加一个状态编译器会立刻要求你把它补进statusStyle不补就编译不过。这种“改一处编译器提醒你跑遍所有使用点”的行为就是类型系统真正值钱的地方。同理错误码表和业务配置表也可以用同样的模式。2.4 配合 as const 和 keyof typeof 实现常量映射项目里经常会先用常量对象定义好所有选项然后再用Record把这个常量对象的键变成类型约束const StatusInfo { pending: { label: 待处理, color: orange }, active: { label: 进行中, color: blue }, done: { label: 已完成, color: green } } as const; type Status keyof typeof StatusInfo; // Status pending | active | done type StatusConfig RecordStatus, { label: string, color: string };这个写法很实用你只需要维护StatusInfo一个对象Status类型和StatusConfig类型都是从它推导出来的。以后想加一种状态改常量对象一处即可类型那边自动更新。2.5 Record 的边界与坑Recordstring, T是内置工具里一个比较容易放松警惕的变体。它相当于给类型增加了一个字符串索引签名所有字符串键都能通过编译。这种写法在需要动态读写不确定键的场合是合理的但如果键集合是已知的还是回到联合类型最稳妥。另外一个值得注意的点Record生成的是“同构”映射也就是说每个键对应的值类型是一样的。如果你的不同键对应不同类型就不该用Record而是老老实实手写对象类型或者用接口描述。3. Partial让所有属性可选的巧妙转换3.1 Partial 的本质与基础用法PartialT会把目标类型里每一个属性都变成可选的。type UpdateUser PartialUser相当于把User里的所有name: string都变成name?: string。官方实现是:type PartialT { [P in keyof T]?: T[P] };这个工具最常见的用途是区分“新增”和“修改”两种操作。新增时很多字段必填但修改时往往只提交部分字段如果用同一个类型就会要么过度宽松、要么过度严格。有了Partial更新操作的入参类型就直接写成PartialUser语义清晰一眼看懂。3.2 真实场景表单编辑、测试数据、接口补丁假设表单编辑页需要支持只修改邮箱不碰其他字段interface User { id: string; name: string; email: string; age: number; } async function updateUser(userId: string, patch: PartialUser) { // 只更新传入的字段 const fields Object.keys(patch); // ...发送 PATCH 请求 } updateUser(u1, { email: newexample.com }); // 合法 updateUser(u1, { id: xxx }); // 类型上合法但业务上通常不应该允许后面这种“类型合法但业务不合适”的问题说明Partial也不是万能的。更严谨的做法是再配合Omit把不允许修改的字段先排除掉。这个后面第五节会讲。在测试代码里Partial也特别香。我在写基于 TypeScript 加 Playwright 的端到端测试时经常需要一个测试数据工厂构造一个全量用户对象然后允许测试用例覆盖部分字段function createTestUser(overrides: PartialUser {}): User { return { id: test-user-1, name: 测试用户, email: testexample.com, age: 18, ...overrides }; }这样每个测试用例只需要写自己关心的字段可读性和可维护性都提升不少。3.3 浅层陷阱Partial 不会深挖Partial只能让“最外层的属性”可选不会递归到嵌套对象内部。如果User里有一个address字段它本身是个对象interface Address { city: string; district: string; } interface User { name: string; address: Address; } type PartialUser PartialUser; // address 是可选了但 Address 内部的 city 仍然必填这意味着你写const user: PartialUser { address: {} };依然会报错。因为address这个键存在时它的值必须是完整的Address结构。处理嵌套更新时你需要的是DeepPartial后面会给出实现。3.4 手写 DeepPartial搞定嵌套结构递归地让每一层属性都可选可以用条件类型加映射类型来写type DeepPartialT T extends object ? T extends Arrayinfer U ? ArrayDeepPartialU : { [P in keyof T]?: DeepPartialT[P] } : T;这个类型的意思很直白如果T是对象就把它的每个键变成可选并且对每个键的值继续递归处理如果是数组就处理数组元素的类型如果是基本类型直接保留。用在工作上像表单编辑器这种一个对象可能嵌套很多层的场景DeepPartial比Partial合适得多。不过要泼一盆冷水递归类型会明显增加编译器的负担特别是超大对象和复杂泛型组合时。能用Partial解决就别轻易上DeepPartial。我一般只在配置合并、深层表单这类确实需要全递归的场景里用它。3.5 兄弟工具Required 和 ReadonlyPartial最常被拿来对比的兄弟是RequiredT和ReadonlyT。Required把所有?去掉恢复必填Readonly把所有属性变成只读。组合起来可以处理很多特殊场景你先用Partial定义入参在真正落库之前用Required校验至少所有必填字段都传了Readonly则适合定义不可变配置。这里有个很典型的用法type DraftConfig PartialConfig; // 用户提交配置允许缺省 type FinalConfig RequiredDraftConfig; // 合并默认值后所有字段都齐了三个工具配合把“草稿态”和“最终态”分得明明白白。4. Omit剔除多余属性而不是重写一遍4.1 Omit 的基本用法与背后逻辑OmitT, K的作用是从类型T中排除掉K指定的属性保留剩下的所有属性。K可以是单个字符串字面量也可以是多个键的联合类型。interface User { id: string; name: string; email: string; password: string; } type SafeUser OmitUser, password; // SafeUser { id: string; name: string; email: string; }最初看到这个工具我觉得它不就是“排除几个属性”吗有什么可讲的。但用过之后发现它在很多资深项目的接口设计里出现频率极高因为“剔除”比“重新声明”更安全——基础类型加新字段时Omit衍生类型会自动带出新字段而手动重写一个接口则很容易漏掉。4.2 Pick、Omit 怎么选PickT, K是只保留K列举的属性OmitT, K是排除K列举的属性。两者是一对互补工具选哪个主要看哪个更省事、更贴近人的直觉工具行为推荐场景Pick保留指定属性其他丢弃大类型里只要两三个字段Omit排除指定属性其他保留大类型里不要两三个字段PartialPickT, K先挑出部分字段再全部可填条件搜索参数、部分更新我自己的判断标准很简单需要保留的属性少用Pick需要剔除的属性少用Omit。比如用户实体可能有二十多个字段但列表页只需要id、name、avatar那Pick更直达反过来如果只想剔除password、salt两个敏感字段用Omit更爽因为它会自动带上所有未来的新字段。4.3 实战隐藏内部字段避免敏感信息泄漏很多后端接口把用户实体原样返回前端对象里直接带passwordHash、salt、internalNote这种内部字段。前端拿到不仅是网络安全问题类型定义也泄漏了不必要的信息。用Omit在类型层做一个“门卫”type UserEntity { id: string; username: string; email: string; passwordHash: string; salt: string; createdAt: Date; updatedAt: Date; }; type PublicUser OmitUserEntity, passwordHash | salt;PublicUser在类型上就不存在passwordHash和salt任何代码试图访问这些字段时编译直接报错相当于把“敏感字段不外泄”从口头约定变成了编译器强制规则。4.4 接口继承加 Omit重新定义继承接口的字段既然前面热词里提到了typescript interface 怎么继承这里具体展开一下。基础做法是extendsinterface BaseUser { id: string; name: string; email: string; createdAt: Date; } interface UserDetail extends BaseUser { lastLoginAt: Date; }UserDetail会拥有BaseUser的全部字段加上自己的lastLoginAt。但如果需要一个“创建用户”的输入类型不想要id和createdAt直接extends BaseUser是做不到“去掉字段”的。这时候要把Omit和继承结合起来interface CreateUserInput extends OmitBaseUser, id | createdAt { password: string; }这个写法在面试题和业务代码里都很有分量它演示了一个关键思路先继承全部再用Omit剔除不需要的键最后再补充自己特有的字段。注意这里CreateUserInput里email仍是必填因为BaseUser里没有标可选。4.5 Omit 的一些坑Omit的第二个参数类型是keyof any也就是说你传一个在T中不存在的键名它不会报错只是没有任何效果。这有时候会掩盖拼写错误type SafeUser OmitUserEntity, passwordHashh; // 尾字母多打一个 h编译器不报错不过通常后面的代码引用passwordHash字段时会报错所以问题不会隐藏太久。另外Omit不会改变剩余属性的可选性。如果被剔除的属性原来是必填剔除后剩下的还是原本的必填状态这符合直觉但在复杂的 DTO 设计时要特别留意。5. 组合实战把 Record、Partial、Omit 用在同一个业务模型里5.1 场景设计一个用户管理系统的类型蓝图前面讲了很多单点用法真正让这套类型工具发光的是在一个中等复杂度的业务模型里组合起来。假设我们在做一个用户管理系统有一个基础实体它代表了数据库里的完整记录type UserStatus pending | active | blocked; interface UserEntity { id: string; username: string; email: string; passwordHash: string; age?: number; status: UserStatus; createdAt: Date; updatedAt: Date; }接下来所有业务场景类型都可以从这一个实体派生。创建接口不需要id、createdAt、updatedAt同时要新增一个明文password字段type CreateUserInput OmitUserEntity, id | passwordHash | createdAt | updatedAt { password: string };更新接口允许部分字段更新又不允许直接改密码密码走专用接口所以先排除敏感字段再转Partialtype UpdateUserInput PartialOmitUserEntity, id | passwordHash | createdAt | updatedAt;这里UpdateUserInput里username、email、status都是可选的。要注意的是可选字段在实际更新时可能是undefined所以写业务逻辑时要判断字段是否存在后面排查章节会细说。对外展示的资料卡剔除密码哈希也不再对外暴露邮箱type UserProfile OmitUserEntity, passwordHash | email;状态文案映射用一个Record把状态枚举映射成页面展示文案和颜色const StatusUi: RecordUserStatus, { label: string, color: string } { pending: { label: 待审核, color: orange }, active: { label: 正常, color: green }, blocked: { label: 已封禁, color: red } };到这里你会发现整套系统里只有一份权威的UserEntity其余所有类型都是它派生出来的。以后如果用户实体新增一个字段比如phone那么CreateUserInput、UpdateUserInput、UserProfile会全部自动带上phone不需要去翻四个文件逐个加。这就是单一起源的威力。5.2 组合查询参数、分页响应与泛型包装用户列表页一般需要过滤条件和分页参数过滤条件不应该是完整的UserEntity而是“部分字段 可选”。用Pick选几个可筛选的键再套Partialtype UserQuery PartialPickUserEntity, status | username { page: number; pageSize: number }; function fetchUsers(query: UserQuery) { // query.status 可选query.page 必填 }接口响应通常还要包一层统一格式正好和泛型一起用interface ApiResponseT { code: number; message: string; data: T; } type UserListResponse ApiResponse{ list: UserProfile[]; total: number; }; type UserDetailResponse ApiResponseUserProfile;这套组合下来前端调用接口时能精准知道data里有哪些字段分页、错误码这些基础设施又复用了一套泛型代码整体会非常紧凑。5.3 实际场景二状态流转的防呆设计状态机里最容易出 bug 的地方是“允许的迁移”和“不允许的迁移”混在一起。配合Record和映射类型可以在类型层面把状态迁移规则做成一张表type StateMachine Recordpending | active | blocked, readonly (active | blocked)[]; const transitions: StateMachine { pending: [active, blocked], active: [blocked], blocked: [] }; function canTransit(status: UserStatus, target: UserStatus) { return transitions[status].includes(target); }虽然这里的Record没有像Omit、Partial那样直接参与状态派生但它保证了“每个状态都必须定义可迁移目标”少定义一个键就编译失败避免漏掉规则。5.4 组合使用后代码维护的变化有多大重构前新人接手这个用户模块可能要同时看CreateUserInput、UpdateUserInput、UserProfile三个接口搞清楚它们之间什么关系然后在一堆重复字段上小心翼翼。重构后新人在UserEntity里改一个字段名编译器会立刻把报错抛到所有派生类型的使用处顺着报错改完所有相关代码就同步了。这种体验的变化没法量化成指标但我实际感受很深类型定义越少的项目改起来反而越轻松。因为不是靠复制粘贴堆出来的类型而是靠逻辑推导出来的类型它们之间天然保持着一致。6. 常见问题与排查技巧实录6.1 Property xxx does not exist on type怎么定位使用Omit之后访问被剔除的属性报错是必然的这个好理解。但还有一种常见情况你在RecordUserStatus, X上访问一个不存在的状态时比如statusUi.disabled编译器会报Property disabled does not exist。这时候先停下来去查UserStatus联合类型里面有没有disabled而不是急着as any。一个实用技巧在 VSCode 里把鼠标悬停到报错的类型上看编辑器展开的完整结构。比如Recordpending | active, string会自动展开成{ pending: string; active: string }一眼就能看到缺了哪个键。6.2 Partial 后字段是 undefined业务逻辑要判空这是Partial最常见的后续问题。更新接口里用了Partial那么在代码里读取input.name时它的类型是string | undefined不能直接拿去数据库查询。很多人第一次遇到会觉得很烦但这是类型系统在提醒你补丁请求可能没传这个字段。我的习惯是写一个显式的字段过滤逻辑function buildUpdateSetT extends object(patch: T) { const set: Recordstring, unknown {}; for (const [key, value] of Object.entries(patch)) { if (value ! undefined) { set[key] value; } } return set; }这样做有几个好处SQL 的SET子句不会出现undefined避免把数据库字段覆盖成空代码在类型层面也说得通。6.3 不要迷信 Recordstring, T它可能是隐患把Recordstring, string用多了类型保护就会形同虚设。我见过一个项目里几十个配置对象都写成Recordstring, any结果某天有个函数从一个配置对象里读config.timeout因为类型是any拼写写成了config.time_out直到线上报错才发现。这类问题就是纯靠类型就能避免的没必要用any去塞住编译器。如果确实需要“任意字符串键”建议用Mapstring, T替代普通对象。Map在语义上更贴近“键值集合”运行时也没有原型链上那些奇怪的键。如果不能用Map至少用Recordstring, T而不是Recordstring, any把值类型守住。6.4 类型体操别上头可读性优先我把三个工具类型讲得很细但不代表鼓励你在项目里堆各种超长组合类型。见过一些“炫技型”写法一个类型别名嵌套五六个组合最后鼠标悬停显示一大屏展开结果同事根本看不懂改起来也战战兢兢。类型工具的目的是降低维护成本而不是秀智商。一个可落地的原则如果一个类型表达式超过两行或者你需要在旁边写注释才能说明它在干嘛那就考虑拆成多个命名清楚的中间类型。比如UpdateUserInput可以先写一个UserMutableFields再写PartialUserMutableFields阅读负担小很多。6.5 面试速答手写实现和一句话总结既然热词里有typescript面试这里就顺手把最常见的三个实现贴出来方便你复习type MyPartialT { [P in keyof T]?: T[P] }; type MyPickT, K extends keyof T { [P in K]: T[P] }; type MyOmitT, K extends keyof any MyPickT, Excludekeyof T, K; type MyRecordK extends keyof any, T { [P in K]: T };面试官如果问这三个工具的区别可以用一句话回答Record负责“同一类型的键值集合映射”Partial负责“把必选改成可选”Omit负责“从类型中拆掉不需要的键”。如果能再补一句“它们都是映射类型的不同组合本质是类型层面的函数”这题基本就稳了。6.6 速查表什么时候用哪个工具作用一句话适用场景Record键集合映射到同一值类型枚举文案、状态样式、配置表Partial所有属性可选更新接口、测试数据覆盖Omit排除若干属性隐藏敏感字段、接口入参精简Pick保留若干属性只取三五个核心字段Required所有属性必填配置合并后兜底Readonly所有属性只读常量配置、不可变数据最后再分享一个我自己在实际项目里的习惯我会在项目types目录下维护一个entity.ts把数据库实体、核心业务对象的完整结构都放在这里然后用Omit、Pick、Partial、Record在dto.ts、api.ts、ui.ts里派生所有业务类型。遇到新需求时先问自己“能不能从现有类型派生出来”而不是立刻新写一个 interface。踩过几次改一处漏一处的坑之后回头看这套思路帮我省下的维护时间真的不是一点半点。
返回列表