☰
手写TypeScript Omit:从keyof到映射类型,彻底搞懂类型体操核心题
2026/10/13 13:52:42 网站建设 项目流程

我第一次在类型体操系列里做到第 3 题“实现 Omit”的时候,第一反应是:这不就是内置工具类型吗?直接Omit<T, K>用就完事了,为什么要自己实现一遍?后来静下心来把题目做完,我才意识到这道题的真正价值不是让你抄答案,而是把keyof、Exclude、Pick、分布式条件类型、映射类型这些 TypeScript 类型编程的基础零件全部串起来。就算你已经在业务里写过无数个Omit,也值得花一个下午把这道题从头到尾手写一遍。

这篇文章我会从题目拆解、实现思路、两种主流写法、边界情况、常见踩坑到进阶路线,完整记录我解这道题的过程和思考。如果你刚开始接触类型体操,或者想理解内置工具类型到底是怎么工作的,这篇文章可以直接当参考笔记用。

1. Omit 到底在考察什么:先把这个“小函数”读懂

1.1 题目要求与输入输出的本质

这道题的目标很简单:实现一个MyOmit<T, K>,接收两个类型参数,一个是对象类型T,一个是键集合K,返回一个新对象类型,这个新对象拥有T中除了K指定的键之外的所有键。

举个最直白的例子。假设我有一个接口:

interface User { id: number name: string email: string }

调用MyOmit<User, 'email'>,得到的就是:

{ id: number name: string }

你看,这不就是内置Omit的行为吗?所以题目的本质是:在不直接使用Omit的前提下,用 TypeScript 的类型系统把它重新造出来。它考察的不只是“会不会用”,而是“明不明白它为什么能这么用”。

在解题之前,我建议你先想清楚一个问题:类型层面的“删除”到底意味着什么?在 JavaScript 里,删除对象的一个键可以用delete obj.email,这是一个运行时副作用操作,键没了就是没了。但在类型层面,没有“就地删除”这种事。你不能说“把 T 身上的 email 这个键抹掉”,因为类型一旦定义出来,它的结构就固定了。你能做的只有一件事——从T里把所有键取出来,过滤掉不需要的,再用剩下的键重新构造一个对象类型。

所以 Omit 的实现思路,本质上是一个“取键-过滤-重建”的三步流水线。想明白这一点,题目就完成了一半。

1.2 为什么这道题在类型体操里很有代表性

如果你去看 TypeScript 内置的工具类型,会发现 Omit 是一个很特殊的成员。它不像Pick那样只做“选择”,也不像Exclude那样只做“集合过滤”,它是把多个基础工具组合起来,实现一个在业务里极其高频的需求。

而且 Omit 这道题的解法不止一种。你可以用Pick + Exclude组合,也可以用映射类型配合重映射(as)语法来实现。这两种解法背后的知识密度很不一样:组合式写法考验的是你对现有工具类型的熟悉度,映射类型写法考验的是你对类型系统底层机制的理解深度。

很多人在业务里写Omit<User, 'email'>写得很溜,但如果让他从零手写,往往会卡在“为什么 Exclude 能把不要的键过滤掉”这个问题上。这恰恰是类型体操存在的意义:把那些你用得很熟练却说不清原理的东西,一个一个拆开,逼你想明白。我自己解完这道题之后,再看Omit相关的类型报错,思路清晰了很多。

2. 核心思路拆解:为什么是 Pick + Exclude 的组合

2.1 第一步:keyof 把对象键变成联合类型

要从对象里“挑出”一部分键,首先得把对象的所有键拿出来。TypeScript 的keyof操作符就是干这个的:

type UserKeys = keyof User // 结果是 'id' | 'name' | 'email'

keyof会返回一个联合类型,这个联合类型就是T的键集合。你可以把它理解成“类型版的数组”,因为它可以被遍历、被过滤、被操作。

有了联合类型,我们就能对它做集合运算。这就像你从一堆照片里先抽出一本相册的所有照片编号,接下来才能谈“删掉哪些编号”。

2.2 第二步:Exclude 把不要的键过滤掉

拿到键集合之后,自然要过滤掉K里的那些键。内置的Exclude<T, U>就是做这个的。它的实现极其简单:

