typescript - Ekstraher fra unionstype der diskriminator også er en union


Jeg har en type som dette:

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

Problemet jeg har er at typeof EnumToUnionMap[Type.A] er never (i virkeligheten er det en generell nøkkelsignatur som [x: string]: string, men det er fordi Extract<Union, {type: T}> returnerer typen never når T er Type.A eller Type.B), mens typeof EnumToUnionMap[Type.C] er

{
  type: Type.C,
  key2: string
}

som forventet.

Dette gir mening fordi type i EnumToUnionMap[Type.A] er Type.A | Type.B og Type.A != (Type.A | Type.B), så de stemmer ikke overens og vi får never.

I hovedsak trenger jeg å gjøre noe som dette:

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

Hvorfor jeg trenger å gjøre dette:

Jeg mottar et svar fra et varslingsendepunkt som har denne formen:

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

Både varsler av Type.A og Type.B gir key1, mens de av Type.C gir key2.
Jeg trenger å mappe nøklene i responsen til kolonnenavn i et rutenett (der noen varseltyper deler et felles sett med nøkler, men har forskjellige visningsnavn for kolonnene):

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

På denne måten kan jeg gjøre noe som følgende:

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
}

Svar #1

Som du bemerker, kan du egentlig ikke bruke Extract-verktøytypen her, fordi forholdet mellom medlemmene i unionen Union og kandidattypene {type: T} som du har, ikke er enkel tildelbarhet. I stedet vil du finne medlemmet U av unionen Union slik at T extends U["type"]. Du må sannsynligvis glemme verktøytypene som TypeScript tilbyr, og i stedet utføre typemanipulasjonen selv.


En mulig definisjon for EnumToUnionMap er denne:

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 }

Det kan se litt skremmende ut; la oss sørge for at det i det minste gjør det du ønsker. IntelliSense viser at det evalueres til:

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

Ser bra ut.


Nå som vi vet at det gjør det du vil, hvordan gjør det det? La oss bryte definisjonen i biter og analysere hver bit:

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 }

Som i din versjon, mapper vi over enum-verdiene, Type.A, Type.B, og Type.C. For hver slik enum-verdi T må vi dele Union inn i dens union-medlemmer, og samle det vi er interessert i. Å skrive Union extends infer U ? U extends { type: any } ? ... bruker betinget typeinferenz for å kopiere Union til en ny typeparameter U, som deretter kan brukes til å lage en distribusjonsbetinget type der U representerer individuelle union-medlemmer av Union. Så herfra og ut, når vi ser U, jobber vi bare med deler av Union, ikke hele.

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 }

For å avgjøre hvilket union-medlem U av unionen Union som skal velges, evaluerer vi den betingede typen T extends U["type"] ? ... : never. Sjekken T extends U["type"] er sann hvis og bare hvis U's type-egenskap er en supertype av T. Hvis T er Type.A og U er {type: Type.A | Type.B, ...}, så er dette sant. Men hvis T er Type.A og U er {type: Type.C, ...}, så er dette usant. Ved å returnere never når sjekken er usann, vil vi bare behandle det "rette" medlemmet av 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 }

Så vi har funnet riktig union-medlem U, men vi vil ikke bare returnere det, fordi det fortsatt inneholder den uønskede type-egenskapen. Vi kunne returnere Omit<U, "type"> ved å bruke Omit-verktøystypen, men dette resulterer dessverre i omstendelig IntelliSense som Omit<{type: Type.A | Type.B; key1: string}, "type"> i stedet for det ønskelige {key1: string}. Så her skriver jeg min egen Omit-type ved å bruke omkartlegging av nøkler i mappede typer (dokumentasjonen forklarer hvordan dette alternativet til Omit fungerer).


Der har du det; EnumToUnionMap-typen går gjennom hvert medlem av Type, henter medlemmet av Union hvis type-egenskap er en supertype av det, og utelater type-egenskapen fra det medlemmet.

Lenke til kode på lekeplass

Svar #2

TypeScript har den naturlige evnen til å avgrense typer ved å bruke &, så jeg ønsket å prøve en løsning som ikke trenger for mye trikseri. Nedenfor er hele koden for å oppnå det forventede resultatet. Merk også typingen av toColumnText-funksjonen, siden det også er en del av løsningen.

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
}

Relaterte artikler