PartialByKeys
PartialByKeys
题目
实现一个通用的 PartialByKeys<T, K>,它接收两个类型参数 T 和 K。
K 指定应设置为可选的 T 的属性集。当没有提供 K 时,它就和普通的 Partial<T> 一样使所有属性都是可选的。
interface User {
name: string
age: number
address: string
}
type UserPartialName = PartialByKeys<User, 'name'>
// { name?: string; age: number; address: string }解题思路
type PartialByKeys<T, K extends keyof T = keyof T> =
Omit<T, K> & Partial<Pick<T, K>> extends infer O
? { [P in keyof O]: O[P] }
: never解法将 T 一分为二,重新组合,再将交叉类型"打平"为普通对象类型。
第一步——拆分: Omit<T, K> 保留所有不在 K 中的属性(保持必需)。Partial<Pick<T, K>> 单独取出 K 中的属性并将它们变为可选。
第二步——合并: 用 & 将两部分重新合并为一个类型。
第三步——打平: 原始交叉类型 Omit<T, K> & Partial<Pick<T, K>> 在结构上等同于目标对象,但无法通过类型挑战的严格 Equal<> 检查,因为它是交叉类型而非普通对象字面量类型。extends infer O ? { [P in keyof O]: O[P] } : never 这个惯用法通过将 O 的所有键重新映射为一个新对象类型来解决这个问题。
默认约束: K extends keyof T = keyof T 身兼两职——默认值 = keyof T 使 K 成为可选参数,让 PartialByKeys<User> 退化为 Partial<User>;约束 extends keyof T 确保传入未知键(如 'unknown')时会产生编译错误(这正是 @ts-expect-error 测试用例所验证的)。
深度解析
为什么交叉类型需要打平?
TypeScript 在计算 Equal<A, B> 时,会在极精细的层面检查结构等价性。交叉类型 { age: number; address: string } & { name?: string } 与普通对象 { age: number; address: string; name?: string } 虽然可以相互赋值,但在类型系统的内部表示中并不完全相同。类型挑战中使用的 Equal 工具类型底层依赖 CheckNonNullable 技巧,能够区分这两种形式。
打平惯用法 T extends infer O ? { [K in keyof O]: O[K] } : never 强制 TypeScript 将类型重新求值为单个对象字面量,从而折叠交叉。这是高级 TypeScript 中最常用的"规范化"模式之一。
Omit + Partial<Pick<>> 模式
这个模式在需要对部分键选择性地应用修饰符时经常出现。同样的结构可以适配其他修饰符:
| 目标 | 模式 |
|---|---|
| 将 K 变为可选 | Omit<T, K> & Partial<Pick<T, K>> |
| 将 K 变为必需 | Omit<T, K> & Required<Pick<T, K>> |
| 将 K 变为只读 | Omit<T, K> & Readonly<Pick<T, K>> |
每种变体都遵循相同的三步节奏:拆分、修改、合并。
替代方案:带 as 键重映射的映射类型
如果不想使用内置工具类型,用带 as 键重映射的单个映射类型也能达到同样效果:
type PartialByKeys<T, K extends keyof T = keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P]
} & {
[P in K]?: T[P]
} extends infer O ? { [P in keyof O]: O[P] } : never第一个映射类型用 as P extends K ? never : P 过滤掉 K 中的键(将键映射为 never 实际上就是删除它们),第二个映射类型将 K 中的键作为可选属性重新加回。两种方法产生完全相同的结果;Omit/Pick 版本对大多数读者来说可读性更强。
带默认值的类型参数
K extends keyof T = keyof T 是一个带约束的默认值:默认值 keyof T 本身满足约束 extends keyof T,因此无需特殊处理。不提供 K 时,该类型的行为与 Partial<T> 完全一致——因为 Omit<T, keyof T> 是 {},Partial<Pick<T, keyof T>> 是 Partial<T>,而 {} & Partial<T> 打平后就是 Partial<T>。
总结
- 交叉类型打平(
T extends infer O ? { [K in keyof O]: O[K] } : never)是将交叉类型转换为普通对象类型的经典惯用法。只要结果需要通过严格的相等性检查,就应该应用它。 Omit<T, K> & Partial<Pick<T, K>>是对部分键选择性地应用可选修饰符(或其他任何修饰符)的惯用模式。将Partial替换为Required或Readonly即可适配该模式。- 带约束的默认类型参数(
K extends keyof T = keyof T)在一次声明中同时提供了回退值和编译期守卫——默认值使参数变为可选,约束则拒绝无效输入。 @ts-expect-error在测试套件中是正向断言:它验证类型系统能够正确拒绝无效调用,确认你的约束确实在生效。