type Exclude<T, U> = T extends U ? never : T

别小看这短短一行。这里的关键在于“分布式条件类型”的机制:当T是一个联合类型,并且以裸类型参数的形式出现在条件类型左侧时,条件类型会把T中的每个成员单独拿出来做判断,最后把结果再合并成新的联合类型。

举个例子:

type Keys = 'id' | 'name' | 'email' type Filtered = Exclude<Keys, 'email'> // 相当于依次判断: // 'id' extends 'email' ? never : 'id' -> 'id' // 'name' extends 'email' ? never : 'name' -> 'name' // 'email' extends 'email' ? never : 'email' -> never // 合并结果:'id' | 'name'

never在联合类型里会被自动吸收掉,所以最终的过滤结果就是'id' | 'name'。

这个细节值得反复体会:类型系统里没有“删除”操作,但你可以利用条件类型把一个成员变成never,never在联合类型中相当于不存在。这就是类型世界里“删掉一个键”的真实含义。

2.3 第三步:Pick 用剩余键重新构造对象

过滤完键集合,接下来就是重建对象。内置的Pick<T, K>会从T中挑选出K指定的键,组成一个新对象:

type Pick<T, K extends keyof T> = { [P in K]: T[P] }

这是一个典型的映射类型:遍历K中的每个键P,然后用T[P]取值。所以当你把Exclude<keyof T, K>的结果传给Pick时,就完成了“只保留我要的键”的重建。

整个流程串起来,就是那句经典的:

type MyOmit<T, K> = Pick<T, Exclude<keyof T, K>>

从数据流角度看,这条链非常清晰:T先变成键集合,键集合过滤后变成新的键集合,新的键集合再映射回一个对象。这就像先拆开一台机器,把不要的零件扔掉,再按原来的规格把剩下的零件重新组装起来。组装出来的机器虽然和原来长得不一样,但每个保留的零件都还是原装的。

2.4 为什么不能直接在映射类型里“跳过”某个键

有一个很容易产生困惑的点:既然映射类型可以遍历keyof T,为什么不直接写一个循环,遇到不想要的键就跳过?比如这样:

type MyOmitWrong<T, K> = { [P in keyof T]: P extends K ? never : T[P] }

这种写法是错误的。原因在于:[P in keyof T]只能控制“遍历哪些键”,不能控制“结果的键是谁”。被映射出来的属性名永远是P本身,你不可能让P变成一个不存在的键还指望结果里没有它。如果你把属性值改成never,得到的只会是一个值为never的多余属性,而不是没有这个属性。

所以正确做法只有两种:要么在键集合层面就用Exclude把键过滤掉,再交给Pick;要么在映射类型里用as子句把不要的键重映射成never,让 TypeScript 知道这个条目应该被丢弃。这两种做法的本质是一样的——在“键集合”这个层面解决问题,而不是在“遍历过程”里试图跳过。

3. 手写实现:从内置工具类型到自研类型

3.1 组合式写法:最简单也最贴近内置实现

先给出这道题最常见的答案:

type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

如果你是在练题环境里提交,这一行大概率就能通过所有测试用例。它的思路非常好懂:先用Exclude从T的键集合里刨掉K,再用Pick把剩下的键重新组成对象。

不过这里有个值得讨论的细节:K extends keyof T这个约束到底要不要加?

如果你看了 TypeScript 内置Omit的定义,会发现它长这样:

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>

注意,内置版本用的是K extends keyof any,不是K extends keyof T。keyof any的结果是string | number | symbol,也就是说,这个约束只要求K是合法的属性键类型,并不要求K必须存在于T中。

为什么要这样设计?我实测过两种约束的区别。如果使用K extends keyof T,那么当你传入一个T中不存在的键时,会直接报类型错误:

type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> type Test = MyOmit<User, 'notExist'> // 报错:Type '"notExist"' does not satisfy the constraint

而使用K extends keyof any则不会报错,因为它会返回原来的T,因为'notExist'在Exclude过程中被自然过滤掉了。

