Nested Data Fetching fp-ts pyramid of doom

Viewed 586

First jump into the world of functional programming in typescript with the fp-ts library.

I have here a "pyramid of doom" the Egyptians would be proud of, I'm clearly doing something wrong in my approach. What approach should I be taking to solve my nested data fetching problem in a less imperative style?

The Pyramid

export const getProgramWithAllElements = (programId: FirestoreDocumentId): TE.TaskEither<FirestoreError, Program> =>
  pipe(
    getProgram(programId),
    TE.chain((program) =>
      pipe(
        getCollCollectionsFromPath(program.id),
        TE.chain((collections) =>
          pipe(
            collections,
            A.map((collection) =>
              pipe(
                getWeekCollectionsFromPath(program.id)(collection.id),
                TE.chain((weeks) =>
                  pipe(
                    weeks,
                    A.map((week) =>
                      pipe(
                        getDayCollectionsFromPath(program.id)(collection.id)(week.id),
                        TE.chain((days) =>
                          pipe(
                            days,
                            A.map((day) =>
                              pipe(
                                getExerciseCollectionsFromPath(program.id)(collection.id)(week.id)(day.id),
                                TE.map(
                                  (exercises) =>
                                    ({
                                      ...day,
                                      exercises: exercises,
                                    } as Day)
                                )
                              )
                            ),
                            A.sequence(TE.taskEither)
                          )
                        ),
                        TE.map(
                          (days) =>
                            ({
                              ...week,
                              days: days,
                            } as Week)
                        )
                      )
                    ),
                    A.sequence(TE.taskEither)
                  )
                ),
                TE.map(
                  (weeks) =>
                    ({
                      ...collection,
                      weeks: weeks,
                    } as Collection)
                )
              )
            ),
            A.sequence(TE.taskEither)
          )
        ),
        TE.map(
          (collections) =>
            ({
              ...program,
              collections: collections,
            } as Program)
        )
      )
    )
  );

Functions used in snippet

declare const getProgram: (programId: FirestoreDocumentId) => TE.TaskEither<FirestoreError, Program>;

declare const getCollCollectionsFromPath: (
  programId: FirestoreDocumentId
) => TE.TaskEither<FirestoreError, Collection[]>;

declare const getWeekCollectionsFromPath: (
  programId: FirestoreDocumentId
) => (collectionId: FirestoreDocumentId) => TE.TaskEither<FirestoreError, Week[]>;

declare const getDayCollectionsFromPath: (programId: FirestoreDocumentId) => (collectionId: FirestoreDocumentId) => (
  weekId: FirestoreDocumentId
) => TE.TaskEither<FirestoreError, Day[]>;

declare const getExerciseCollectionsFromPath: (programId: FirestoreDocumentId) => (collectionId: FirestoreDocumentId) => (
  weekId: FirestoreDocumentId
) => (dayId: FirestoreDocumentId) => TE.TaskEither<FirestoreError, Exercise[]>;

Simplified Data Model

export interface Program {
    id: string;
    // Other Fields
    collections?: Collection[];
}

export interface Collection {
    id: string;
    // Other Fields
    weeks?: Week[];
}

export interface Week {
    id: string;
    // Other Fields
    days?: Day[];
}

export interface Day {
    id: string;
    // Other Fields
    exercises: ProgramExercise[];
}

export interface ProgramExercise {
    id: string;
    // Other Fields
}
2 Answers

I don't know FP-TS so I can only provide a general answer, but the underlying algebraic rules are the same.

First of all, monads naturally form nested structures. There is no way around it, but you can hide it provided you have the right tool (do notation in Haskell). Unfortunately, in Javascript there is no way to abstract from nesting in general, but you can use generators to maintain a flat structure for deterministic computations. The following code depends on the scriptum lib I maintain:

const record = (type, o) => (
  o[Symbol.toStringTag] = type.name || type, o);

const Cont = k => record(Cont, {cont: k});

const contOf = x => Cont(k => k(x));

const contChain = mx => fm =>
  Cont(k =>
    mx.cont(x =>
      fm(x).cont(k)));

const _do = ({chain, of}) => init => gtor => {
  const go = ({done, value: x}) =>
    done
      ? of(x)
      : chain(x) (y => go(it.next(y)));

  const it = gtor(init);
  return go(it.next());
};

const log = (...ss) =>
  (console.log(...ss), ss[ss.length - 1]);

const inck = x => Cont(k => k(x + 1));

const sqrk = x => Cont(k =>
  Promise.resolve(null)
    .then(k(x * x)));

const mainM = _do({chain: contChain, of: contOf}) (5) (function* (init) {
  const x = yield inck(init),
    y = yield sqrk(init);

  return [x, y];
});

mainM.cont(log)

