
您可能已经注意到,TypeScript 内置了实用类型来帮助处理对象的常见类型转换,例如 Omit 和 Pick。
在构建针对低级(更细致)原语(基础组件/原始组件)的特定包装React组件时,Omit在对Props(组件属性/参数)进行类型定义时很有用,因为它可以将您的实现与基础(原语)组件联系起来(进行类型约束)。
typescript
type SelectProps = {
onChange: (value: string) => void
options: ReadonlyArray<SelectOption>
value: string | null
}
type UserSelectProps = Omit<SelectProps, 'options'>
它基本上表示:我需要依赖组件的所有属性,但这个(或这些)除外 。然后我们可以自己通过展开 props 并设置缺失的内容来构建 UserSelect 组件:
javascript
function UserSelect(props: UserSelectProps) {
const users = useSuspenseQuery(usersQueryOptions)
return <Select {...props} options={users.data} />
}
这有两个优势:首先,在构建 SelectProps 的包装组件时,我们无需重新声明(从而避免复制)所有字段;其次,这些字段会自动保持同步。这种依赖关系是刻意设计的:由于我们把 {...props} 展开到 Select 上,类型声明也已包含该操作,因此当 Select 增加字段时,UserSelect 也能自动受益。
但这种做法也存在弊端:当类型在多层组件间堆叠时,可能难以清晰追踪组件实际接收的属性。建议避免超过单层嵌套,总体而言这仍是种优雅的模式------直到 SelectProps 变得过于复杂为止。
可区分联合类型
让我们为 Select 添加一个新特性:一个 clearable 的属性,允许用户取消选择当前值。当这种情况发生时,我们需要触发 onChange 事件并传入 null。类型系统的初稿可能如下所示:
typescript
type SelectProps = {
onChange: (value: string | null) => void
options: ReadonlyArray<SelectOption>
value: string | null
clearable?: boolean
}
这虽然可行,却引入了新问题:所有现有的 Select 用法现在都会报错,因为它们的 onChange 处理程序无法处理 null 。这不太理想,因为这些组件在运行时永远不会收到 null ------显然它们无法被清除。
我们真正需要告诉类型检查器的是:"若传递 clearable 对象,onChange 可能收到 null ;否则绝不会收到"。此时区分联合类型能帮上忙:
typescript
type BaseSelectProps = {
options: ReadonlyArray<SelectOption>
value: string | null
}
type ClearableSelectProps = BaseSelectProps & {
clearable: true
onChange: (value: string | null) => void
}
type UnclearableSelectProps = BaseSelectProps & {
onChange: (value: string) => void
clearable?: false
}
type SelectProps = ClearableSelectProps | UnclearableSelectProps
这看起来比之前更复杂,但值得我们付出这些努力。现在,TypeScript 能根据 clearable 标志区分联合类型:当该标志传递为 true 时,onChange 接收的结构与传递 false 或未传递时不同。提取到 BaseSelectProps 的操作只是为了避免重复联合类型中两部分相同的类型声明。
现在我们的新 clearable 功能在类型层面也实现了向后兼容,可以准备发布了。但出乎意料的是,我们在 UserSelect 组件的CI中发现了错误,类似于:

初读时我完全摸不着头脑------毕竟我只是用Omit组合类型,之前明明能正常工作。🤔 到底是什么变了?
当我检查 UserSelectProps 现在展开的内容后,事情开始变得更有意义了:
typescript
type UserSelectProps = {
onChange:
| ((value: string | null) => void)
| ((value: string) => void)
value: string | null
clearable?: boolean | undefined
}
区分 clearable 的联合类型已消失------添加 Omit 操作符后,所有内容都被"展开"了。这显然是 Omit 的错误,但事实并非如此,这是其预期行为。
Omit 不会单独处理每个联合类型(它不具备分配律 ),而是将联合类型视为整体,逐个映射所有成员。正如Ryan Cavanaugh在某次议题评论中所言,所有可能的Omit实现方案都存在权衡取舍,他们选择了认为最符合通用需求的方案。
既然能在TypeScript类型中运行Doom,那么编写一个不破坏联合体的 Omit 辅助工具理应可行。幸运的是,我们只需借鉴可分配条件类型即可实现。
条件类型
条件类型允许 TypeScript 根据测试结果在两种类型间进行选择。它相当于类型层面的 if 语句:
T extends U ? X : Y你可以将其理解为:"如果T能被赋值给U,则使用类型X;否则使用类型Y。"
分配条件类型
正如 TypeScript 文档所述:"当条件类型作用于泛型类型时,若遇到联合类型则会变成分配式。" 换言之,条件会分别作用于联合类型的每个成员,这正是我们想要的效果。然而我们既没有条件类型,也不真正需要它。那么这究竟要表达什么?
分配性省略
你是否见过类似 T extends any ? ... : never 的类型定义,并疑惑:为什么要这么做?显然,所有类型都继承自 any ------这是 TypeScript 的顶层类型。
没错,正是如此,这恰恰是关键所在。这是种伪条件类型 ,永远匹配条件语句的 true 分支。这相当于直接使用 true 分支内的内容,但现在它具有了分配性。
借助这种特性,我们只需在虚假条件型的真分支中调用 Omit,就能创建更适合联合类型的 Omit 辅助类型:
scala
type DistributiveOmit<T, K extends keyof T> = T extends any
? Omit<T, K>
: never
让我们看看将此应用于 UserSelectProps 时会发生什么:
ini
type UserSelectProps = DistributiveOmit<SelectProps, 'options'>
如果我们将鼠标悬停在此类型上,它将展开为:
sql
type UserSelectProps =
| Omit<ClearableSelectProps, 'options'>
| Omit<UnclearableSelectProps, 'options'>
这清楚地表明它按预期工作:Omit 被应用于联合类型的每个部分,现在我们的 UserSelect 也将隐式受益于 clearable 特性。🎉
若需实践该方案,可访问此 TypeScript Playground 演示环境。请注意,此技巧同样适用于 Pick 等其他辅助类型。
此外,我们的 DistributiveOmit 方案还具备常规 Omit 不具备的额外优势:
Limited Keys
如果我们查看 Omit 的类型签名:
scala
type Omit<T, K extends keyof any> = {
[P in Exclude<keyof T, K>]: T[P]
}
我们可以发现, K 类型参数没有上界限制(keyof any 展开后等同于 string | number | symbol)。这意味着你可以传递对象中实际不存在的键。虽然实践中这无伤大雅------忽略不存在的键不会改变任何结果------但确实让我感到意外。当我切换到 DistributiveOmit(该类型要求K extends keyof T)后,TypeScript突然在五个键值被省略的位置报错------尽管其中两个键早已不存在。
这些键很可能曾存在过,只是在清理过程中被遗留下来。了解我的人都知道,我向来厌恶死代码,这正好成了整理代码的好机会。✂️
今天就到这里。如有任何疑问,欢迎在Bluesky上联系我,或者直接在下方留言。⬇️