TypeScript- Union to intersection type[TypeScript 高级类型编程初级教程][全文翻译]

首发知乎: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<T>,这些工作教给了我成吨 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> = 
      (T extends any ? (x: T) => any : never) extends 
      (x: infer R) => any ? R : never
    

    还在看么?很好,这里有太多要从这里一步步推导,我们慢慢来。这个式子是一个条件类型嵌入在另一个条件类型里,我们用 infer 关键字和还有这些式子看起来什么都没做。但是其实它们做了很多事,这是 TypeScript 的一些特性。首先,naked 类型。

    裸类型(the naked type)

    如果你仔细看 UnionToIntersection<T>的第一个条件,你可以看到我们用一个泛型参数作为一个裸类型。

    type UnionToIntersection<T> = 
      (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> = 
      T extends ... // 裸类型
    
    type NotNaked<T> = 
      { o: T } extends ... // 非裸类型
    

    裸类型在条件类型中有特性。如果 T 是一个联合类型,编译器会触发条件类型对于联合类型的每一个组成类型做运算。所以对于一个裸类型,联合类型的条件变成了条件类型的联合类型。举例来说:

    type WrapNaked<T> = 
      T extends any ? { o: T } : never
    
    type Foo = WrapNaked<string | number | boolean>
    
    // 因为这是一个裸类型,所以上式等价于下式
    
    type Foo = 
      WrapNaked<string> | 
      WrapNaked<number> | 
      WrapNaked<boolean>
    
    // 等价于
    
    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>
    
    // 因为这是一个非裸类型,所以这个等价于
    // 即无法展开
    
    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> = 
      (T extends any ? (x: T) => any : never) //...
    // 相当于把 T 装在了后面这个函数参数的位置上,可以理解为装箱。
    

    因为这个类型总是真,所以我们把我们的一个泛型类型包装在一个函数里,这里 T 是函数的参数,但是为啥我们要这样做呢?

    逆变类型位置(Contra-variant type positions)

    然后我们来看第二个条件:

    type UnionToIntersection<T> = 
      (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<X> = (...args: X[]) => void
    
    declare let f: Fun<string>
    declare let g: Fun<string | number>
    
    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> = 
      (T extends any ? (x: T) => any : never) extends 
      (x: infer R) => any ? R : never
    
    type Intersected = UnionToIntersection<Video["urls"]>
    
    // 等价于
    
    type Intersected = UnionToIntersection<
      { format320p: string } |
      { format480p: string } |
      { format720p: string } |
      { format1080p: string } 
    >
    
    // 我们有了一个裸类型, 这意味着
    // 我们可以做并集的交集操作:
    
    type Intersected = 
      UnionToIntersection<{ format320p: string }> |
      UnionToIntersection<{ format480p: string }> |
      UnionToIntersection<{ format720p: string }> |
      UnionToIntersection<{ format1080p: string }> 
    
    // 展开...
    
    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 }
    
    // 但是等等! `R` 从一个逆变位置 inferred
    //我做了一个交集, 否则我丢失了类型兼容性
    
    type Intersected = 
      { format320p: string } & 
      { format480p: string } & 
      { format720p: string } & 
      { format1080p: string }
    

    这就是我们寻找的解决方案!所以来看我们一开始的例子:

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

    FormatKeys现在就变成了"format320p" | "format480p" | "format720p" | "format1080p"。当我们在原来的并集中增加其他的格式, FormatKeys会自动更新这个格式。维护一份,到处使用。

    更多阅读

    我通过研究在 TypeScript 中逆变问题解决了这个问题。通过这个类型系统术语,我们可以有效的通过函数参数来获得一个泛型并集的所有成分。

    如果你想更深的研究这个话题,我建议阅读以下文章:

  • https://www.typescriptlang.org/play?#code/C4TwDgpgBAYg9gJwLYENgGYBMAGKBeKAbygFcEAbAZwC4ioAzRVDHMWy4BASwDsBzKAF8hAWABQoSLCZoALAA5cBYmSq1ijZHMVsoHbvyGiJ4aPC3AA7Dnx1VNOpubXsu-bwHDB4yWZnAARmxFWxUKBw1-IJ12Tg8jbzEfUygAIRRKLgBjADUuABMIOAARNBRQxOSpPMK4W3TM3IKi0uBygDIoAApxKGkLLFwAH37mBWHRtBcoEfNmaOxxAEpxVZMpAFUeLjgeABU4AEkeYAgESggs4B2eAB49gD5bXu69qAgAD1OefMooFB4ICgAH5uh9aHslvgngCgbQeBAAG5nKGfb6-KAvLrgqC8ehnKAAJSheBhgJBRKg8KRZzWvkmwAA0hAQH8CABrFlwehQLY3A7HU7nS7XXa3GpFADaACJ7NKALoPNZAA)

转载请注明本文知乎链接和译者 Hugo。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容