Lloyd Richards Design
FP-TS
IO-TS

Oct 14, 2023

fp-ts: Under Utilized Functions (Part 5)

Exploring fp-ts, and some of the less used functions. In this lab we will look at some of the other functions in fp-ts that I don't use as often.

Under Utilized Functions

In this lab I'm going to explore some of the other functions in fp-ts that I don't use as often. Some of these are just concepts that I don't understamd (yet) and others are just functions that I've never had a need for.

Apply

const double = (n: number): number => n * 2;
const increment = (n: number): number => n + 1;
 
const sequenceOptionStruct = Ap.sequenceS(O.Applicative);
const sequenceOptionTuple = Ap.sequenceT(O.Applicative);
 
const structResult = pipe(
  sequenceOptionStruct({
    a: O.some(1),
    b: O.some(2),
    c: O.some(3),
  }),
  O.map(({ a, b, c }) => a + b + c),
  O.map(increment),
);
 
const tupleResult = pipe(
  sequenceOptionTuple(O.some(1), O.some(2), O.some(3)),
  O.map(A.map(double)),
);
 
console.log(structResult); // Output: Some(7)
console.log(tupleResult); // Output: Some([2, 4, 6])

Apply is a type class that extends Functor. It is used to apply a function contained in a context to a value contained in a context. The sequenceS and sequenceT functions are used to apply a function to a struct or tuple of values.

This seems very useful when needing to do validation on a struct or tuple of values. Especially when applying multiple validations to a struct or tuple of values.

Do

const doResult = pipe(
  O.Do,
  O.bind("a", () => O.some(1)),
  O.bind("b", ({ a }) => O.some(increment(a))),
  O.bind("c", ({ b }) => O.some(increment(b))),
  O.map(({ a, b, c }) => a + b + c),
  O.map(increment),
);
 
console.log(doResult); // Output: Some(7)

The Do function is used to chain computations in a Monad. In combination with the bind function, it can be used create computed objects which are chained together. The resulting right value is the final computation, can be used with map to apply a function to the final value.

I think this is a very useful function, and I'm going to start using it more often. Especially when used as TaskEither or IO to chain together computations that rely on multiple successful steps.

Reader

interface Config {
  port: number;
  host: string;
}
 
const getConfig = R.ask<Config>();
 
// const getPort = (config: Config): number => config.port;
const getPort = pipe(
  getConfig,
  R.map((config) => config.port),
);
 
const readerResult = pipe(
  O.some({ port: 3000, host: "localhost" }),
  O.map(getPort),
);
 
console.log(readerResult); // Output: Some(3000)

The Reader type is used to maniplulate the object passed to a function. It is used create a function that takes a config object and returns a value. The ask function is used to get the config object from the context.

I have a little harder time finding good examples of when this would be useful. I think it would be useful when you have a function that needs to access a configuration object, but you don't want to pass the configuration object to the function every time it is called.

Covariant

const someNumber = O.some(1);
 
const someNumberResult = pipe(someNumber, O.map(double));
 
console.log(someNumberResult); // Output: Some(2)
 
const maybeNumber = E.right<string, number>(1);
 
const maybeNumberResult = pipe(
  maybeNumber,
  E.bimap(
    (error) => `Not a number ${error}`, // on left side; add error message
    double, // on right side; double the number
  ),
);
 
console.log(maybeNumberResult); // Output: Right(2)

The Covariant type class is the group of types that support the map function. Similar to the map funtions that iterates over the right value of an Either, or the some value of an Option. The bimap function is used to apply a function to the left or right value of an Either.

This can be useful when consolidating functions that might return an Either or an Option. Such as when editing error messages, or when you want to apply a function to the right value of an Either.

Contravariant

type User = {
  id: number;
  name: string;
};
 
const sortById = pipe(
  N.Ord, // Ord<number>
  Ord.contramap((user: User) => user.id), // Ord<User>
);
 
const sortResult = pipe(
  [
    { id: 2, name: "John" },
    { id: 1, name: "Jane" },
  ],
  A.sort(sortById),
  A.map((user) => user.name),
);
 
console.log(sortResult); // Output: [ 'Jane', 'John' ]

The Contravariant type class is the group of types that support the contramap function. This function is used to apply a function to the input of a function.

This can be useful when you want to sort a list of objects by a property of the object. The contramap function can be used to apply a function to the input of the Ord function.

Profunctor

type UserProfile = User & {
  email: string;
  verified: boolean;
};
 
const someUser: UserProfile = {
  id: 1,
  name: "John",
  email: "john@gmail.com",
  verified: false,
};
 
const isEligible = pipe(
  (user: UserProfile) => user.email, // map the input (B -> C)
  R.promap(
    (user: UserProfile) => ({ ...user, email: user.email.toLowerCase() }), // pre-process the input (A -> B)
    (email: string) => /@gmail.com$/.test(email), // post-process the output (C -> D)
  ),
);
 
const isEligibleResult = pipe(someUser, isEligible);
 
console.log(isEligibleResult); // Output: true
 
const isVerified = pipe(
  O.fromPredicate((user: UserProfile) => user.verified),
  R.promap(
    (user: UserProfile) => ({ ...user, verified: user.verified === true }),
    E.fromOption(() => "User is not verified"),
  ),
);
 
