2

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
}
Robert Field
  • 321
  • 5
  • 16
  • If you want a more functional programming style, don't use `pipe` like that. – Bergi Dec 22 '20 at 15:21
  • `getExerciseCollectionsFromPath(program.id)(collection.id)(week.id)(day.id)` - something is wrong with that API. Why not `day.getExercises()` (or `getExercises(day)`)? – Bergi Dec 22 '20 at 15:23
  • Thanks for the response @Bergi I understand the use of pipe is wrong and must be based of some miss understanding or limited knowledge of the utility, what would be the alternative in my situation? `getExerciseCollectionsFromPath(program.id)(collection.id)(week.id)(day.id)` is a function that calls the Firebase Firestore NoSQL database. I don't know your experience with Firestore but the sub collection that the exercises are stored in has no knowledge of it's parent day collection. So you need to specify the entire path to access it e.g. (program.id)(collection.id)(week.id)(day.id) – Robert Field Dec 22 '20 at 16:17
  • Regarding `pipe`, instead of `pipe(val, g, f)` just write `f(g(val))`. Or instead of `val => pipe(val, g, f)` just write `compose(f, g)`. Seems like that would already save some indentation levels of your pyramid. – Bergi Dec 22 '20 at 16:34
  • About the collection access, if you included the week in the day interface, the collection in the week etc, you'd need to only pass the day object that contains all the necessary identifiers. – Bergi Dec 22 '20 at 16:36
  • `compose` has been dropped in favour of `flow` in `fp-ts`. I had a look at using `Do` provided by `fp-ts-contrib` but in your case it doesn't really help since you are flip-flopping between tasks and lists and you also need the result of the entire chain at the end. – chautelly Dec 23 '20 at 17:53
  • I think you best bet is defining some of the functions outside of the chain for better readability – chautelly Dec 23 '20 at 17:55

2 Answers2

1

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.

  • Beautiful code demos here. I love `record`. I'm using `const struct = (T, t) => Object.assign(t, { [Symbol.toStringTag]: T.name ?? T })` – Mulan Dec 23 '20 at 17:21
  • @Thankyou Uh, thank you as well. I am currently working on affine arrays/maps with safe in-place updates. Seems as if mutations are not that harmful after all. You only need to contain the sharing, so I was told by the Rust language. –  Dec 23 '20 at 18:13
0

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
                  )
                )
              )
            )
          )
        )
      )
    )
  );
chautelly
  • 447
  • 3
  • 14