DEV Community

Cover image for Functional programming with fp-ts.
Petr Tcoi
Petr Tcoi

Posted on • Updated on

Functional programming with fp-ts.

In the previous article we looked at an example of using the ramda library together with React/Redux. Here, I want to share my experience using another great library, fp-ts.

These are my first steps in this direction, and I would appreciate any comments and feedback.

Functional programming - programming with containers

If ramda is more about function composition, fp-ts is already about working with functors, monads, and so on. These are quite abstract concepts that, for simplicity, I defined as just containers.

The idea is that in the code, we work not with the values of variables as such, but rather place them in containers that store both the value of the variable and additional information about it (context). This approach makes the code more reliable by isolating potentially dangerous value options (such as null or undefined).

Let's look at the basic Option container, which is responsible for a variable that may or may not exist. It stores context in the _tag field, which takes two values: "None" or "Some". The first value is taken by _tag if the variable of interest is undefined or null. The second value is taken if the variable has some other value (i.e., exists).

example with undefined:

import * as Option from 'fp-ts/Option';
const x = undefined      //  variable x is "undfined"
const container = Option.of(x) // put it into Option
// now its value is
// {_tag: "None"}

Enter fullscreen mode Exit fullscreen mode

example with existing value:

import * as Option from 'fp-ts/Option';
const x = 55                  //  variable x exist
const container = Option.of(x)      // put it into Option
// now the value is
// {_tag: "Some", value: 55}
Enter fullscreen mode Exit fullscreen mode

Right now, we don't have direct access to the value of the variable x. It is hidden in the Option container, and in order to access it, we need to use the map function. For example, if we want to get the result of multiplying the received variable by 2:

import { pipe } from 'fp-ts/function';
...
const result = pipe (
  container,
  Option.map((y) => y * 2)
)
Enter fullscreen mode Exit fullscreen mode

On the output, we will not get the result of multiplication directly, but the result of multiplication hidden in the Option container. The type of the variable result will be as follows:

const result: Option.Option<number>
Enter fullscreen mode Exit fullscreen mode

The map function performs the following steps:

  1. Accepts a function foo that needs to be applied to the value stored in the container.
  2. Checks the _tag value of the container.
  3. If it has a value of "None", it simply returns the container as it is and does nothing.
  4. If it has a value of "Some", it executes the function foo, passing the value from the value variable of the container as an argument.
  5. Is the resulting value equal to null/undefined? Returns Option.None, which is {_tag: "None"}
  6. If the resulting value is not equal to null/undefined, it returns it as Option.Some, which is {_tag: "Some", value: foo(x)}.

Functors and Monads

A container that has a map function is called a functor.

In some cases, the function passed to map can itself return a container, instead of the value directly:

const result = pipe (
  container,
  Option.map((y) => { return Option.of(y * 5)})
)
Enter fullscreen mode Exit fullscreen mode

If the function passed to map returns a container itself instead of a value, the resulting value will be nested inside another container. Consequently, working with such a structure is not possible.

const result: Option.Option<Option.Option<number>>
Enter fullscreen mode Exit fullscreen mode

To avoid this, you need to extract Option.Option<number> from the extra layer of Option. The function that first executes the passed function and then "removes" the extra layer of the container from the resulting value is called flatMap or chain (in the case of fp-ts).

Essentially, this is the same function as map, after which the value is extracted from the outer container. If a container has such a function in its arsenal, it is already called a monad.

Why does this make sense?

Since we do not interact with the variable's value directly, we do not have to worry about a "missing" variable appearing during code execution, leading to unpredictable results. The Option container and its map function will take care of this for us.

Placing the variable and all calculation results into containers allows us to avoid using constant checks in the code such as

if (x == null) {
  throw ...
}
Enter fullscreen mode Exit fullscreen mode

We know that if there is undefined or null anywhere, they will be sealed in an Option container and filtered out by the map method when applied to any function. This continues until the end of the code, where we can check what is in the container: a result (Option.Some) or an error (Option.None).

