Hvordan kan en TypeScript forEach utlede typene til en union, og endre returtypen


Vi ønsker å ta en dokumenttype og endre noen av feltene dens og deretter returnere det nye dokumentet med de nye typene. Vi ønsker å bestemme hvilke felter som skal endres basert på en rekke verdier (collectionDataFields i eksemplet nedenfor).

type AllDocuments = {
    __typename: 'cat'
    a: '123',
    b: '123',
    z: 'xyz'
} | {
    __typename: 'dog'
    a: '123',
    c: '123',
    z: 'xyz'
}

const collectionDateFields = {
    cat: ['a', 'b'],
    dog: ['a', 'c']
} as const

export const useStringsToNums = (document: AllDocuments) => {
  const dateFields = collectionDateFields[document.__typename]

  dateFields.forEach((field) => {
    document[field] = parseInt(document[field])
  })
  return document
}

I eksemplet er dateFields typet som readonly ["a", "b"] | readonly ["a", "c"], men det individuelle field kommer inn som any.

document returneres som AllDocuments selv om vi ønsker at det skal ha feltet med den endrede typen (som et nummer).

TS-lekeplass Eksempel

Rediger: For et eksempel på bruken, ville jeg gjort noe sånt som dette:

useStringsToNums({
    __typename: 'cat'
    a: '123',
    b: '123',
    z: 'xyz'
})

I dette tilfellet gjør funksjonen om egenskapene som matcher __typename: 'cat' til tall (med parseInt).

Svar #1

Vi ønsker å ta en dokumenttype og endre noen av feltene dens

Hvis du har et objekt av en type hvor det finnes en string-verdi egenskap på en viss nøkkel, kan du ikke tilordne et number til den samme egenskapen uten at kompilatoren klager. En av motivasjonene bak TypeScript er å legge til et sterkt statisk typesystem til JavaScript, og å tilordne et number til noe som skal være en string går imot dette:

function doSomething(doc: AllDocuments) {
  doc.a = 123; // error! Type '123' is not assignable to type '"123"'    
}

Du kan bare bruke typepåstander for å undertrykke disse feilene:

function doSomething(doc: AllDocuments) {
  doc.a = 123; // error! Type '123' is not assignable to type '"123"'    
}

Men alt dette gjør er å gjøre ting verre på lang sikt. TypeScript modellerer ikke vilkårlige typeendringer, bare innsnevring. Det finnes ingen måte å si at etter å ha kalt doSomething(doc), har typen til doc endret seg slik at en tidligere string-egenskap nå er en number. Ved å anta at en number er en string, lyver du for kompilatoren, og du kan støte på feil under kjøring:

const doc: AllDocuments = { __typename: "cat", a: "123", b: "123" };
doSomething(doc);
try {
  doc.a.toUpperCase(); // compiles but has runtime error:
} catch (e) {
  console.log(e); // doc.a.toUpperCase is not a function    
}

Forutsatt at du ikke ønsker å utvide de egenskapstypene fra "123" til noe som kan håndtere både "123" og 123, er det sannsynligvis en bedre idé å bare opprette et nytt objekt av typen du ser etter, og behandle det opprinnelige objektet som uforanderlig:

function doSomethingElse(doc: AllDocuments) {
  return { ...doc, a: 123 }
}
const newDoc = doSomethingElse(doc);
/* const newDoc: {
  a: number;
  __typename: 'cat';
  b: '123';
 } | {
  a: number;
  __typename: 'dog';
  c: '123';
 } */
console.log(newDoc.a.toFixed(2)); // "123.00"

For resten av dette svaret vil jeg bruke denne tilnærmingen.



Nå til resten av spørsmålet ditt. Du støter på et problem med det jeg har kalt korrelerte unionstyper som diskutert i microsoft/TypeScript#30581.

I følgende kode:

  const dateFields = collectionDateFields[document.__typename]
  dateFields.forEach((field: "a" | "b" | "c") => {
    document[field] // oops
  })

selv om vi annoterer field med typen vi forventer at den skal være, er kompilatoren ikke i stand til å se at document har en nøkkel som heter field. Typen til document er en forening av to forskjellige typer, og typen til dateField er også en forening av to forskjellige typer, og typen til field er også en forening. Men kompilatoren ser disse tre foreningene som uavhengige eller ukorrelerte. Så langt den forstår, kan document.__typename være "cat" og likevel kunne field være "c". Du vet at dette er umulig, men kompilatoren mister tråden.


Noen ganger er det mulig å refaktorere kode for å unngå det korrelerte foreningsproblemet. Dessverre, til tross for å ha brukt litt tid på å prøve å få noe noe sånt som typesikkert fra kompilatoren, er det nærmeste jeg kunne komme ikke nyttig nok til å være verdt kompleksiteten:

const useDatesToLuxon = <T extends AllDocuments['__typename']>(
  document: Extract<AllDocuments, { __typename: T }>
) => {
  type Fields = (typeof collectionDateFields)[T][number];
  const dateFields: readonly Fields[] = collectionDateFields[document.__typename]
  const doc = document as Record<Fields, string>;
  const newDoc = {} as Record<Fields, number>;
  dateFields.forEach((field) => {
    newDoc[field] = parseInt(doc[field])
  })
  const ret = { ...(document as Omit<typeof document, Fields>), ...newDoc };
  type Ret = typeof ret;
  return ret as { [K in keyof Ret]: Ret[K] }
}