内置Omit选择宽约束是有道理的:实际业务中,你经常要排除一个可能存在于某个对象、也可能不存在的键。比如一个公共函数接收任意对象类型,然后排除掉'status'字段,你希望调用方只在运行时关心这个字段存不存在,而不是在编译阶段就被约束卡死。所以我个人建议,在自定义类型工具时优先模仿内置版本的宽约束写法:

type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>

3.2 映射类型 + as 子句:更底层的实现

组合式写法虽然简洁,但它依赖了Pick和Exclude两个现成工具。如果你想让自己的理解再深一层,可以试试不借助这两个工具,直接用映射类型加重映射语法实现:

type MyOmit<T, K extends keyof any> = { [P in keyof T as P extends K ? never : P]: T[P] }

这里的关键是as子句。TypeScript 4.1 之后,映射类型允许你通过as对键名做一次“重映射”。语法上的意思是:原本要生成的键是P,现在把它重映射成P extends K ? never : P的计算结果。如果一个键被重映射成never,TypeScript 就会认为这个属性不需要生成,效果等同于把它过滤掉了。

我来拆开解释下这行发生了什么。假设 T 是 User,K 是'email'。遍历开始:

  1. P = 'id','id' extends 'email'为假,键名重映射为'id',生成属性id: number;
  2. P = 'name',同理,生成属性name: string;
  3. P = 'email','email' extends 'email'为真,键名重映射为never,这个属性被丢弃。

所以结果和组合式写法完全一致,但这里没有任何“现成工具”的依赖,每一步都发生在类型系统的底层。我在练习的时候会把两种写法都写一遍:组合式版本用来验证思路对错,映射版本用来加深对as重映射机制的理解。如果有人问我 Omit 的原理是什么,我会首选映射版本来讲。

需要提醒的是,as重映射语法需要 TypeScript 4.1 以上才支持。如果你在维护一个版本较老的项目,组合式写法会更稳妥。

3.3 自测用例:自己动手验证实现

在练题环境里,一般会提供现成的测试用例。如果你是在自己的工程里练习,我建议写几个类型断言,确保实现真的没问题。

首先,我们需要一个严格比较两个类型是否相等的辅助类型。这里可以用一种“严格相等”的判断技巧:

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false type Expect<T extends true> = T

这个Equal的原理是从函数类型协变比较中“套”出两个类型的差异,可以识别出一些普通赋值比较识别不了的差别,比如any与unknown、可选属性和 undefined 的区别。平时写类型测试可以直接复用这个辅助类型。

然后准备一个接口和几个用例:

interface Todo { title: string description: string completed: boolean meta?: { author: string } readonly createdAt: string } type cases = [ Expect<Equal<MyOmit<Todo, 'description'>, { title: string completed: boolean meta?: { author: string } readonly createdAt: string }>>, Expect<Equal<MyOmit<Todo, 'title' | 'completed'>, { description: string meta?: { author: string } readonly createdAt: string }>>, ]

这里我特意在接口里加了可选属性meta和只读属性readonly createdAt,目的是验证实现不会把属性修饰符弄丢。我自己第一次写的时候,心里也在打鼓:Pick会不会把 readonly 干掉?映射类型会不会把可选标记丢掉?后面我会专门聊这个边界情况。

3.4 用编辑器做断点调试

类型编程不像运行时程序那样能打console.log,但我有自己的调试习惯。写完实现之后,我会在编辑器里临时加一行类型,然后用鼠标 hover 上去看推导结果:

type Debug1 = MyOmit<Todo, 'description'> type Debug2 = Exclude<keyof Todo, 'description'>

把鼠标移到Debug1上面,编辑器会展示完整的对象类型,我一眼就能确认description有没有被剔除,meta的可选标记、createdAt的 readonly 标记还在不在。移到Debug2上面,则会看到过滤后的键集合是'title' | 'completed' | 'meta' | 'createdAt'。

这个习惯在解更复杂的类型题时特别有用。类型系统就像一门隐形的编程语言,你看不到执行过程,只能观察“输入”和“输出”。多做中间步骤的检查,就等于给类型打上了“日志”,比一次性兜底要高效得多。

4. 边界情况与高频翻车点:这些坑我全踩过

4.1 K 传入了对象中不存在的键