The probability of forgetting to perform the necessary check in the code is eliminated. The key is not to extract our values from the Option container. We can write code as if no errors are happening. If undefined appears somewhere, such a variable will be ignored by all functions passed to the container via map or chain.

Modern linters successfully help to avoid errors related to missing checks of variable values or computation results. But the strength of fp-ts is that its checks will work during the runtime.

Example with REST API

Here is an example of a backend function for a hypothetical store, where a registered user tries to purchase an item by paying for it using funds stored in their balance.

The logic is as follows:

  1. Check if itemId is received.
  2. Check if an item with the specified itemId exists.
  3. Call a method that checks the request headers and determines whether the user is authorized (for simplicity, it does not take req and simply returns some user data).
  4. Check if such a user exists in our database.
  5. Check if the client has enough funds in their account to purchase the item.
  6. Make the appropriate entries in the database.

Here is the sample code:

const handler = async (req: CustomNextApiRequest, res: NextApiResponse<ResponseType>) => {
  const body = makeSerializable(req.body);
  const method = makeSerializable(req.method);

  if (method !== 'POST') {
    res.status(405).send('Wrong method');
    return;
  }

  if (!body.itemId) {
    res.status(400).send('Missed data');
    return;
  }

  const sessionUserId = await getSessionUserId();
  if (!sessionUserId) {
    res.status(403).send('No signed in user');
    return;
  }

  const user = await getUser(sessionUserId);
  if (!user) {
    res.status(400).send('Cant find user');
    return;
  }

  const item = await getItem(body.itemId);
  if (!item) {
    res.status(400).send('Cant find item');
    return;
  }

  if (!isBalanceSufficient(item, user)) {
    res.status(400).send('Balance is not sufficient');
    return;
  }

  /** some db actions */

  res.status(200).send('OK');

};
Enter fullscreen mode Exit fullscreen mode

At each stage, a check is made to ensure that the received value is valid. If you skip the error and allow the appearance of undefined or null values in the program, the result can be unpredictable.

Solution using fp-ts and TaskEither.

To solve this problem with fp-ts, we will need the TaskEither module. It consists of two parts:

Either

TaskEither is similar to Option, but instead of returning Option.None/Option.Some, it returns Either.Left and Either.Right values. Some and Right are fundamentally the same - they are just containers for storing data. The Either.Left variant, unlike None, can also store a string with an error message.

The most common way to pass a value to Either is through the method with a self-explanatory name fromNullable. It takes a string with an error message as the first argument and the second argument is the value of interest, which can be null (nullable).

For example:

E.fromNullable('No data')(565);  // { _tag: 'Right', right: 565 }
E.fromNullable('No data')(null)  // { _tag: 'Left', left: 'No data' }
Enter fullscreen mode Exit fullscreen mode

Either is preferred over Option since it allows us to provide information about the nature of the error.

Task

Task is a wrapper for asynchronous tasks.

T.of(getItem)  // T.Task<(id: number) => Promise<Item | null>>
Enter fullscreen mode Exit fullscreen mode

It is assumed that Task always completes successfully. In order to handle "bad" results, TaskEither is used. That is, a Task that can return both a positive result (Either.Right) and a negative one (Either.Left).

Шаг 1. Processing User

This piece of code is a bit more complex than working with Item, so I will explain it right away, and then the code with item will become clear.

The task is to get the user wrapped in an Either container. That is, either the user exists (Either.Right), or the user does not exist (Either.Left with an error message in the string format).

const user: E.Either<string, User>  // we need to get 
Enter fullscreen mode Exit fullscreen mode

the code:

import * as TE from 'fp-ts/TaskEither';

