学习TypeScript:联盟到相交类型

89 阅读6分钟

最近,我不得不将一个联合类型转换成一个交叉类型。在一个辅助类型的工作中UnionToIntersection<T> ,让我学到了很多关于条件类型和严格函数类型的东西,我想和你分享。

当我试图对一个至少需要设置一个属性的类型进行建模时,我非常喜欢使用非歧视性的联合类型,使所有其他属性都是可选的。就像在这个例子中。

type Format320 = { urls: { format320p: string } }
type Format480 = { urls: { format480p: string } }
type Format720 = { urls: { format720p: string } }
type Format1080 = { urls: { format1080p: string } }

type Video = BasicVideoData & (
  Format320 | Format480 | Format720 | Format1080
)

const video1: Video = {
  // ...
  urls: {
    format320p: 'https://...'
  }
} // ✅

const video2: Video = {
  // ...
  urls: {
    format320p: 'https://...',
    format480p: 'https://...',
  }
} // ✅

const video3: Video = {
  // ...
  urls: {
    format1080p: 'https://...',
  }
} // ✅

然而,当你需要例如所有可用的键时,把它们放在一个联合体中会产生一些副作用。

// FormatKeys = never
type FormatKeys = keyof Video["urls"]

// But I need a string representation of all possible
// Video formats here!
declare function selectFormat(format: FormatKeys): void

在上面的例子中,FormatKeysnever ,因为在这个类型中没有共同的、相交的键。由于我不想维护额外的类型(那可能会出错),我需要以某种方式将我的视频格式的联合转化为视频格式的交集。交集意味着所有的键都需要是可用的,这允许keyof 操作者创建我所有格式的联合。

那么,我们如何做到这一点呢?答案可以在随TypeScript 2.8发布的条件类型的学术描述中找到。这里有很多专业术语,所以让我们一块儿看一下,以便弄清楚。

解决方案#

我先来介绍一下解决方案。如果你不想知道这下面的工作原理,就把这看作是TL/DR。

type UnionToIntersection<T> = 
  (T extends any ? (x: T) => any : never) extends 
  (x: infer R) => any ? R : never

还在这里?很好!这里有很多东西需要解开。有一个条件类型嵌套在一个条件类型中,我们使用推断关键字,一切看起来都是太多的工作,根本没有任何作用。但它确实如此,因为TypeScript对几个关键部分进行了特殊处理。首先,裸体类型。

赤裸裸的类型#

如果你看一下UnionToIntersection<T> 中的第一个条件,你可以看到我们使用通用类型参数作为一个裸体类型。

type UnionToIntersection<T> = 
  (T extends any ? (x: T) => any : never) //...

这意味着我们检查T 是否在一个子类型的条件中,而不把它包裹在什么东西中。

type Naked<T> = 
  T extends ... // naked!

type NotNaked<T> = 
  { o: T } extends ... // not naked!

条件类型中的裸体类型有一个特点。如果T 是一个联合体,它们会对联合体的每个组成成分运行条件类型。所以用裸体类型,联合类型的条件变成了条件类型的联合。比如说。

type WrapNaked<T> = 
  T extends any ? { o: T } : never

type Foo = WrapNaked<string | number | boolean>

// A naked type, so this equals to

type Foo = 
  WrapNaked<string> | 
  WrapNaked<number> | 
  WrapNaked<boolean>

// equals to

type Foo = 
  string extends any ? { o: string } : never |
  number extends any ? { o: number } : never |
  boolean extends any ? { o: boolean } : never

type Foo = 
  { o: string } | { o: number } | { o: boolean }

与非裸露的版本相比。

type WrapNaked<T> = 
  { o: T } extends any ? { o: T } : never

type Foo = WrapNaked<string | number | boolean>

// A non Naked type, so this equals to

type Foo = 
  { o: string | number | boolean } extends any ? 
    { o: string | number | boolean } : never

type Foo = 
  { o: string | number | boolean }

很微妙,但是对于复杂的类型来说有很大的不同

所以,回到我们的例子中,我们使用裸体类型并询问它是否扩展了any(它总是这样,any是允许所有的顶级类型)。

type UnionToIntersection<T> = 
  (T extends any ? (x: T) => any : never) //...

由于这个条件总是真的,我们把我们的通用类型包裹在一个函数中,其中T 是函数参数的类型。但是我们为什么要这样做呢?

相邻的变体类型位置#

这就引出了第二个条件。

