DEV Community

Mepuka Kessy
Mepuka Kessy

Posted on

Functional Programming Baby Steps: Why TaskEither Is Better Than Promise

In a previous post I introduced two type classes (actually they're monads but that's not for now) the Option type and Either type. These types are extremely useful abstractions for dealing with operations that may fail. The former gives us no information about the failure just an empty None while the later gives us a Left type containing some information about the failure (like an error message.)

The Option and Either Types
type Option<A> =
  | { type: 'None' } // our operation failed
  | { type: 'Some'; value: A } // our operation succeeded and we have a value of type A

type Either<L, A> =
  | { type: 'Left'; left: L } // holding a failure
  | { type: 'Right'; right: A } // holding a success
Enter fullscreen mode Exit fullscreen mode

Ok these are useful but are hardly a comprehensive model for the types of data and operations we might encounter while web programming. One ubiquitous type of operation that cannot be avoided are those that are not synchronous -- an asynchronous operation. This could be an operation fetching a webpage, an operation connecting to a database, or even a series of synchronous operations that are resource intensive and may take awhile to complete.

In TypeScript/JavaScript we have an abstraction that deals with such operations called a Promise. As described in the MDN web docs:

The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.

They also provide a handy diagram to help think through the control flow of a Promise and its differing states.

Flow chart for a typical Promise

As you can see there is a lot going on here. What's more is you can chain promises together so imagine pasting this same diagram everywhere you see a .then. This complexity can be difficult to work through especially as the Promise chain grows and you start to encounter nested call backs. In extreme cases it can lead to what's known as callback hell.

For this reason async/await syntax was introduced. It helps avoid Promise chain hell and makes our code look more synchronous. Unfortunately we still run into the problem of having to constantly await promise-based values before we can operate on them. Further more those awaited Promises could reject and so we need to explicitly wrap them in Try Catch Finally blocks or chain .catch and .finally callbacks.

But there's another way we can think about asynchronous operations that might help us escape some of the complexity of Promises.

The Task Type

In fp-ts a Task is defined as

interface Task<A> {
  (): Promise<A>
}
Enter fullscreen mode Exit fullscreen mode

Task<A> represents an asynchronous computation that yields a value of type A and never fails. And while this is just a wrapped Promise the stipulation that this operation can never fail is a subtly powerful contract if we adhere to it. Knowing that it won't fail means that Task<Whatever> is always going to return a Whatever.

Now how useful is this really? Unfortunately in the real world we're often working with operations that fail, especially those that are asynchronous. So how do we represent async operations that can fail? Well we know how to represent async operations with Task and we know how to represent operations that can yield a failure value with Either.

interface TaskEither<E, A> extends Task<Either<E, A>> {}
Enter fullscreen mode Exit fullscreen mode

So a TaskEither is just a Task that is guaranteed to yield an Either value. In other words it is a Promise with only a resolve path. Instead of rejecting we store the failure value in the Left type of the Either sum type.

Initially this concept was confusing to me as it seemed like a bit of hack to just ignore an entire part of the Promise API. But if we look at the flow diagram above it's clear how simplifying this abstraction can be. We no longer have to deal with the Reject branch. Instead values corresponding to rejection are contained within the Either type.

Let's go back to the example from the previous post. We have an API that returns a list of Users.


// type declaration
declare fetchUsersFromAPI: () => Promise<User[]>

// possible implementation using Axios
function fetchUsersFromApi() {
    return axios.get('https://api.com/users')
}

const newUsers: User[] = await fetchUsersFromAPI();
for (const newUser of newUsers) {
    if(newUser.bio != null) {
        uploadUserBio(newUser.bio);
    }
    // do stuff
}

Enter fullscreen mode Exit fullscreen mode

As we discussed in the previous post this implementation could blow up since we're not catching the promise rejection and even if it doesn't reject the newUsers array could be null.

Let's refactor this and wrap our fetchUsersFromAPI in a TaskEither. Fp-ts provides us some handy helper functions just for this task. One such function is tryCatchK in the TaskEither module.