const user = await pipe(
    getSessionUserId,  // () => Promise<number | null>
    TE.fromTask,       // TE.TaskEither<never, number | null>
    TE.chain(
      flow(                                // get number | null
        TE.fromNullable('Not logged In'),  // TE.TaskEither<string, number>
        TE.chain(
          flow(                             // get usereId
            getUser,                        // return Promise<User | null>
            (user) => () => user,
            TE.fromTask,                                  // TE.TaskEither<never, User | null>
            TE.chain(TE.fromNullable('Cant find user')),  // TE.TaskEither<string, User>
          )),
      ),
    ),   // TE.TaskEither<string, User>
  )();

Enter fullscreen mode Exit fullscreen mode

For convenience, I added the return values as comments after each step.

Let's go through what's happening here step by step:

const user = await pipe(...)()
Enter fullscreen mode Exit fullscreen mode

The pipe function takes a list of functions that are called sequentially. The result of the first function is passed as an argument to the second function. The result of the second function is passed as an argument to the third function, and so on.

Since we need to get TaskEither as a result, I immediately called the obtained result (await()) to get just Either.

pipe (
    getSessionUserId, 
    TE.fromTask, 
...)
Enter fullscreen mode Exit fullscreen mode

We take the getSessionUserId function and wrap it in a container (TaskEither) using the TE.fromTask method. Now we can work with it safely.

pipe (
    getSessionUserId, 
    TE.fromTask, 
    TE.chain (
       flow (
         ...
       )
      ...
    )
...)
Enter fullscreen mode Exit fullscreen mode

Since the result of the getSessionUserId function is inside a container, we need to unpack it in order to work with it. The methods map and chain are responsible for this. Later in the code, the result will be wrapped in another TaskEither, so we use chain to avoid double nesting.

Another thing to note is that we use flow instead of pipe here. This option is more concise when you need to pass pipe as an anonymous function. The two options below are equivalent.

flow(foo,...)
(x) => pipe(x, foo...)
Enter fullscreen mode Exit fullscreen mode

The variable passed to flow has the type undefined | number, so we first wrap it in a container:

flow (
   TE.fromNullable('Not logged In')  // TE.TaskEither<string, number>
   ...
)
Enter fullscreen mode Exit fullscreen mode

In case the value of getSessionUserId was undefined, the following TE.chain(...) code is skipped and only the error message ('Not logged In') is returned.

In case the user was logged in, we use their id value for further work inside the next chain -> flow block.

flow(
    getUser,   // return Promise<User | null>
        (user) => () => user,
        TE.fromTask,  // TE.TaskEither<never, User | null>
        TE.chain(TE.fromNullable('Cant find user')),
)),

Enter fullscreen mode Exit fullscreen mode

Here we also call the asynchronous function getItem, and wrap it in the TaskEither container. Since it requires the signature () => Promise<User | null>, the line (user) => () => user appears.

Then, again, we use chain and check the received result, indicating an error message in case the user is not found.

TE.chain(TE.fromNullable('Cant find user'))
Enter fullscreen mode Exit fullscreen mode

Processing Item

The process of getting the requested Item data is very similar. The only difference is that instead of getSessionUserId, we simply check whether the itemId was passed in the request.

  const item = await pipe(
    body.itemId,
    TE.fromNullable('Item ID is missed'),  // TE.TaskEither<string, number>
    TE.chain(                              // pass itemId as 35 to flow
      flow(
        getItem,                            // Promise<Item | null>
        (item) => () => item,               // () => Promise<Item | null>
        TE.fromTask,                        // TE.TaskEither<never, Item | null>
        TE.chain(TE.fromNullable('Cant find item')),
      )),
  )();

Enter fullscreen mode Exit fullscreen mode

Balance checking

The final step is to check if the user has enough balance to purchase the item. Here, we can't simply use pipe or flow since the check involves two variables that are wrapped in Either containers.

For such cases, we use the Do notation: we declare which variables we're going to use and give them names, then call the function with these variables using E.chain(...). It looks like this:

