Ondra Bašista
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?
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.
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.
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!
Just leave us a message using the contact form or contact our sales department directly. We will arrange a meeting and discuss your business needs. Together we will discuss the options and propose the most suitable solution.