Search code examples
typescriptfp-ts

How can I avoid the pyramid of doom with chain in fp-ts?


I frequently come up against this situation, where I need to complete several sequential operations. If each operation exclusively used data from the previous step, then I could happily do something like pipe(startingData, TE.chain(op1), TE.chain(op2), TE.chain(op3), ...). I can't find a nice way to write this when op2 also needs data from startingData, without a bunch of nested callbacks.

How can I avoid the pyramid of doom in the example below?

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


Solution

  • There is a solution to the problem, and it's called "do notation". It's been available in fp-ts-contrib for a while, but it now also has a version baked into fp-ts itself using the bind function (which is defined on all monadic types). The basic idea is similar to what I was doing below - we bind computation results to a particular name, and track those names inside a "context" object as we go along. Here's the code:

    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
    )
    

    Original Answer below

    I've come up with a solution that I'm not very proud of, but I'm sharing it for possible feedback!

    Firstly, define some helper functions:

    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 allows me to apply a function to all values in an object (subquestion 1: is there a builtin function that would allow me to do this?). TEofStruct uses this function to turn a struct of values into a struct of TaskEithers for those values.

    My basic idea is to accumulate the new value along with the previous values using TEofStruct and sequenceS. So far it looks like this:

    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),
        })
      )
    );
    

    It feels like I could write some kind of helper function that combines sequenceTE with TEofStruct to reduce the boilerplate here, but I'm still not sure overall if this is the right approach, or if there is a more idiomatic pattern!