pipe(
  E.Do,
  E.bind("_item", () => item),
  E.bind("_user", () => user),
  E.chain(({ _user, _item }) => isBalanceSufficient(_item, _user)
    ? E.left('Balance is not sufficient')
    : E.right('OK')
  ),
  E.fold(
    (result) => res.status(400).send(result),
    (result) => res.status(200).send(result)
  )
);
Enter fullscreen mode Exit fullscreen mode

The function works quite simply: if the balance is enough, then it returns a "good" E.right value. If it is not enough, then a "bad" E.left value is returned with an error message.

The last function E.fold() takes the E.Either<string, string> container and checks its value (result). If the value is wrapped in E.left, then the first function is executed: a response with a string containing an error description and a status of 400. If the result is positive E.right, then it returns "OK" with a status of 200.

Full code

The complete function looks like this:

const handler = async (req: CustomNextApiRequest, res: NextApiResponse<ResponseType>) => {
  const body = makeSerializable(req.body);
  const method = makeSerializable(req.method);

  if (method !== 'POST') {
    res.status(405).send('Wrong method');
    return;
  }


  const item = await pipe(
    body.itemId,
    TE.fromNullable('Item ID is missed'),  // TE.TaskEither<string, number>
    TE.chain(                              // pass itemId as 35 to flow
      flow(
        getItem,                            // Promise<Item | null>
        (item) => () => item,               // () => Promise<Item | null>
        TE.fromTask,                        // TE.TaskEither<never, Item | null>
        TE.chain(TE.fromNullable('Cant find item')),
      )),
  )();


  const user = await pipe(
    getSessionUserId,  // () => Promise<number | null>
    TE.fromTask,       // TE.TaskEither<never, number | null>
    TE.chain(
      flow(                                // get number | null
        TE.fromNullable('Not logged In'),  // TE.TaskEither<string, number>
        TE.chain(
          flow(                             // get usereId
            getUser,                        // return Promise<User | null>
            (user) => () => user,
            TE.fromTask,                                  // TE.TaskEither<never, User | null>
            TE.chain(TE.fromNullable('Cant find user')),  // TE.TaskEither<string, User>
          )),
      ),
    ),   // TE.TaskEither<string, User>
  )();



  pipe(
    E.Do,
    E.bind("_item", () => item),
    E.bind("_user", () => user),
    E.chain(({ _user, _item }) => isBalanceSufficient(_item, _user)
      ? E.left('Balance is not sufficient')
      : E.right('OK')
    ),
    x => x,
    E.fold(
      (result) => res.status(400).send(result),
      (result) => res.status(200).send(result)
    )
  );
  return;
};

export default handler;

Enter fullscreen mode Exit fullscreen mode

The code can be split into separate functions, but for the sake of demonstration, this version seemed more illustrative.

Since all the values are wrapped in either TaskEither or Either containers, we don't have to worry about unforeseen scenarios.

For example, what would happen if a request came without an itemId:

  1. At the TE.fromNullable('Item ID is missed') stage, since the body.itemId value is undefined, we would get { _tag: 'Left', left: 'Item ID is missed' }.

  2. Since the value is Left, the function enclosed in TE.chain is skipped.

  3. At this stage - E.bind("_item", () => item) - we pass the existing value to the variable _item.

  4. When we "unpack" the inner contents of _item in the E.chain(...) line, the container again sees that it is E.left, so it skips it further.

  5. At the E.fold(...) stage, because the value is E.left, the first function is executed, passing the error message contained within.

As a result, we get a response from the server saying "Item ID is missed" with a status code of 400.

Conclusion

Although the fp-ts library is quite popular and has detailed documentation, it was initially difficult to understand. Many thanks to @souperman for his advice, without which I would not have been able to put together this example.

I will definitely try to make the most of the library in my next project, which I will be creating for myself and writing alone. The fp-ts syntax and approaches are too specific to be seamlessly integrated into projects where I work in a team, and the code should be as understandable as possible for any participant.

If I find any interesting patterns, I will write about them in a new article.

Top comments (0)