从 Union 类型中提取,其中鉴别器也是一个 Union

Extract from Union type where discriminator is also a Union

我有这样的类型:

enum Type {
  A = 'A',
  B = 'B',
  C = 'C'
}

type Union =
 | {
     type: Type.A | Type.B;
     key1: string
   }
 | {
     type: Type.C;
     key2: string
   }

type EnumToUnionMap = {
  [T in Type]: {
    [k in keyof Extract<Union, {type: T}>]: string
  }
}

我遇到的问题是 typeof EnumToUnionMap[Type.A]never(实际上,它是像 [x: string]: string 这样的通用密钥签名,但那是因为 Extract<Union, {type: T}> returns 当 TType.AType.B 时键入 never,而 typeof EnumToUnionMap[Type.C]

{
  type: Type.C,
  key2: string
}

符合预期。

这一切都是有道理的,因为 EnumToUnionMap[Type.A] 中的 typeType.A | Type.BType.A != (Type.A | Type.B),所以它们不匹配,我们得到 never

基本上我需要做这样的事情:

type EnumToUnionMap = {
  [T in Type]: {
    [k in keyof Extract<Union, T in Union['type']>]: string
  }
}

为什么我需要这样做:

我从具有以下形状的警报端点收到响应:

{
 type: Type,
 key1: string,
 key2: string
}

Type.AType.B 的警报都提供 key1Type.C 的警报提供 key2.
我需要将响应中的键映射到网格中的列名称(其中某些警报类型共享一组通用键,但列的显示名称不同):

const columnMap: EnumToUnionMap = {
  [Type.A]: {
    key1: 'Column name'
    // Note that in actuality this object will contain
    // multiple keys (i.e. multiple columns) for a
    // given `Type` so creating a map
    // between `Type -> column name` is not possible.
  },
  [Type.B]: {
    key1: 'Different column name'
  },
  [Type.C]: {
    key2: 'Another column name'
  }
}

这样,我可以执行以下操作:

const toColumnText = (alert) => columnMap[alert.type]

...

if (alert.type === Type.A) {
  const key1ColumnName = toColumnText(alert).key1 // typed as string
  const key2ColumnName = toColumnText(alert).key2 // Typescript warns of undefined key
}

TypeScript 具有使用 & 缩小类型的天然能力,所以我想提供一个不需要太多技巧的解决方案。以下是实现预期结果的完整代码。还要注意 toColumnText 函数的输入,因为这也是解决方案的一部分。

enum Type {
  A = 'A',
  B = 'B',
  C = 'C'
}

type Union =
 | {
     type: Type.A | Type.B;
     key1: string;
   }
 | {
     type: Type.C;
     key2: string
   }

type FindByType<TWhere, T extends Type> = TWhere extends { type: infer InferredT }
  ? (InferredT extends T ? (TWhere & { type: T }) : never)
  : never;

type EnumToUnionMap = {
  [T in Type]: {
    // Change `FindByType<Union, T>[k]` to `string` to force all extracted properties to be strings intead of their original type
    [k in Exclude<keyof FindByType<Union, T>, 'type'>]: FindByType<Union, T>[k];
  }
};

const columnMap: EnumToUnionMap = {
  [Type.A]: { key1: 'Column name' },
  [Type.B]: { key1: 'Different column name' },
  [Type.C]: { key2: 'Another column name' }
}

const toColumnText = <T extends Type>(alert: { type: T }): EnumToUnionMap[T] => columnMap[alert.type];

const alertObj: Union = { type: Type.A, key1: 'test' };
if (alertObj.type === Type.A) {
  const key1ColumnName = toColumnText(alertObj).key1 // typed as string
  const key2ColumnName = toColumnText(alertObj).key2 // Typescript warns of undefined key
}

正如你所注意到的,你不能在这里真正使用the Extract utility type,因为联合成员Union和你拥有的候选类型{type: T}之间的关系不是简单的可分配性。相反,您想要找到联合 Union 的成员 U 使得 T extends U["type"]。您可能不得不忘记 TypeScript 提供的实用程序类型,而是自己执行类型操作。


EnumToUnionMap 的一个可能定义是这样的:

type EnumToUnionMap = { [T in Type]: Union extends infer U ? U extends { type: any } ? (
    T extends U["type"] ? { [K in keyof U as Exclude<K, "type">]: U[K] } : never
) : never : never }

它可能看起来有点令人生畏;让我们确保它至少做你想做的。 IntelliSense 显示它的计算结果为:

/* type EnumToUnionMap = {
    A: { key1: string; };
    B: { key1: string; };
    C: { key2: string; };
} */

看起来不错。


既然我们知道它可以满足您的要求,那么它是如何做到的呢?让我们把定义分解成几块来分析每一块:

type EnumToUnionMap = { [T in Type]: Union extends infer U ? U extends { type: any } ? (
    T extends U["type"] ? { [K in keyof U as Exclude]: U[K] } : never
) : never : never }

在您的版本中,我们是 mapping over the enum values, Type.A, Type.B, and Type.C. For each such enum value T, we need to split Union into its union members, and collect the one we care about. Writing Union extends infer U ? U extends { type: any } ? ... uses conditional type inference to copy Union into a new type parameter U, which can then be used to create a distributive conditional type,其中 U 代表 Union 的各个工会成员。所以从现在开始,每当我们看到 U 时,我们只处理 Union 的一部分,而不是整个事情。

type EnumToUnionMap = { [T in Type]: Union extends infer U ? U extends { type: any } ? (
    T extends U["type"] ? { [K in keyof U as Exclude]: U[K] } : never
) : never : never }

为了确定联合 Union 到 select 的哪个联合成员 U,我们评估条件类型 T extends U["type"] ? ... : never。当且仅当 Utype 属性 是 T 的超类型时,检查 T extends U["type"] 才为真。如果 TType.A 并且 U{type: Type.A | Type.B, ...},那么这是真的。但是如果 TType.AU{type: Type.C, ...},那么这是错误的。通过 returning never 当检查为假时,我们将只处理 Union 的“正确”成员。

type EnumToUnionMap = { [T in Type]: Union extends infer U ? U extends { type: any } ? (
    T extends U["type"] ? { [K in keyof U as Exclude]: U[K] } : never
) : never : never }

所以我们找到了正确的联合成员U,但我们不想只return它,因为它仍然包含不需要的type属性.我们可以 return Omit<U, "type"> 使用 the Omit utility type, but this unfortunately results in verbose IntelliSense like Omit<{type: Type.A | Type.B; key1: string}, "type"> instead of the desirable {key1: string}. So here I'm writing my own Omit type using key remapping in mapped types(文档解释了 Omit 的替代方法是如何工作的)。


给你; EnumToUnionMap 类型遍历 Type 的每个成员,提取 Union 的成员,其 type 属性 是它的超类型,并省略 type 属性 来自该成员。

Playground link to code