【翻译】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上联系我,或者直接在下方留言。⬇️

相关推荐
行走的陀螺仪12 小时前
前端基建从0到1搭建步骤清单(含工具选型+配置要点+落地注意事项)
前端·javascript·typescript·设计规范·前端工程化·规范化·前端基建
ttod_qzstudio16 小时前
Vue 3 的魔法:用 v-bind() 让 CSS 爱上 TypeScript 常量
css·vue.js·typescript
[seven]1 天前
React Router TypeScript 路由详解:嵌套路由与导航钩子进阶指南
前端·react.js·typescript
_Kayo_1 天前
TypeScript 学习笔记2
前端·javascript·typescript
[seven]2 天前
React Router TypeScript 路由详解:类型安全的路由配置与参数处理
安全·react.js·typescript
李子烨2 天前
吃饱了撑的突发奇想:TypeScript 类型能不能作为跑业务逻辑的依据?(纯娱乐)
前端·typescript
坐公交也用券2 天前
适用于vue3+pnpm项目自动化类型检查及构建的Python脚本
开发语言·javascript·python·typescript·自动化
LYFlied2 天前
TypeScript 常见面试问题
ubuntu·面试·typescript
前端不太难2 天前
RN + TypeScript 项目越写越乱?如何规范架构?
前端·javascript·typescript
JQ_Zhang3 天前
极致体验!一个小工具实现智能关键词高亮 (中英文混排/全字匹配)
typescript