Pragmatic functional patterns with purify-ts

Error handling in functional programming

Purify-ts is a typescript library providing datatypes used in functional programming. These datatypes, such as Either or Maybe, are especially useful to handle errors, remove conditional boilerplate, and reduce complexity. Overall, they make the code easier to read, understand and maintain.

What are the advantages of using Either / Maybe instead of exceptions ?

First of all, exceptions are not exposed in function signatures, and this may become a pain if you were actually supposed to handle them. On the other hand, a function returning Either clearly exposes the fact that the function might fail, and the type actually forces you to handle that.

Secondly, exceptions are hard to compose; ie. pass around others functions and transform. It’s also not trivial to know where the exception is actually handled. Using functional programming patterns, an error is just a value that gets passed around like any others. We can easily transform the value inside Either if it’s a success; all that in a concise and declarative way (specifying what you want, not how).

Then, the actual exception is not typed. Usually you would have an Error type, but Either explicitly types the error returned.
A good practice to follow about when to use exceptions vs error handling datatypes would be the one exposed by the Rust team: use exceptions whenever you want to bring the program to a halt because some state or invariant is corrupted. Use Either/Maybe (or Result for Rust), whenever the error can be expected, might be recovered from, and the calling code must decide how to handle it

Refresher on the Either datatype

For the rest of this article, we will focus on the Either datatype, but the concepts are similar for the Maybe one.

Either is a common way to handle a flow that can fail. This datatype is a union of two types: Left and Right. Left represents an error, and the Right type represents a successful value.

type Either<L, R> = Left<L> | Right<R>;

A helpful mental way to picture Either is a box that either contains a value (Right), or an error (Left). You can perform computations on the box without the need to open it and actually know what you are dealing with. For example, here’s what happens when mapping over Either. Mapping means applying a function to the value that might be in the box.

Map operation on Either

Here’s a trivial example of a function that verifies if a string is a proper email.

type Email = string; // type alias for readability
const validateEmail = (email: string): Either<{errorReason: string}, Email> => {
  if (!email.includes("@")) {
    return Left({errorReason: 'The email does not contain an @ character' });
  }

  if (email.length < 3) {
    return Left({errorReason: 'The email be at least 3 characters long' });
  }

  return Right(email);
}

After calling this function, we end up with a potential email (depending on the input) encapsulated in the Either box.
So now if we need transform the email, for example extract the domain part, we can do it very easily without any condition boilerplate, just by mapping over it:

// takes a validated email and extracts the domain part
const extractDomainFromEmail = (email: Email): string => {
  const parts = email.split('@');
  return parts[1];
}


// example with invalid email
const eitherEmail = validateEmail('invalid-mail'); // returns Left
eitherEmail.map(extractDomainFromEmail); // does not perform any computation, returns former Left

// example with valid email
const eitherEmail = validateEmail('valid-mail@domain.com'); // returns Right
eitherEmail.map(extractDomainFromEmail); // returns Right('domain.com')

As we can see, the datatype is smart enough to perform a computation only when the email is valid. This is because Either holds the computational context of something that can fail.

Using Either with async Promises

Promises are used to represent asynchronous operations, and hence often involve side effects like API calls, reading a file, etc. Promises are very close to being monads; and similarly to the Either datatype we saw earlier, we can perform operations on the value contained inside the promise without needing to “open it”.
With the then keyword, we can chain promises. If an error occurs, the chains are skipped and the catch is executed instead. This should look familiar to the way Either works, with then being called chain, and catch being called chainLeft in purify-ts.

How and why combine Either with Promises ?


Let’s assume the following flow:

-> read a string from the standard input

-> validate that the string has a proper email format

-> perform an API call (that we will simulate) to retrieve a sessionId from the email

-> print the sessionId + email + domain part of the email

We can see that we have a mixed bag of async IO actions – that will be handled by promises – alongside some data manipulations with possible expected errors, for which we will use Either. 

Here’s what the code might look like:

// the promise is never rejected
// we handle the error with Either
const readMailFromCli = async (): Promise<Either<{errorReason: string}, string>> => {
  const rl = readline.createInterface({ input, output });
  return rl.question('Please enter your email \n')
    .then(Right)
    .catch(error => Left({errorReason: error.message}))
    .finally(() => {
      rl.close();
    })
}