However, as far as I can tell from your code you don't actually need monad, because your next computations don't depend on previous values, like in chain(tx) (x => x === 0 ? of(x) : of(2/x). Applicative functor should be just enough:

const record = (type, o) => (
  o[Symbol.toStringTag] = type.name || type, o);

const Cont = k => record(Cont, {cont: k});

const contMap = f => tx =>
  Cont(k => tx.cont(x => k(f(x))));

const contAp = tf => tx =>
  Cont(k =>
    tf.cont(f =>
      tx.cont(x =>
        k(f(x)))));

const contOf = x => Cont(k => k(x));

const liftA2 = ({map, ap}) => f => tx => ty =>
  ap(map(f) (tx)) (ty);

const contLiftA2 = liftA2({map: contMap, ap: contAp});

const log = (...ss) =>
  (console.log(...ss), ss[ss.length - 1]);

const inck = x => Cont(k => k(x + 1));

const sqrk = x => Cont(k =>
  Promise.resolve(null)
    .then(k(x * x)));

const mainA = contLiftA2(x => y => [x, y]) (inck(5)) (sqrk(5))

mainA.cont(log);

As I've already mentioned you cannot use generators together with non-deterministic computations like linked lists or arrays. However, you can resort to applying monads in an applicative manner to ease the pain:

const arrChain = mx => fm =>
  mx.flatMap(fm);

const chain2 = chain => mx => my => fm =>
  chain(chain(mx) (x => fm(x)))
    (gm => chain(my) (y => gm(y)));

const log = (...ss) =>
  (console.log(...ss), ss[ss.length - 1]);

const xs = [1,2],
  ys = [3,4];

main3 = chain2(arrChain) (xs) (ys) (x => [y => [x, y]]);

log(main3);

As you can see there is still nested structure but it still looks tidier.

I am not entirely sure if this technique works for all monads, because it performs effects twice as often as normal. So far I haven't encountered an issue yet, so I'm pretty confident.

Here's an attempt to abstract some of the repeating patterns:

import * as A from "fp-ts/Array";
import { flow, pipe } from "fp-ts/lib/function";

type FirestoreDocumentId = number;
interface Collection {
  id: number;
}
interface FirestoreError {
  id: number;
}
interface Day {
  id: number;
}
interface Week {
  id: number;
}
interface Program {
  id: number;
}
interface Exercise {
  id: number;
}

declare const getProgram: (
  programId: FirestoreDocumentId
) => TE.TaskEither<FirestoreError, Program>;

declare const getCollCollectionsFromPath: (
  programId: FirestoreDocumentId
) => TE.TaskEither<FirestoreError, Collection[]>;

declare const getWeekCollectionsFromPath: (
  programId: FirestoreDocumentId
) => (
  collectionId: FirestoreDocumentId
) => TE.TaskEither<FirestoreError, Week[]>;

declare const getDayCollectionsFromPath: (
  programId: FirestoreDocumentId
) => (
  collectionId: FirestoreDocumentId
) => (weekId: FirestoreDocumentId) => TE.TaskEither<FirestoreError, Day[]>;

declare const getExerciseCollectionsFromPath: (
  programId: FirestoreDocumentId
) => (
  collectionId: FirestoreDocumentId
) => (
  weekId: FirestoreDocumentId
) => (dayId: FirestoreDocumentId) => TE.TaskEither<FirestoreError, Exercise[]>;

const mapTo = <K extends string>(prop: K) => <T>(obj: T) => <E, A>(
  task: TE.TaskEither<E, A>
) =>
  pipe(
    task,
    TE.map(
      (data): T & { [P in K]: A } =>
        Object.assign({}, obj, { [prop]: data }) as any
    )
  );

const chainSequence = <E, A, B>(f: (t: A) => TE.TaskEither<E, B>) =>
  TE.chain(flow(A.map(f), A.sequence(TE.taskEither)));

const chainSequenceAndMapTo = <K extends string>(prop: K) => <E, B>(
  f: (id: number) => TE.TaskEither<E, B[]>
) => <A extends { id: number }>(task: TE.TaskEither<E, A[]>) =>
  pipe(
    task,
    chainSequence((a) => pipe(f(a.id), mapTo(prop)(a)))
  );

export const getProgramWithAllElements = (programId: FirestoreDocumentId) =>
  pipe(
    getProgram(programId),
    TE.chain((program) =>
      pipe(
        getCollCollectionsFromPath(program.id),
        chainSequenceAndMapTo("collections")((collectionId) =>
          pipe( 
            getWeekCollectionsFromPath(program.id)(collectionId),
            chainSequenceAndMapTo("weeks")((weekId) =>
              pipe(
                getDayCollectionsFromPath(program.id)(collectionId)(weekId),
                chainSequenceAndMapTo("exercises")(
                  getExerciseCollectionsFromPath(program.id)(collectionId)(
                    weekId
                  )
                )
              )
            )
          )
        )
      )
    )
  );
Related