The art of technology

Blog

Back

FP-TS - Either

Technology
FP-TS - Either

Welcome all readers to the first part of the ApiTree series on functional programming in TypeScript, which will (among other things) gradually introduce selected features of libraries from the FP-TS family. We have a light introduction to FP behind us, so let's dive into a more practical implementation.

There are two types of problems that the programmer constantly encounters. Error handling and null values. In this article I want to focus on error handling in TypeScript and it’s, in my opinion, improved alternative in the concept of the FP-TS library.

What is the problem?

  • If the programmer does not explicitly define in the type annotation - he can relatively successfully cover the implementation of error handling within the structure with the "help" of TS.
  • TypeScript does not force the type annotation of such a structure, nor may it correctly understand the structure for type inference.
  • Every programmer has the freedom (often unfortunately) to implement error handling in his own way. Once it propagates the error through a Throw, once it just logs, other times it writes to the database entity. Such behaviour often leads to problems in the composition of functions.
  • Different types of errors and their handlers often do not agree.
  • Error handling is often completely forgotten. Hello unhandled promise rejection :-)
  • The functional approach is not to directly throw errors, but to convey the captured ones in a controlled way.
  • If the programmer handles each error in his own way within the function, he decides to deal with the error prematurely
  • It's all punk, which leads to an increase in errors or a problem when logging / retrieving them!

Practical example of intentionally suboptimal error handling in TS:

const getSomeDataThrow = (data: string) => {
  try {
    const result = fakeDataProducer(data);
    return result.someData;
  } catch (e) {
    throw new Error(e);
  }
};

The code is, of course, truncated to a minimum, but the problem is already with type annotation. TypeScript returns a string without an explicit label, which can cause a rather unpleasant surprise when viewed from the outside. The function in no way claims to be potentially dangerous, and when composing multiple such entities, the problem arises multiple times.

We continue:

const getSomeDataLog = (data: string) => {
  try {
    const result = fakeDataProducer(data);
    return result.someData;
  } catch (e) {
    db.someEntity.errors.push(e);
    console.log(JSON.stringify(e));
  }
};

The same problem in type annotation, the function allegedly returns a string, and this time there is no throw error, but logging in and writing to the db entity. Yes, the type can be expressed explicitly, but freedom is treacherous.

If we combine both functions into some kind of pipe / flow, the sky looks sunny:

const result = pipe(
  'some string data',
  getSomeDataThrow,
  getSomeDataLog,
)

But under the hood, the pipe can now behave unpredictably. Let's add asynchronous code and we're done with unhandled errors.

Either monad

In TypeScript, however, we can do it better, functionally and cleanly. At first a little theory, but really just a little with reference to my first article.

Why is Either a monad?

Imagine a structure that can receive data in an envelope, unpack its contents, apply a function to it, and wrap the result back in the envelope. We have a functorial. Let's upgrade the structure and allow it to accept not only value in the envelope, but a function that it then applies to data from another envelope. We have an applicative functorial. Roughly. Let's add the ability to wrap data from an envelope to a function that returns data in another envelope - let's call it for example a flatmap or chain and we're approaching a monad. Abstract? Definitely. Sufficient for an idea before a practical demonstration. For us, the monad is still an algebraic structure that can manipulate another similar structure quite comprehensively.

Rails

Either is a monad, a wrapper over data, which can work with a wrapper of a similar wrapper or wrap/unwrap the data in a wrapper. Typical of Either's monads (or the technique that Either implements) is the so-called railway-oriented programming - or programming in rails. The left rail carries a wagon with errors, the right with the results. Either <Error, Result>.

So, let's rewrite the previous code. First in the version where we explicitly define the Either rails and dust it off a bit.

Then in a more refined build-in form:

const getFakeDataEither = (data: string): E.Either<Error, string> => {
  try {
    const result = fakeDataProducer(data);
    return E.right(result.someData);
  } catch (e) {
    return E.left(Error(e));
  }
};

As is clear from the code. If the error in the try branch is not captured, we will wrap the value in the right (that happy day) rail. If we catch the error, we will follow the left rail. Here, we have just created the Either monad, which clearly declares both error and non-error scenarios.

Let's now write the code correctly and use a ready-made function suitable for our scenario:

const getFakeDataEither = (data: string): E.Either<Error, string> => {
  return (
    E.tryCatch<Error, string>(() => fakeDataProducer(data).someData, (e) => Error(JSON.stringify(e)))
  );
};

We used a built-in tryCatch function that returns an Either monad with a string as a positive result and an Error as an error value. We clearly declare what our intention is, the potential error rate of the function, and the type of inference works as expected (I declare the type only for overview).

Okay, so we have a monad. But what's next? How about extending our resulting string if the function was successful? But how do we do that we have the data wrapped in some dubious wrapper?

Let's use the map function:

const eitherTest: E.Either<Error, string> = pipe(
  "some string data",
  getFakeDataEither,
  E.map((value) => `${value} updated`)
);

As you can see, to preserve our rails and update the string, all you have to do is use the map property (derived from the functorial), which is (as I described above) able to unpack the getFakeData result, call the declared function with update value and data back into the envelope. In the event of an error when calling getFakeData, the following function will never be executed and the monad in the left branch will hold the error.

But what happens if the chained function itself returns the monad as a return value? This is a common case and we have a solution for it as well. Flatmap or chain.

First, I will show the wrong variant:

const wrongEitherTest = pipe("some string data", getFakeDataEither, E.map(getFakeDataEither));

What type does the following function return to us? E.Either <Error, E.Either <Error, string >> Huh. Envelope in an envelope. And now the chain:

const niceEitherTest = pipe("some string data", getFakeDataEither, E.chain(getFakeDataEither));

The resulting type is our beautiful E.Either <Error, string>

For now, the last step will be to pull the data out of the envelope. The front-endists are a bunch of clumsy people, and for the return value from the Object API type {_tag: "Right", right: "some string data"}, they will certainly kill us:

const eitherFold: string = pipe(
  "some string data",
  getFakeDataEither,
  E.chain(getFakeDataEither),
  E.fold(
    (e) => JSON.stringify(e),
    (res) => res
  )
);

A little rough, but the unwrapped string is born. In general, however, it is ideal to perform transformations on the data in the envelopes and leave them unpacked to the application outputs. Both in terms of reusability and in terms of handling side effects.

In conclusion

We have just used the Either monad with error handling in a slightly uncombed lite form (it can also be used for validations, for example). Of course, the Either API is more extensive, but I hope the example is sufficient for a basic understanding of the issue. The issue of concatenation and composition of algebraic structures is more complex, FP-TS provides other transformation functions - for example for transition from one type of monad to another (for example Option> Either), various tweaks for expanding values, asynchronous programming (Task, TaskEither) and tools for all sorts of iterations and flexible manipulation with objects of a similar type. But about that again in other parts.

Good luck, TypeScript developers!