我在前面已经提到过这个边界,但这里再说得细一点,因为它直接关系到你在业务里能不能放心使用自定义 Omit。

宽约束版本K extends keyof any对不存在的键持“无所谓”态度:

type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>> type Test = MyOmit<Todo, 'notExist'> // 结果就是 Todo 本身

窄约束版本K extends keyof T则会直接编译报错。从“类型安全”角度讲,窄约束看起来更合理:你明明没有这个键,凭什么让我排除?但从真实业务角度讲,宽约束使用起来更顺手。一个常见的场景是:你有一个基础配置对象,想排除掉一个只在某些模块才会出现的字段。模块之间并不保证这个字段一定存在,但你希望类型工具在编译期不要因此报错。

所以我的个人建议是:自己实现 Omit 时采用宽约束K extends keyof any,这样和内置类型行为一致,不会在重构时突然多出一堆编译错误。当然,练题环境的模板往往写的是K extends keyof T,这并不冲突,答案的核心逻辑是一样的,只是约束宽窄的区别。

4.2 可选属性和 readonly 修饰符会不会丢失

这是我在实际业务中曾经被坑过一次的问题。某次我用一个类似 Omit 的类型去处理接口,结果发现得到的类型里,原本可选字段全部变成了必选字段,直接把下游组件逼疯了。

原因是我当时写了一个“自作聪明”的映射类型,把属性值重新包装了一下,导致 TypeScript 不再把它视为同态映射类型(homomorphic mapped type),可选修饰符和 readonly 修饰符就丢了。

什么是同态映射类型?简单说,就是形如{ [P in keyof T]: ... }这种没有用as改变键集合、并且直接遍历keyof T的映射类型。这种映射类型会自动保留原有属性的可选标记、readonly 标记。而Pick<T, K>其实就是同态映射类型,所以标准 Omit 不会丢修饰符。

我写的映射版本{ [P in keyof T as P extends K ? never : P]: T[P] }同样会保留修饰符,因为键集合仍然是keyof T,只是有些键被as变成了 never 从而被丢弃。在实际测试里,可选属性和 readonly 属性都完好保留。这是我实际验证过的结果,你可以在自己工程里用Equal断言测一下,不要凭感觉判断。

4.3 分布式条件类型带来的“意外行为”

很多人在写Exclude或者自己写条件类型时,容易忽略分布式条件类型的触发条件。所谓分布式,是指当条件类型左侧的类型参数是一个裸类型参数时,TypeScript 会把联合类型的每个成员分发到条件里分别判断。

回到Exclude的实现:

type Exclude<T, U> = T extends U ? never : T

这里的T是一个裸类型参数,所以当keyof Todo是'title' | 'description' | 'completed'时,它会逐个判断每一个键是否 extends U,然后把结果重新合并。

如果你不小心把T包进了一个数组、元组或者其它泛型结构里,分发就不会发生。比如:

type WithoutDistribute<T, U> = [T] extends [U] ? never : T type Test = WithoutDistribute<'a' | 'b', 'a'> // 结果:'a' | 'b',而不是 'b'

因为[T]整体作为一个非裸类型参数,不会被分发,['a' | 'b'] extends ['a']整体为 false,所以返回了完整的 T。

这个知识点在 Omit 的常规解题里不会踩坑,因为keyof T传给Exclude后仍然是裸类型参数。但如果你将来自己写更复杂的类型工具,或者修改Exclude的代码,一定要记住:分发是条件类型的行为,不是理所当然的。我的经验是,一旦发现“类型工具没有按联合类型逐个处理”,优先检查类型参数是否被某种结构包裹了。

4.4 键类型不只是字符串:symbol 和 number 键

日常业务里,对象键基本是字符串字面量。但keyof不止返回字符串,它还可能包含 number 和 symbol 类型。比如数组类型:

type ArrKeys = keyof string[] // number | 'length' | 'push' | 'pop' | ...(Array 的所有方法名和属性名)

如果对数组类型用 Omit:

type MyOmit<string[], 'length'> // 结果会得到一个奇怪的东西,但重要的是这个操作并不会报错

再比如对象里定义了 symbol 键:

const uniqueKey = Symbol('key') interface Mixed { name: string [uniqueKey]: number }

