【翻译】TypeScript中可区分联合类型的省略

原文链接tkdodo.eu/blog/omit-f...

您可能已经注意到,TypeScript 内置了实用类型来帮助处理对象的常见类型转换,例如 OmitPick

在构建针对低级(更细致)原语(基础组件/原始组件)的特定包装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上联系我,或者直接在下方留言。⬇️

相关推荐
月弦笙音21 小时前
【Promise.withResolvers】发现这个api还挺有用
前端·javascript·typescript
4***14901 天前
TypeScript在React中的前端框架
react.js·typescript·前端框架
槁***耿1 天前
TypeScript类型推断
前端·javascript·typescript
y***54881 天前
TypeScript在React项目中的状态管理
javascript·react.js·typescript
y***86691 天前
TypeScript在Electron应用中的使用
javascript·typescript·electron
初学者,亦行者2 天前
DevUI微前端集成实战解析
前端·typescript
心随雨下2 天前
typescript中Triple-Slash Directives如何使用
前端·javascript·typescript
爱泡脚的鸡腿2 天前
ts学习D1---超基础语法导入
typescript
q***38513 天前
TypeScript 与后端开发Node.js
javascript·typescript·node.js