const validateEmail = (email: string): Either<{errorReason: string}, Email> => {
  if (!email.includes("@")) {
    return Left({errorReason: 'The email does not contain an @ character' });
  }

  if (email.length < 3) {
    return Left({errorReason: 'The email be at least 3 characters long' });
  }

  return Right(email);
}

const getSessionIdFromEmail = (email: Email): Promise<Either<{errorReason: string}, SessionId>> => {
  // let’s simulate an API call
  return Promise.resolve(Right("12345"));
}

const printInformations = ({
  email,
  sessionId
}: {
  email: Email,
  sessionId: SessionId
}): void => {
  console.log(`Email: ${email}; SessionId: ${sessionId}`);
}

And the main function code that ties it together:

const main = async (): Promise<void> => {
 const eitherEmailFromInput = await readMailFromCli(); // Returns an Either type

 const eitherEmail = eitherEmailFromInput.map(validateEmail); // map over the either to validate the email

 eitherEmail.caseOf({
  Left: error => console.log(error.errorReason), // email is invalid
  Right: async email => {
    const eitherSessionId = await getSessionIdFromEmail(email);
    eitherSessionId.caseOf({
      Left: error => console.log(error.errorReason), // getting the sessionId failed
      Right: sessionId => {
        printInformations({sessionId, email});
      }
     })
  }
 })
}

main();

We saw an example of how to combine Promise with Either. However, since we have 2 layers, the nesting grows bigger and gets harder to read because we can’t easily chain Promise<Either<L,R>> with each other. We need to peel off the Promise layer, leaving use with the Either layer to peel off next.

It would be great if we had a way to peel off both layers at the same time, ie. be able to map / chain on a resolved promise with a success inside (Right) .We wouldn’t need those nesting anymore. In a nutshell, we want to turn Promise<Either> into a single coherent unit, that we can easily compose.

That’s where EitherAsync comes in useful !

EitherAsync to the rescue

EitherAsync is basically a wrapper around Promise<Either<L,R>>. It allows us to chain and compose async computation that may contain a failure.
EitherAsync can be viewed as a single box over which we can perform these transformations.

Here’s how we map over the datatype. We can see it’s awfully similar to the Either.map operation:

EitherAsync map operation

How do we build an EitherAsync ?

This datatype is meant to represent an async operation (Promise) that may contain a failure (Either)

So the natural way to get it is to turn a Promise<Either> into an EitherAsync.
Another way to get is by lifting an Either into an EitherAsync (which is basically an Either with an extra layer, the async part). This is useful when we need to compose (chain for example), an Either with some EitherAsync.
By “lifting”, it’s basically going to put that Either into a resolved promise, so that it can be turned into an EitherAsync. We lift the Either into an async layer.
Below a schema of the 2 operations we described to build an EitherAsync:

  • fromPromise lets us transform our Promise<Either> into an EitherAsync type.
  • liftEither “lifts” an Either type into an EitherAsync.

Here’s the main function from before refactored to use EitherAsync:

const flow = EitherAsync<{errorReason: string}, void>(async ({ liftEither, fromPromise }) => {
  const emailFromInput = await fromPromise(readMailFromCli());
  const email = await liftEither(validateEmail(emailFromInput));
  const sessionId = await fromPromise(getSessionIdFromEmail(email));
  printInformations({sessionId, email});
});

const main = async () => {
  const result = await flow.run();
  result.ifLeft(error => {
    console.log(error.errorReason)
  })
}

main();

We immediately notice that this version is much cleaner. We removed all the nesting and were able to chain instances of Promise<Either<L,R>> in a seamless way.

The magic here is that when using the await keyword inside the EitherAsync constructor, it pulls out the value nested deep down in Promise and Right. If inside the Promise we actually had a Left (failure), the execution (the chains) would stop and the whole EitherAsync block would return a resolved Promise with a Left inside, that we can handle in the main function.
Note that EitherAsync will return a  Promise<Either<L,R>> when run, that contains the entire flow. This promise never rejects.

If you are familiar with monad transformers, this might look somewhat similar to the EitherT or MaybeT datatypes, and how the do notation unwraps the nested value.

Conclusion

In this article, we briefly discussed the Either datatype, which is a common way to deal with errors in functional programming. We saw how the Either datatype can be used to represent the result of a function that may fail, and how it can be used to handle errors in a structured and predictable way.

We also saw how EitherAsync can be used to handle async values and errors wrapped, and makes it easy to compose them.

Most importantly, it shows how functional programming offers crucial tools that help reasoning about our program flow.