keyof Mixed会包含typeof uniqueKey,Exclude和Pick都能正常处理 symbol 类型的键。组合式写法和映射类型写法在这个场景下行为基本一致,因为键集合的操作是通用的。

不过,如果你用字符串字面量去Pick一个只包含 symbol 键的对象,得到的新类型可能是空对象类型。这种行为不算错误,但用的时候要清醒:Omit 操作的是“键集合”,不是“字符串集合”。对数组、元组、函数等特定内置类型做 Omit 时要多留个心眼,因为它们的keyof往往比你想的要复杂得多。

5. 用 Omit 打开类型体操的大门:我的练习路径

5.1 题型之间的串联关系

如果按照正常顺序刷类型体操题,你会发现 Omit 这道题的知识点密度是陡然上升的。前面一两题可能只是让你实现MyPick或者MyReadonly,本质上是单步映射类型;到了 Omit,你需要同时理解:

  • keyof返回键集合;
  • 条件类型在联合类型上的分布式行为;
  • Exclude的过滤语义;
  • Pick的重建语义;
  • 映射类型如何保留属性修饰符;
  • as重映射如何丢弃键。

这几乎是一套完整的“类型函数式编程”基本功。所以我的建议是,做 Omit 之前先把MyPick、MyReadonly、MyExclude这几道更基础的题练熟,再回头挑战 Omit。不要硬啃,把“零件”准备好之后,组合只是最后一公里的事。

5.2 从易到难的实战变体

做完 Omit,可以顺着这个思路挑战更复杂的变体。比如“深层 Omit”,也就是递归地把嵌套对象里某个路径上的键排除掉。这种题考察的是递归类型和条件类型的结合,难度要比 Omit 高一个级别,网上也有不少实现思路。我没必要在这里把完整实现贴出来,但可以给你一个思考方向:把 T 的每个属性值展开,如果属性名要排除,就丢弃;如果属性名保留但值是一个对象,就对值递归调用同样的工具。

再比如“按属性值类型排除键”。假设你想排除所有值为函数类型的键,只保留普通数据字段。这种工具在前端处理接口响应数据时很实用,它需要你先取出所有键,再用T[K]取值的类型,最后根据值类型条件过滤。Omit 的“键集合过滤”思路在这里完全适用,只是把过滤条件从“键名是否匹配”换成了“值类型是否匹配”。

我个人的经验是:每学一个新工具类型,就想想能不能把它改造成更符合业务场景的变体。这样练的不是“背答案”,而是“造轮子”的能力。

5.3 练习方法和个人体会

最后聊聊我自己的练习习惯,也许对你有参考价值。

第一,先不看答案,直接写。哪怕第一版写得又臭又长、完全跑不通测试,也要逼自己先动手。写的过程会逼你把“打算用什么思路”变成“具体怎么写”,这中间会产生大量具体的疑问,这些疑问才是学习的关键。

第二,写完一版之后,再要求自己用另一种思路重写一遍。比如先用组合式写法,再用映射类型加as子句重写。两版对比之后,你对类型系统的理解会从一个“会用的 API”变成“能解释的原理”。

第三,主动扩展测试用例。不要只满足于题目的测试,把可选属性、readonly 属性、联合类型键、多个排除键这些边界情况都加进自己的断言里。我在写这篇笔记时就是这么做的,结果还真发现了约束宽窄、修饰符保留这些值得讨论的问题。

说实话,这道题本身并不难,但它像一个分水岭。跨过它之后,你再去看Parameters、ReturnType、Awaited这些更绕的工具类型,会少很多畏惧感。因为你会发现,复杂类型本质上是简单类型的组合,而组合方式就是那几个核心操作:条件、分发、映射、递归。Omit 正好让你把“条件 + 分发 + 映射”三件套练了一遍。

我到现在写公共类型工具时,还会经常想起这道题。比如要写一个“从配置对象里去掉了某类字段后再传给子组件”的类型,第一反应仍然是Pick<T, Exclude<keyof T, K>>那条链。类型体操从来不是炫技,它练的是你描述类型的精确度。能把 Omit 彻底讲明白的人,写业务里的复杂类型时一定不会含糊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询