Our journey to functional programing

Hugo
inato
Published in
6 min readDec 20, 2021

--

How did we end up using complex types borrowed from languages such as Haskell (Either, Reader, Option…) to build our product?

tl;dr

Our progressive move towards a more “fp-heavy” approach in our codebase has mostly been the result of identifying specific frustrations and finding ways to manage them.

Instead of saying “fp is da bomb! Let’s do some of that!” we slowly but surely realized that, as our codebase grew, static typing was a way to enhance our productivity while mitigating some bugs, hence flow and then TypeScript.

At some point, we found that it was a pain to investigate the source of an unexpected throw, and so we started to use Either. Same thing for null values, hence Option. And soon enough, it got difficult to compose instances of Promise<Either>, and so we adopted Task and TaskEither.

When our use-cases started to grow a gigantic list of needed services and repositories, we managed it with Reader. And so on, and so on…

It has been a slow and steady march towards more functional programing in our codebase, tackling one specific pain point at a time.

(Pre)history

A long, long time ago, in times immemorial before I joined Inato, our codebase at large was written in plain JavaScript. However, as it grew, it became more and more difficult to avoid type-related bugs and flow was added to incrementally provide some basic type-checking.

Finally, with the growing popularity of TypeScript, it was decided that it would be the way to go moving forward and the process of migration began. By the time I started working at Inato, most of the codebase was already migrated to TypeScript (save for a few legacy services) and any new development would be in TypeScript.

Homemade solutions

Silent errors are no fun. Not knowing whether a function will throw without diving deep into a rabbit hole of function calls is frustrating.

Either to the rescue : our own, custom made Either.

What is Either?

Either<E, A> is a union type (sometimes also called sum type or discriminated union). What this means is that it is a type that can either carry a value of type E or a value of type A but not both at the same time. It makes it then particularly useful to describe the return of a function that can fail, allowing it to return an A in case of success or an E in case of failure.

Conceptually, an Either looks like this:

Either — A result with the possibility of failure

And it may be defined as follows (in TypeScript):

Our original naïve Either implementation

Notice that the Either itself doesn’t carry any semantics tied to error handling. It is merely a way to discriminate between two types (in fact it is the canonical union type, meaning that any union type can be expressed strictly in terms of Either). That’s also why some languages such as Rust or Elm chose to have a Result<E, A> type which is isomorphic (ie. strictly equivalent) to Either but with the explicit semantic of error handling.

How can we go further?

Using Either has helped us greatly in reducing the number of unexpected runtime errors. Unfortunately, while it is not too much of a hassle to handle Either values directly, composing them as asynchronous computations in Promise<Either<E, A>> quickly became a major painpoint.

To the rescue came the amazing fp-ts library by Giulio Canti. It not only defines its own version of the Either but it goes much further than that:

  • Option<T> for expressing nullable values
Option — A result that may not exist
  • Task<T> for expressing asynchronous computations
Task — A result that will be produced at some point in the future
  • Reader<R, T> to perform dependency injection
Reader — A result that will be produced only when provided with an additional dependency

and many so-called transformer stacks for easy composition between those types such as TaskEither<E, A> for asynchronous computations that may fail and many more.

It also provides a consistent interface for many helper functions around those types, that allows them to be used inside the pipe function.

New product, clean slate

Around the start of 2020, we shifted our focus from our existing product and started building the brand new Inato marketplace, from scratch.

There would not be a better time to reconsider parts of our technical stack!

With the hindsight of everything we had learnt working on the feasibility platform, we started exploring new solutions. Specifically, from a language perspective, leveraging a type system with strong guarantees around error handling, nullable values and side effects management was an explicitly stated goal.

We knew and loved TypeScript but maybe there were better alternatives? Two main contenders were suggested: Rust 🦀 and ReasonML 🐫.

Over the course of two weeks, prototypes were built in those languages and presented to the team, with their advantages and drawbacks. In the end, we chose to stick with TypeScript.

But ultimately, it was not for nothing as we learned many things along the way (and that is a story for my next article 😉).

Adopting new concepts when needed

Over time, for each new difficulty we faced, we ended up finding and integrating a new pattern that helped solve it.

For example, TypeScript is structurally typed (as opposed to nominally typed) so creating a named wrapper around primitive types to use as value objects is not straightforward. Enter newtype-ts, that brings the newtype pattern used in various languages such as Rust or Haskell to TypeScript.

We also do a lot of serialization/deserialization, and because we very much care about whether any value that enters the system is well-typed, we ended up adopting io-ts. In fact, that one use-case wasn’t new for us. We originally had our own homemade solution: decod. But as we started relying more and more on fp-ts, it became easier to use other libraries that integrated seemlessly within the ecosystem.

Reader was a complex beast to tame for those who never got exposed to that concept. In fact, I hesitated for quite some time before introducing it. Is the learning curve worth it? Will it cause more confusion and complexity than it’s worth? Well I’m happy to report, more than a year later, that yes, it was well worth it!

Investing for the future

Of course, all those tools come at a cost. Their learning curve can be pretty steep in some cases and that is something that needs addressing. So what can we do?

Quite a lot, actually!

The first step is having good documentation and training materials for each new concept introduced in our codebase. This is primarily aimed at new recruits that need to be brought up to speed but it also serves as a reference for all team members.

We created two repository to achieve this:

  • the first one is fp-ts-cheatsheet that collects “recipes” of the pattern we use at Inato
  • and the second isfp-ts-training that is presented to each new product engineer that joins the team

But there’s more we can do to give back to the community that provides us with such amazing tools. If there is a need for fp-ts-cheatsheet, it’s also in part because the official documentation of fp-ts can be pretty dry. Hopefully we can take the time to submit some of the in-house documentation we wrote to upstream.

We can, and have, in the past also needed new functions and combinators not present in the library and submitted them to be integrated.

Finally, we can also raise awareness about these tools we grew to know and love. And that was the object of that article.

--

--