type UnionToIntersection<T> = 
  (T extends any ? (x: T) => any : never) extends 
  (x: infer R) => any ? R : never

由于第一个条件总是产生真,意味着我们的类型被包裹在一个函数类型中,另一个条件也总是产生真。我们基本上是在检查我们刚刚创建的类型是否是它的一个子类型。但我们不是通过T ,而是推断出一个新的类型R ,并返回推断出的类型。

所以我们所做的是通过一个函数类型来包装和解除类型T

通过函数参数来做这件事,把新推断的类型R 带到一个反变量的位置。我将在以后的文章中解释反变量。现在,重要的是要知道,这意味着在处理函数参数时,你不能把一个子类型分配给一个超类型。

例如,这样就可以了。

declare let b: string
declare let c: string | number

c = b // ✅

string 是 的一个子类型, 的所有元素都出现在 ,所以我们可以把 赋给 。 仍然按照我们最初的意图行事。这就是string | number string string | number b c c 共变性

另一方面,这是不可行的。

type Fun<X> = (...args: X[]) => void

declare let f: Fun<string>
declare let g: Fun<string | number>

g = f // 💥 this cannot be assigned

如果你想一想,这也很清楚。当把f 分配给g 时,我们突然不能再用数字调用g 了!我们错过了 的部分契约。我们错过了g 的部分合同。这就是contra-variance,它实际上像一个交叉点一样工作。

这就是当我们在一个条件类型中放入禁忌变量位置时发生的事情。TypeScript从中创建了一个交集。意思是说,既然我们从一个函数参数推断,TypeScript知道我们必须履行完整的契约。创建一个联合中所有成分的交集。

基本上,union到intersection。

解决方案如何运作#

让我们把它运行起来。

type UnionToIntersection<T> = 
  (T extends any ? (x: T) => any : never) extends 
  (x: infer R) => any ? R : never

type Intersected = UnionToIntersection<Video["urls"]>

// equals to

type Intersected = UnionToIntersection<
  { format320p: string } |
  { format480p: string } |
  { format720p: string } |
  { format1080p: string } 
>

// we have a naked type, this means we can do
// a union of conditionals:

type Intersected = 
  UnionToIntersection<{ format320p: string }> |
  UnionToIntersection<{ format480p: string }> |
  UnionToIntersection<{ format720p: string }> |
  UnionToIntersection<{ format1080p: string }> 

// expand it...

type Intersected = 
  ({ format320p: string } extends any ? 
    (x: { format320p: string }) => any : never) extends 
    (x: infer R) => any ? R : never | 
  ({ format480p: string } extends any ? 
    (x: { format480p: string }) => any : never) extends 
    (x: infer R) => any ? R : never | 
  ({ format720p: string } extends any ? 
    (x: { format720p: string }) => any : never) extends 
    (x: infer R) => any ? R : never | 
  ({ format1080p: string } extends any ? 
    (x: { format1080p: string }) => any : never) extends 
    (x: infer R) => any ? R : never

// conditional one!

type Intersected = 
  (x: { format320p: string }) => any extends 
    (x: infer R) => any ? R : never | 
  (x: { format480p: string }) => any extends 
    (x: infer R) => any ? R : never | 
  (x: { format720p: string }) => any extends 
    (x: infer R) => any ? R : never | 
  (x: { format1080p: string }) => any extends 
    (x: infer R) => any ? R : never

// conditional two!, inferring R!
type Intersected = 
  { format320p: string } | 
  { format480p: string } | 
  { format720p: string } | 
  { format1080p: string }

// But wait! `R` is inferred from a contra-variant position
// I have to make an intersection, otherwise I lose type compatibility

type Intersected = 
  { format320p: string } & 
  { format480p: string } & 
  { format720p: string } & 
  { format1080p: string }

这就是我们一直在寻找的东西!所以应用到我们原来的例子。

type FormatKeys = keyof UnionToIntersection<Video["urls"]>

FormatKeys 现在是 。每当我们在原始联盟中添加另一种格式时, 类型就会自动更新。维护一次,到处使用。"format320p" | "format480p" | "format720p" | "format1080p" FormatKeys

进一步阅读#

我是在深入研究了什么是禁忌位置以及它们在TypeScript中的含义后得出这个解决方案的。在类型系统的行话旁边,它有效地告诉我们,如果作为函数参数使用,我们需要提供通用联盟的所有组成成分。而这在赋值过程中作为一个交集发挥作用。