const isVerifiedResult = isVerified(someUser);
 
console.log(isVerifiedResult); // Output: Left("User is not verified")

The Profunctor type class is the group of types that support the promap function. This function is used to apply a function to the input and output of a function.

This was a little harder for me to wrap my head around. I understand that it can be used to apply a function to the input and output of a function, but I'm not sure when I would use this.

Moniod

type Shape = "Circle" | "Box" | "Pyramid";
type SomeObj = {
  id: number;
  count: number;
  group: Shape;
};
const someObj: SomeObj[] = [
  {
    id: 1,
    count: 2,
    group: "Circle",
  },
  {
    id: 2,
    count: 4,
    group: "Circle",
  },
  {
    id: 3,
    count: 3,
    group: "Box",
  },
  {
    id: 4,
    count: 2,
    group: "Pyramid",
  },
];
 
const byGroup = pipe(
  S.Ord,
  Ord.contramap((obj: SomeObj) => obj.group),
);
 
const byCount = pipe(
  N.Ord,
  Ord.contramap((obj: SomeObj) => obj.count),
);
 
const SomeM = Ord.getMonoid<SomeObj>();
const byBothOrds = M.concatAll(SomeM)([byGroup, byCount]);
 
const sortSomeObj = pipe(
  someObj,
  A.sort(byBothOrds),
  A.map((obj) => obj.id),
);
 
console.log(sortSomeObj); // Output: [ 3, 1, 2, 4 ]
 
const sortByArray = pipe(
  someObj,
  A.sortBy([byGroup, byCount]),
  A.map((obj) => obj.id),
);
 
console.log(sortByArray); // Output: [ 3, 1, 2, 4 ]
 
// order by first "Circle" then "Box" then "Pyramid"
const byOrdinal = pipe(
  Ord.fromCompare((a: Shape, b: Shape) =>
    pipe(
      O.Do,
      O.bind("ordinal", () => O.fromNullable(["Circle", "Box", "Pyramid"])),
      O.bind("aIdx", ({ ordinal }) => A.findIndex((x) => x === a)(ordinal)),
      O.bind("bIdx", ({ ordinal }) => A.findIndex((x) => x === b)(ordinal)),
      O.match(
        () => 0,
        ({ aIdx, bIdx }) => N.Ord.compare(aIdx, bIdx),
      ),
    ),
  ),
  Ord.contramap((obj: SomeObj) => obj.group),
);

The Monoid type class is the group of types that support the concatAll function. This function is used to combine multiple Ord functions into a single Ord function.

This can be useful when you want to sort a list of objects by multiple properties of the object. I've done this through the use of M.concatAll and by A.sortBy as well as trying out custom ordinal sorting.

ReadonlyArray

const studentsGrades = [
  {
    name: "john",
    age: 21,
    classes: {
      history: {
        grade: 89,
        semester: "spring",
        category: "humanities",
      },
      math: {
        grade: 95,
        semester: "all_year",
        category: "quantitative",
      },
      physics: {
        grade: 81,
        semester: "fall",
        category: "quantitative",
      },
      literature: {
        grade: 77,
        semester: "spring",
        category: "humanities",
      },
    },
  },
 
  {
    name: "amanda",
    age: 20,
    classes: {
      history: {
        grade: 95,
        semester: "spring",
        category: "humanities",
      },
      math: {
        grade: 99,
        semester: "all_year",
        category: "quantitative",
      },
      physics: {
        grade: 89,
        semester: "fall",
        category: "quantitative",
      },
      literature: {
        grade: 65,
        semester: "spring",
        category: "humanities",
      },
    },
  },
 
  {
    name: "rachel",
    age: 19,
    classes: {
      history: {
        grade: 80,
        semester: "spring",
        category: "humanities",
      },
      math: {
        grade: 90,
        semester: "all_year",
        category: "quantitative",
      },
      physics: {
        grade: 100,
        semester: "fall",
        category: "quantitative",
      },
      literature: {
        grade: 88,
        semester: "spring",
        category: "humanities",
      },
    },
  },
];
 
const gradesByCategory = (groupBy: string) =>
  pipe(
    studentsGrades,
    RA.chain(({ classes }) => Object.values(classes)),
    RA.filterMap(({ category, grade }) =>
      category === groupBy ? O.some(grade) : O.none,
    ),
  );
 
console.log(gradesByCategory("quantitative"));
 
const groupGrades = pipe(
  studentsGrades,
  RA.reduce({} as Record<string, Array<number>>, (acc, { classes }) => {
    const grades = Object.values(classes);
    return pipe(
      grades,
      RA.reduce(acc, (acc, { category, grade }) => {
        const current = acc[category] ?? [];
        return {
          ...acc,
          [category]: [...current, grade],
        };
      }),
    );
  }),
  Re.toEntries,
  A.map<[string, number[]], [string, number]>(([category, grades]) => [
    category,
    pipe(
      grades,
      RA.reduce(0, (acc, grade) => acc + grade),
      (x) => +(x / grades.length).toFixed(2),
    ),
  ]),
  Re.fromEntries,
);
 
console.log(groupGrades);

The ReadonlyArray type class is the group of types can not be mutated. This helps to prevent accidental mutation of an array. When using ReadonlyArray I I explored how to use it group and aggregate values across an array of objects.