最近,我不得不将一个联合类型转换成一个交叉类型。在一个辅助类型的工作中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
在上面的例子中,FormatKeys 是never ,因为在这个类型中没有共同的、相交的键。由于我不想维护额外的类型(那可能会出错),我需要以某种方式将我的视频格式的联合转化为视频格式的交集。交集意味着所有的键都需要是可用的,这允许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中的含义后得出这个解决方案的。在类型系统的行话旁边,它有效地告诉我们,如果作为函数参数使用,我们需要提供通用联盟的所有组成成分。而这在赋值过程中作为一个交集发挥作用。