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
}
