type-challenges 精讲 33763:用 Union to Object from key 从对象联合中按键提取目标对象
type-challenges 精讲 #33763:用 Union to Object from key 从对象联合中按键提取目标对象
导读
本篇文章围绕 type-challenges 仓库中的 hard 难度题目 Union to Object from key 展开。该题要求实现一个工具类型 UnionToObjectFromKey<Union, Key>:给定一个由对象类型组成的联合类型和一个键名,找出联合类型中包含该键的对象类型(可多个,结果仍是联合)。读完本文,你将掌握条件类型的分布式分发(distributive conditional types)、Extract 与 Record 的配合用法,以及如何用 keyof 做键存在性判断,并能独立写出通过全部测试用例的类型实现。
题目速览:根据键名筛选对象联合
题目原文(README.md)只有一句话:"Find the object containing the key in the union type by the key. It takes two parameters: a union of object types and a key name."——即:通过键名,在联合类型中找出包含该键的对象类型。
该题难度标记为 hard(见 info.yml 中的 difficulty: hard),初始模板非常简单:
type UnionToObjectFromKey<Union, Key> = any
实现目标的关键语义可以拆解为两点:
- 筛选(filter)而非转换(map):结果是原联合的子集,而不是新构造的对象;
- 命中可为多个:只要包含目标键的对象都会被保留,因此结果本身仍可能是一个联合类型。
从测试用例反推精确语义
仓库为该题提供了完整的在线评测测试文件 test-cases.ts,测试夹具定义了三个对象:
type Foo = {
foo: string
common: boolean
}
type Bar = {
bar: number
common: boolean
}
type Other = {
other: string
}
三条断言精确刻画了题目的边界行为:
type cases = [
Expect<Equal<UnionToObjectFromKey<Foo | Bar, 'foo'>, Foo>>,
Expect<Equal<UnionToObjectFromKey<Foo | Bar, 'common'>, {
foo: string
common: boolean
} | {
bar: number
common: boolean
}>>,
Expect<Equal<UnionToObjectFromKey<Foo | Bar | Other, 'common'>, {
foo: string
common: boolean
} | {
bar: number
common: boolean
}>>,
]
逐条解读:
- 断言 1:
Key = 'foo'时,只有Foo声明了foo键,Bar没有,因此结果是Foo本身(单元素); - 断言 2:
Key = 'common'时,Foo与Bar都包含common,两者都被保留,结果依然是它们的联合; - 断言 3:在
Foo | Bar | Other中找'common',Other不含该键被剔除,结果与断言 2 完全一致。
测试中的 Expect 与 Equal 均来自仓库的工具类型包 utils/index.d.ts,其中 Equal<X, Y> 使用经典的函数逆变技巧实现严格相等比较,任何结构差异都会导致断言失败。
核心原理:条件类型的分布式分发
要解决本题,必须先掌握 TypeScript 条件类型最重要的行为——分布式分发:当 T extends U ? X : Y 中的 T 是裸类型参数(即没有被子句包裹的泛型变量)时,条件类型会对 T 的联合成员逐个求值,再把结果重新拼回联合。
例如:
type FilterString<T> = T extends string ? T : never
// 分发到 'a' | 'b' | 42 | true,结果是 'a' | 'b'
type R = FilterString<'a' | 'b' | 42 | true>
利用"命中保留、未命中变 never"的分发模式,就可以实现类型层面的筛选。本题的 Union 恰好是裸类型参数,天然适合这种写法。
解法一:Extract + Record(推荐)
TypeScript 内置的 Extract<T, U> 本质就是一个分发条件类型:
type Extract<T, U> = T extends U ? T : never
因此最简洁的解法是把"是否包含键 Key"的判断编码进 U 的位置,用 Record<Key, unknown> 来描述"拥有 Key 这个键的对象":
type UnionToObjectFromKey<Union, Key> = Extract<Union, Record<Key, unknown>>
验证三条断言:
Foo满足Record<'foo', unknown>(有foo: string),Bar不满足,Extract分发后得到Foo;Foo、Bar都满足Record<'common', unknown>,Other不满足,分发后得到Foo | Bar;- 第三条同理,
Other被剔除。
这里有一个容易踩的坑:Record<Key, unknown> 对键的要求是"键存在"而非"值为 unknown"。任何对象字面量只要声明了该键,无论其值类型是什么,都满足 Record<Key, unknown>——这正是题目需要的语义。
解法二:裸参数分发 + 条件判断
如果你希望完全不依赖 Extract,可以直接写分发条件类型:
type UnionToObjectFromKey<Union, Key> = Union extends Record<Key, unknown> ? Union : never
这里 Union 是裸类型参数,会对 Foo、Bar、Other 分别做 extends Record<Key, unknown> 判断,命中者原样返回、未命中者归并为 never。该写法与解法一在语义上完全等价,只是显式展示了分发机制本身。
若想用 keyof 判断键是否存在,也可以写成:
type UnionToObjectFromKey<Union, Key> = Union extends any
? Key extends keyof Union
? Union
: never
: never
注意此处 Union extends any 的作用是人为触发分发(any 是任何类型的一等公民,条件恒成立,但 Union 作为裸参数仍会按成员分发),随后用 Key extends keyof Union 判断键是否在该成员的可枚举键集合中。Other 的 keyof 只有 'other',因此 'common' extends keyof Other 为假,Other 被剔除。
边界情况与易错点
- Key 不在任何成员中:例如
UnionToObjectFromKey<Foo | Bar, 'baz'>,所有成员都不满足条件,结果为never。这是"筛选后空集"的自然表达。 - Key 出现在部分成员中:结果是包含该键的成员的联合(子集),这是本题与"从联合中取交集"类题目的本质区别。
- Key 出现在全部成员中:结果退化为原联合本身(断言 2 即是此场景)。
- 不要写成非分发形式:如果写成
Union extends Record<Key, unknown> ? Union : never而Union被当作整体求值,当且仅当联合整体满足条件时才返回整个联合,无法做到逐成员筛选。要利用Extract、裸参数分发,或显式Union extends any触发分发。 - 键类型范围:
Key理论上可以是string | number | symbol(即PropertyKey),本题测试用例只覆盖了字符串字面量键,若需要更严格可以加上Key extends PropertyKey约束。
仓库内的关联与延伸
本题在 type-challenges 中属于"union 分发"技术族。仓库同目录下还有一道姊妹题 Union to Intersection,要求把联合转换成交叉('foo' | 42 | true → 'foo' & 42 & true),其经典解法同样建立在分发机制之上:
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
该解法也被收录进工具类型包 utils/index.d.ts 的 UnionToIntersection 中,作为跨题复用的基础设施。对比可见:本篇文章实现的 UnionToObjectFromKey 走的是"分发 + 筛选"路线,而 UnionToIntersection 走的是"分发 + 逆变推断"路线,两者共同展示了联合类型操作的两个核心方向。
验证方式
- 在线评测:题目页面提供在线评测入口(README 中的 Take the Challenge 徽章),可直接在浏览器中运行测试用例;
- 本地仓库:仓库根 package.json 使用 pnpm 管理依赖,测试框架(
@type-challenges/utils)位于 utils/,题目自身的测试断言集中在 test-cases.ts,将template.ts中的any替换为实现后即可运行校验。
总结
UnionToObjectFromKey 的核心价值在于演示了用条件类型对对象联合做按键筛选的完整套路:借助 Extract<Union, Record<Key, unknown>> 或显式的裸参数分发,一行即可完成"包含某键的对象保留、不含者剔除"的类型逻辑。掌握分发机制后,无论是筛选、映射还是推断联合成员,你都能在类型层面写出与运行时 filter 同样直观的代码。