Yuzhe's Blog

yuzhes

PartialByKeys

PartialByKeys

题目链接

题目

实现一个通用的 PartialByKeys<T, K>,它接收两个类型参数 TK

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>

总结