// Converts a function returning a Promise to one returning a TaskEither

declare const tryCatchK: <E, A extends readonly unknown[], B>(
  f: (...a: A) => Promise<B>,
  onRejected: (reason: unknown) => E
) => (...a: A) => TaskEither<E, B>

const fetchUsersTE = tryCatchK(
  fetchUsersFromAPI,
  (reason: unknown) => String(reason)
)
// const fetchUsersTE: () => TaskEither<string, User[]>
Enter fullscreen mode Exit fullscreen mode

Rejoice! With this simple change we do not need to handle Promise rejection with clunky try catch blocks.

Remember a TaskEither<E, A> is just an alias for Task<Either<E,A>>. And we know that Task<A>: () => Promise<A> so TaskEither<E,A>: () => Promise<Either<E, A>> That is to say that our fetchUsersTE function is a function that returns another function that returns a Promise containing an Either. Again recall that the contract we signed by using Task ensures that the promise it returns will never reject. So we can safely 'unwrap' our promise (no try catch block needed) and get to the juicy Either within. Then returning to the previous code we can fold the Either and handle both Left and Right cases.

const usersTaskEither = fetchUsers();
const usersEither = await usersTaskEither(); 
// Either<string, Users[]> 
// The Task contract ensure this promise will never reject

fold(
  usersEither,
  (error: string) => `Something went wrong ${error}!`,
  (users: Users[]) => {
    for (const newUser of users) {
    if(newUser.bio != null) {
        uploadUserBio(newUser.bio);
      }
  }
})

Enter fullscreen mode Exit fullscreen mode
Final Notes and Next Steps

So there are some caveats. For one we need to be careful when we wrap promises in TaskEither. Referencing the signature for tryCatch below there are two things to consider. First, the function f should never throw an error since it won't be caught. Any error handling should be abstracted away inside this function. Second, we need to ensure we know when the Promise returned by f rejects. In our example using the Axios API it will reject for any error HTTP status codes (400-500+). This behavior might be desirable or not. For example it is often the case that we want any non 200 response to be considered an error and put in the Left of the Either. Axios provides a config option to ensure this behavior. But you should always be clear under what conditions the Promise will reject.

declare const tryCatchK: <E, A extends readonly unknown[], B>(
  f: (...a: A) => Promise<B>,
  onRejected: (reason: unknown) => E
) => (...a: A) => TaskEither<E, B>

Enter fullscreen mode Exit fullscreen mode

Finally, what can we actually do with this TaskEither? Is it just a temporary container to simplify Promises? In the beginning of this post I mentioned that it is a monad. While this term has specific mathematical meaning, for practical purposes we only need to know that this means it implements an interface comprised of a number of functions that allow us to work and manipulate TaskEither types.

For example, say I wanted to compute the length of the returned Users array. I could extract the value from the TaskEither by running the promise, folding the Either and finally accessing the length property on the array. This is a lot of work. Instead as a monad TaskEither implements a function called map. map is a function that takes a function from A to B and returns another function from TaskEither<E, A> to TaskEither<E, B>.

const map: <A, B>(f: (a: A) => B) => <E>(fa: TaskEither<E, A>) => TaskEither<E, B>

const getLength = map((users: User[]) => users.length);

const usersLengthTE = getLength(usersTE);

// const usersLengthTE: TE.TaskEither<string, number>

Enter fullscreen mode Exit fullscreen mode

Now we have a function that returns a promise that either returns an error string or the length of the users. All this without ever actually touching the Promise API. Hopefully the gears are starting to spin and you can appreciate how powerful this could be.

We've only scratched the surface and in future posts we'll start exploring all the functions implemented by the monad interface and why it is such a powerful concept. If you can't wait that long (I don't blame you) see below for more in-depth discussion.

Top comments (1)

Collapse
 
jderochervlk profile image
Josh Derocher-Vlk

Great write up! It's always good to see people talking about fp-ts!