首发知乎:https://zhuanlan.zhihu.com/p/438903357
-
链接:TypeScript: Union to intersection type
作者:@ddprrt
日期:2020.06.29
转载请注明本文知乎链接和译者 Hugo。
摘要
总有些场景,你会需要把一个并集转换为交集。我们来学习如何通过条件类型和逆变来达到这个目的。
通过本文,你可以学到:
- 类型的交集和并集
- 条件类型
- 协变和逆变
下文名词:
- union:并集
- intersection:交集
- naked type:裸类型
- non naked type | not naked type:非裸类型
正文
近来,我需要把一个 union 类型转换成 intersetion 类型。为了解决这个问题,我花了大量时间在一个工具类型 UnionToIntersection
,这些工作教给了我成吨 TypeScript 的条件类型和严格函数类型,这些内容也是我想用这篇文章和你们分享的。 我非常喜欢和非辨识联合类型(non-discriminated union types)打交道 ,然后让其他属性可选。就像下面这个例子:
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://...', } } // ✅
然而,当你想把这些类型放入一个联合类型且你想获得所有的 key 时,会有一些副作用
// 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,因为这些 key 都不一样,所以交集就是 never。因为我不想维护额外的类型(额外的类型可能是错误的源头),所以,我需要把我的 Video 格式的并集变换为交集。交集意味着,所有的 key 都是可用的,这样就可以通过 keyof 操作符去创建我想要的格式的并集。
所以,我是怎么做的呢?答案是 TypeScript 2.8 的条件类型。这里有一些术语,但是别急,我们一步步来看看这个问题如何解决。
方案
我开始展示我的方案。如果你不想知道下面的表达式时如何工作的,可以使用(TL/DR)的功能。(该功能把文字隐去,只留下了最后的代码)
type UnionToIntersection
= (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never 还在看么?很好,这里有太多要从这里一步步推导,我们慢慢来。这个式子是一个条件类型嵌入在另一个条件类型里,我们用 infer 关键字和还有这些式子看起来什么都没做。但是其实它们做了很多事,这是 TypeScript 的一些特性。首先,naked 类型。
裸类型(the naked type)
如果你仔细看 UnionToIntersection
的第一个条件,你可以看到我们用一个泛型参数作为一个裸类型。 type UnionToIntersection
= (T extends any ? (x: T) => any : never) //... This means that we check ifTis in a sub-type condition without wrapping it in something.
type Naked
= T extends ... // 裸类型 type NotNaked = { o: T } extends ... // 非裸类型 裸类型在条件类型中有特性。如果 T 是一个联合类型,编译器会触发条件类型对于联合类型的每一个组成类型做运算。所以对于一个裸类型,联合类型的条件变成了条件类型的联合类型。举例来说:
type WrapNaked
= T extends any ? { o: T } : never type Foo = WrapNaked // 因为这是一个裸类型,所以上式等价于下式 type Foo = WrapNaked | WrapNaked | WrapNaked // 等价于 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
= { o: T } extends any ? { o: T } : never type Foo = WrapNaked // 因为这是一个非裸类型,所以这个等价于 // 即无法展开 type Foo = { o: string | number | boolean } extends any ? { o: string | number | boolean } : never type Foo = { o: string | number | boolean } 这个看起来很微妙,但是对于复杂的类型,结果是大不同的!
所以,会到我们的例子,我们使用裸类型,然后加上条件,如果这个类型 extends any(这个条件一定会触发,因为 any 是所有类型的顶级类型)
type UnionToIntersection
= (T extends any ? (x: T) => any : never) //... // 相当于把 T 装在了后面这个函数参数的位置上,可以理解为装箱。 因为这个类型总是真,所以我们把我们的一个泛型类型包装在一个函数里,这里 T 是函数的参数,但是为啥我们要这样做呢?
逆变类型位置(Contra-variant type positions)
然后我们来看第二个条件:
type UnionToIntersection
= (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never 因为第一个式子一定是真,所以我们把 T 装入了函数的参数的位置上,所以下一句的条件也一定是真。因为下一句本质是在说这个类型是不是这个类型的子类型。但是和直接用 T 不一样,我们 infer 了一个新的类型 R,然后把这个 infer 的类型返回了。
所以我们只不过通过函数类型对类型 T进行了包装和解包。
通过这个操作,新的 infer 类型 R 是一个逆变参数位置。我会在后续的文章解释逆变。现在,你只需要知道,逆变意味着,你不能把父类型赋给子类型。因为父类型的范围更大。
declare let b: string declare let c: string | number c = b // ✅
string 是 string | number 的子类型,所有 string 包含的元素一定包含在 string | number 中,所以我们可以把 b 赋给 c。c 和之前的行为是一样的,这个叫协变(co-variance)
但是下面这个例子,就是有问题的:
type Fun
= (...args: X[]) => void declare let f: Fun declare let g: Fun g = f // this cannot be assigned 如果你想一想,这个也不难理解。当把 f 赋给 g 时,新的 g 不能使用 number 类型的参数了!我们丢失了 g 的一部分。这个叫 逆变(contra-variance),这个和交集的工作机制类似。
这个是当我们把逆变位置放在条件类型时会发生的:TypeScript 会创建一个交集。意味着,因为我们从函数参数里 infer 了一个类型,TypeScript 知道我们必须符合逆变的条件。然后 TypeScript 会自动创建并集中所有的成分的交集。
基本上,这个就是并集的交集(union to intersection)
这个方案是如何工作的
来看这个代码:
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 ) type UnionToIntersection
= (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never type Intersected = UnionToIntersection 这就是我们寻找的解决方案!所以来看我们一开始的例子:
type FormatKeys = keyof UnionToIntersection
FormatKeys现在就变成了"format320p" | "format480p" | "format720p" | "format1080p"。当我们在原来的并集中增加其他的格式, FormatKeys会自动更新这个格式。维护一份,到处使用。
更多阅读
我通过研究在 TypeScript 中逆变问题解决了这个问题。通过这个类型系统术语,我们可以有效的通过函数参数来获得一个泛型并集的所有成分。
如果你想更深的研究这个话题,我建议阅读以下文章:
- TypeScript 2.4 :函数逆变相关
- TypeScript 2.8: 条件类型相关
- 协变和逆变
- 上面这个例子的 playground
https://www.typescriptlang.org/play?#code/C4TwDgpgBAYg9gJwLYENgGYBMAGKBeKAbygFcEAbAZwC4ioAzRVDHMWy4BASwDsBzKAF8hAWABQoSLCZoALAA5cBYmSq1ijZHMVsoHbvyGiJ4aPC3AA7Dnx1VNOpubXsu-bwHDB4yWZnAARmxFWxUKBw1-IJ12Tg8jbzEfUygAIRRKLgBjADUuABMIOAARNBRQxOSpPMK4W3TM3IKi0uBygDIoAApxKGkLLFwAH37mBWHRtBcoEfNmaOxxAEpxVZMpAFUeLjgeABU4AEkeYAgESggs4B2eAB49gD5bXu69qAgAD1OefMooFB4ICgAH5uh9aHslvgngCgbQeBAAG5nKGfb6-KAvLrgqC8ehnKAAJSheBhgJBRKg8KRZzWvkmwAA0hAQH8CABrFlwehQLY3A7HU7nS7XXa3GpFADaACJ7NKALoPNZAA)
转载请注明本文知乎链接和译者 Hugo。