Either monad (fp-ts) for processing deeply-nested documents with Typescript

Nik Vaklev
4 min readAug 22, 2023

Intro

The Either monad can be very helpful, when working with nested data structures for example from API responses. Often the problem is an API response from a 3rd party system would return data structures that can vary depending on the underlying data.

Simply, the data they produce is not perfect either and could easily have gaps and it is not the job of an endpoint to make for missing or incomplete information.

How would you process the API response below?

[
{
reviewerID: "A2SUAM1J3GNN3B",
asin: "0000013714",
reviewerName: "J. McDonald",
helpful: [2, 3],
reviewText:
"I bought this for my husband who plays the piano. He is having a wonderful time playing these old hymns.",
overall: 5.0,
summary: "Heavenly Highway Hymns",
unixReviewTime: 1252800000,
reviewTime: "09 13, 2009",
},
{
reviewerID: "A2SUAM1J3GNN3A",
asin: "0000013714",
reviewerName: "J. Smith",
helpful: [1, 2],
overall: 3.0,
summary: "Heavenly Highway Hymns",
unixReviewTime: 1252800001,
reviewTime: "09 14, 2009",
},
{
reviewerID: "A2SUAM1J3GNN3A",
asin: "0000013714",
reviewerName: "J. Banks",
helpful: [7, 8],
reviewText: "",
overall: 7.0,
summary: "Heavenly Highway Hymns",
unixReviewTime: 1252800001,
reviewTime: "09 14, 2009",
},
null,
]

We have three JSON documents in the response… Well actually they are four but the last one is simply null!

The second one is missing the reviewText key. Because we are talking about product reviews, logically, not all users will provide a full review. For brevity, the design team behind this particular API decided to make that key optional (often) in order to minimise the data transferred between servers and clients.

What about thenull value at the end of the list? Maybe this review was deleted for some reason but the system still wants us to know there was something there.

Solution 1: The Naive Approach

This is vanilla Typescript snippet showing the processing of a JSON API response:

interface SingleReview {
reviewerID: string;
asin: string;
reviewerName: string;
helpful: [number, number];
reviewText?: string;
overall: number;
summary: string;
unixReviewTime: number;
reviewTime: string;
};

const complexLogic = (i: SingleReview | null) => {
if (!!i) {
switch (i.reviewText) {
case "":
return "";
case null:
case undefined:
return "NO_REVIEW_TEXT";
default:
return i.reviewText;
}
} else {
return "NO_REVIEW";
}
};

// Get an array of all the reviews
function getReviewsSimpleApproach(reviews: Array<SingleReview | null>) {
return reviews.map((i) => complexLogic(i));
}

// Get an array of the length of all the reviews
function processReviewsSimpleApproach(reviews: Array<SingleReview | null>) {
return reviews
.map((i) => {
return complexLogic(i);
})
.map((i) => {
switch (i) {
case "NO_REVIEW":
case "NO_REVIEW_TEXT":
return null;
default:
return i.length;
}
});
}

function simpleReviewProcess() {
return [
processReviewsSimpleApproach(reviews), // list of review lengths
getReviewsSimpleApproach(reviews) // list of all the texts
];
}

Solution 2: Either monad

The code above works but it can be improved by using Either monad from fp-ts

import * as E from "fp-ts/Either";
import { flow } from "fp-ts/function";

interface SingleReview {
reviewerID: string;
asin: string;
reviewerName: string;
helpful: [number, number];
reviewText: string;
overall: number;
summary: string;
unixReviewTime: number;
reviewTime: string;
};

const fromNullableText = E.fromNullable("NO_REVIEW_TEXT");
const fromNullableReview = E.fromNullable("NO_REVIEW");

const getReviewLength = flow(
E.flatMap((j: SingleReview) => fromNullableText(j.reviewText)),
E.map((j: string) => j.length),
E.match(
() => undefined,
(right: number) => right
)
);

const getReviewSummary = flow(
E.flatMap((j: SingleReview) => fromNullableText(j.reviewText)),
E.match(
(err) => {
switch (err) {
case "NO_REVIEW":
return err;
case "NO_REVIEW_TEXT":
return err;
default:
return "";
}
},
(right: string) => right
)
);

function processAllReviews() {
const either_reviews = reviews.map((i: SingleReview | null) =>
fromNullableReview(i)
);

return [
either_reviews.map((i) => getReviewLength(i)), // list of review lengths
either_reviews.map((i) => getReviewSummary(i)), // list of all the texts
];
}

Both approaches produce the same table below:

I would argue that the use of Either monad makes it easier to follow, reason and one day refactor this code. Functional programming is all about expressing your intent and re-using code as much as possible.

Also imagine if the JSON returned by the API was actually nested, i.e. one of the keys contains not a simple value but another object. The sheer effort to write the logic to account for all the possibilities to retrieve values from deeply nested structures will be daunting and error prone. Either is good at handling this case and provides a template for how to deal with this case.

There is one down side to monads… They are still objects which exist in memory and thus take up RAM and CPU resources. Wrapping each value in a monad from a list of millions of objects, will blow up as well.

The main difference between vanilla Typescript and using Either from fp-ts, is that we as humans don’t have to constantly keep writing code to check about all the possible edge cases. The code quality and maintainability deteriorates quickly when the number of if- and switch-statements explodes.

My name is Nik and I am the founder of Techccino Ltd.

--

--