Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Extract from Union type where discriminator is also a Union

Tags:

I have a type like this:

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
  }
}

The issue I'm having is that typeof EnumToUnionMap[Type.A] is never (in reality, it's a generic key signature like [x: string]: string but that's because Extract<Union, {type: T}> returns type never when T is Type.A or Type.B) while typeof EnumToUnionMap[Type.C] is

{
  type: Type.C,
  key2: string
}

as expected.

This all makes sense because the type in EnumToUnionMap[Type.A] is Type.A | Type.B and Type.A != (Type.A | Type.B) so they don't match and we get never.

Essentially what I need to do something like this:

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

Why I need to do this:

I receive a response from an alerts endpoint that has this shape:

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

Both alerts of Type.A and Type.B provide key1 while those of Type.C provide key2.
I need to map the keys in the response to column names in a grid (where some alert types share a common set of keys but have different display names for the columns):

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'
  }
}

This way, I can do something like the following:

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
}
like image 983
Justin Taddei Avatar asked May 27 '21 17:05

Justin Taddei


2 Answers

As you note, you can't really use the Extract utility type here, because the relationship between the members of the union Union and the candidate types {type: T} that you have is not simple assignability. Instead, you want to find the member U of the union Union such that T extends U["type"]. You'll probably have to forget about the utility types that TypeScript provides and instead perform the type manipulation yourself.


One possible definition for EnumToUnionMap is this:

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 }

It might look a bit daunting; let's make sure it at least does what you want. IntelliSense shows that it evaluates to:

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

Looks good.


Now that we know it does what you want, how does it do it? Let's break the definition into pieces and analyze each piece:

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 }

As in your version, we are 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 where U represents individual union members of Union. So from here on, whenever we see U, we are dealing with just pieces of Union, not the whole thing.

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 }

To determine which union member U of the union Union to select, we evaluate the conditional type T extends U["type"] ? ... : never. The check T extends U["type"] is true if and only if U's type property is a supertype of T. If T is Type.A and U is {type: Type.A | Type.B, ...}, then this is true. But if T is Type.A and U is {type: Type.C, ...}, then this is false. By returning never when the check is false, we are only going to process the "right" member of 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 }

So we've found the right union member U, but we don't want to just return it, because it still contains the undesirable type property. We could return Omit<U, "type"> using 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 (the documentation explains how this alternative to Omit works).


There you go; the EnumToUnionMap type walks through each member of Type, extracts the member of Union whose type property is a supertype of it, and omits the type property from that member.

Playground link to code

like image 158
jcalz Avatar answered Oct 11 '22 22:10

jcalz


TypeScript has the natural ability to narrow types using & so I wanted to give a solution a shot that doesn't need too much trickery. Below is the full code to achieve your expected result. Note the typing of the toColumnText function as well, as that's also part of the solution.

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
}
like image 43
SeinopSys Avatar answered Oct 11 '22 21:10

SeinopSys