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,这些工作教给了我成吨 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 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
    

    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。

你可能感兴趣的:(TypeScript- Union to intersection type[TypeScript 高级类型编程初级教程][全文翻译])