typescript - Hvordan kan jeg unngå pyramiden av doom med chain i fp-ts?


Jeg støter ofte på denne situasjonen, hvor jeg må fullføre flere sekvensielle operasjoner. Hvis hver operasjon utelukkende brukte data fra forrige steg, kunne jeg gladelig gjort noe som pipe(startingData, TE.chain(op1), TE.chain(op2), TE.chain(op3), ...). Jeg kan ikke finne en fin måte å skrive dette på når op2 også trenger data fra startingData, uten en masse nestede callbacks.

Hvordan kan jeg unngå pyramiden av død i eksemplet nedenfor?

declare const op1: (x: {one: string}) => TE.TaskEither<Error, string>;
declare const op2: (x: {one: string, two: string}) => TE.TaskEither<Error, string>;
declare const op3: (x: {one: string, two: string, three: string}) => TE.TaskEither<Error, string>;

pipe(
  TE.of<Error, string>('one'),
  TE.chain((one) =>
    pipe(
      op1({ one }),
      TE.chain((two) =>
        pipe(
          op2({ one, two }),
          TE.chain((three) => op3({ one, two, three }))
        )
      )
    )
  )
);

Svar #1

Det finnes en løsning på problemet, og den kalles "do-notasjon". Den har vært tilgjengelig i fp-ts-contrib en stund, men den har nå også en versjon som er integrert i fp-ts selv ved bruk av bind-funksjonen (som er definert på alle monadiske typer). Den grunnleggende ideen er lik det jeg gjorde nedenfor - vi binder resultatene av beregninger til et bestemt navn, og sporer disse navnene inne i et "context"-objekt underveis. Her er koden:

pipe(
  TE.of<Error, string>('one'),
  TE.bindTo('one'), // start with a simple struct {one: 'one'}
  TE.bind('two', op1), // the payload now contains `one`
  TE.bind('three', op2), // the payload now contains `one` and `two`
  TE.bind('four', op3), // the payload now contains `one` and `two` and `three`
  TE.map(x => x.four)  // we can discharge the payload at any time
)

Originalt svar nedenfor

Jeg har kommet opp med en løsning som jeg ikke er veldig stolt av, men jeg deler den for mulig tilbakemelding!

Først, definer noen hjelpefunksjoner:

function mapS<I, O>(f: (i: I) => O) {
  return <R extends { [k: string]: I }>(vals: R) =>
    Object.fromEntries(Object.entries(vals).map(([k, v]) => [k, f(v)])) as {
      [k in keyof R]: O;
    };
}
const TEofStruct = <R extends { [k: string]: any }>(x: R) =>
  mapS(TE.of)(x) as { [K in keyof R]: TE.TaskEither<unknown, R[K]> };

mapS lar meg bruke en funksjon på alle verdier i et objekt (underdelspørsmål 1: finnes det en innebygd funksjon som lar meg gjøre dette?). TEofStruct bruker denne funksjonen for å gjøre en struktur av verdier om til en struktur av TaskEither-er for disse verdiene.

Min grunnleggende idé er å akkumulere den nye verdien sammen med de forrige verdiene ved å bruke TEofStruct og sequenceS. Så langt ser det slik ut:

pipe(
  TE.of({
    one: 'one',
  }),
  TE.chain((x) =>
    sequenceTE({
      two: op1(x),
      ...TEofStruct(x),
    })
  ),
  TE.chain((x) =>
    sequenceTE({
      three: op2(x),
      ...TEofStruct(x),
    })
  ),
  TE.chain((x) =>
    sequenceTE({
      four: op3(x),
      ...TEofStruct(x),
    })
  )
);

Det føles som om jeg kunne skrive en slags hjelpefunksjon som kombinerer sequenceTE med TEofStruct for å redusere oppsettingsarbeidet her, men jeg er fortsatt ikke helt sikker på om dette er den rette tilnærmingen, eller om det finnes et mer idiomatisk mønster!

Svar #2

Den offisielle fp-ts DO-notasjonen er her: https://gcanti.github.io/fp-ts/guides/do-notation.html

Men det er ingenting galt med å trekke ut nestet logikk til sin egen funksjon.

Relaterte artikler