Det er mye typejonglering, og til slutt fungerer det bare for argumenter som man vet er et enkelt medlem av AllDocuments-unionen:

const r = useDatesToLuxon({ __typename: "cat", a: "123", b: "123" });
/* const r: {
    __typename: 'cat';
    a: number;
    b: number;
} */
console.log(r); // as expected

Hvis du prøver å kalle det på et objekt hvis type bare er kjent som hele AllDocuments-unionen, får du feil utdata type:

function butWait(doc: AllDocuments) {
  const u = useDatesToLuxon(doc);
  /* const u: {
    __typename: "cat" | "dog";
    a: number;
    b: number;
    c: number;
    } */
  // UGH, no, that's not the type we want; we cannot programmatically *distribute* the 
  // behavior of useDatesToLuxon across the union members of AllDocuments
}

Så la oss gi opp på slik refaktorering.


Det finnes ikke en god løsning på korrelerte unioner. En måte du kan omgå problemet på er ved å bruke redundante koder, ved å dele kodeblokken din inn i flere identiske deler der kompilatoren kan bruke kontrollflytanalyse for å gå gjennom de forskjellige tilfellene:

export const useDatesToLuxon = (document: AllDocuments) => {
  switch (document.__typename) {
    case "cat": {
      const dateFields = collectionDateFields[document.__typename]
      const temp = {} as Record<typeof dateFields[number], number>
      dateFields.forEach((field) => {
        temp[field] = parseInt(document[field])
      })
      return { ...document, ...temp }
    }
    case "dog": {
      const dateFields = collectionDateFields[document.__typename]
      const temp = {} as Record<typeof dateFields[number], number>
      dateFields.forEach((field) => {
        temp[field] = parseInt(document[field])
      })
      return { ...document, ...temp }
    }
  }
}

Dette fungerer veldig bra, og er ganske typesikkert. I "cat"-tilfellet og i "dog"-tilfellet vet kompilatoren nøyaktig hva som skjer. Men slik redundans skalerer ikke. Det hadde vært fint om du kunne be kompilatoren om å late som du hadde skrevet slik redundant kode, som jeg ba om i microsoft/TypeScript#25051, men det kan du ikke.


Min generelle anbefaling for denne situasjonen er å bruke typepåstander for å løsne på typekontrollen inne i implementeringen av funksjonen din. Dette flytter byrden med å opprettholde typesikkerhet fra kompilatoren (som ikke kan gjøre det ordentlig her) over på deg, så du må være forsiktig.

Her er en mulig måte å gjøre det på. Først, la oss beregne typen vi forventer å få ut av funksjonen din:

type CollectionDateFields = typeof collectionDateFields;

type ConvertedDocuments = { 
  [D in AllDocuments as D['__typename']]: { 
    [K in keyof D]: K extends 
      CollectionDateFields[D['__typename']][number] ? number : D[K] 
} }[keyof CollectionDateFields]

Det bruker nøkkelomkartlegging for å iterere over hvert medlem av AllDocuments og konvertere de relevante egenskapene nevnt i collectionDateFields til number. Du kan verifisere at det er korrekt:

/* type ConvertedDocuments = {
__typename: "cat";
a: number;
b: number;
} | {
__typename: "dog";
a: number;
c: number;
} */

Nå kan vi gi useDatesToLuxon en enkel overbelastningskall-signatur som beskriver hva den gjør:

function useDatesToLuxon<D extends AllDocuments>(
  document: D
): Extract<ConvertedDocuments, Pick<D, '__typename'>>

Det er en generell funksjon som kan gjøre hele AllDocuments-unionen om til hele ConvertedDocuments-unionen, samt gjøre enhver subtype om til den tilsvarende outputtypen. Nå for implementeringen:

function useDatesToLuxon(_document: AllDocuments): ConvertedDocuments {
  // let's just pretend it's one of the members of the union
  const document = _document as Extract<AllDocuments, { __typename: "cat" }>
  const dateFields = collectionDateFields[document.__typename]
  const temp = {} as Record<typeof dateFields[number], number>
  dateFields.forEach((field) => {
    temp[field] = parseInt(document[field])
  })
  return { ...document, ...temp }
}

Her er den enkleste påstanden for meg å lage å bare late som at document er et av medlemmene i unionen. Det er ikke teknisk sant, men det undertrykker klagene fra kompilatoren. Vi må bare være forsiktige med at vi har implementert det riktig.

Og nå for å teste det:

const r = useDatesToLuxon({ __typename: "cat", a: "123", b: "123" });
/* const r: {
    __typename: 'cat';
    a: number;
    b: number;
} */
console.log(r); // as expected

function andThen(doc: AllDocuments) {
  const u = useDatesToLuxon(doc);
  /* {
    __typename: "cat";
    a: number;
    b: number;
  } | {
    __typename: "dog";
    a: number;
    c: number;
  } */

}

Ser bra ut!

Lenke til kode på lekeplassen

Relaterte artikler