hazybits

home20 October 2021

Handling errors in Promises with TypeScript & fp-ts

TypeScriptFunctional Programmingfp-ts

Have you ever wondered if handling errors in Promises with TypeScript could be better? I think it can and this is what we cover in this post.

Why do you need a better Promise?

Consider this code sample:

function calculateRisk(): Promise<number> {
if (Math.random() < 0.2) {
return Promise.reject("error");
} else {
return Promise.resolve(0.5);
}
}
const p: number = await calculateRisk();
export {}

This program works 80% of a time and fails for the remaining 20% (the error happens non deterministically but it's not relevant in our case). Caller of calculateRisk function doesn't know - judging by function's signature - anything about possible errors being thrown.

The problem with unexpected errors is even more obvious if calculateRisk is not just an extracted part of our code but a functionality we import, either from other module or some library.

import { calculateRisk } from "somewhere";
const p: number = await calculateRisk();
export {}

In this scenario, even with TypeScript, we are not safe. Any errors that might occur are invisible in type signatures.

Not good.

How can we improve?

The way typed functional programming solves issues is by using more types. We'll use this approach here as well.

Currently, we have an interface:

declare function calculateRisk(): Promise<number> {

But we would like to also represent that some error might be present so we can do something like this:

declare function calculateRisk(): Promise<number | string> {

This is... not ideal, though.

We completely mixed a desired ("success") value returned from calculateRisk with possible failures. What is more, it's also not very ergonomic in TypeScript to split an arbitrary A | B (union of A and B) into A and B branches.

So we probably should be able to do better.

And we, in fact, can.

import type { Either } from "fp-ts/lib/Either";
declare function betterCalculateRisk(): Promise<Either<string, number>>

Here, we're using an Either from fp-ts. Either is a construct similar to ordinary TypeScript union but not exactly the same. One important difference is that Either is (right-)biased. When for TypeScript A | B === B | A for any A and B, for Eithers, it's not the case.

Either has a "left" and "right" channel that we'll be using (respecively) for "error" and "success" branches.

This way, we can think of a Promise as purely an async construct, without worrying about failures that might be present in Promise.reject channel.

And for failures, we're using Either - a specialized machinery that has everything we need to handle operations that might produce an error.

If it all sounds too scary at the moment, don't worry. We'll clear the things up with code samples.

So how does it look for a caller right now?

import { betterCalculateRisk } from "somewhere";
const p: Either<string, number> = await betterCalculateRisk();
console.log(p);
export {}

So much better. Why?

A caller is now aware that betterCalculateRisk may fail. Because it's aware, it can decide what's the best action in their scenario. A non-exhaustive list of possible best actions might be:

  • use some fallback value
  • retry until succeeded
  • retry n times
  • retry n times and switch to alternative api calculating risks that never fails
  • fetch some local database for entries from a day before
  • panic and exit a whole program

Of course, all of these actions were also possible in the initial example but a caller was never aware it should/can make the decision. Moreover, a fact that this decision was made was never reflected after an error was handled:

import { Either, getOrElse } from "fp-ts/lib/Either";
import { pipe } from "fp-ts/lib/function";
const p1: Promise<number> = calculateRisk().catch(error => {
return Promise.resolve(1);
});
const p2: Promise<number> = betterCalculateRisk().then(result => {
return pipe(
result,
getOrElse(() => 1)
)
});

Types for p1 and p2 are the same but don't forget that with second approach we treat Promise as async context/wrapper that cannot fail. To make this more obvious, fp-ts comes with a Task

const p2_task: Task<number> = () => betterCalculateRisk().then(result => {
return pipe(
result,
getOrElse(() => 1)
)
});

that's a simple wrapper for a promise, hidden behind a thunk (to makes it lazy):

Task<A> represents an asynchronous computation
that yields a value of type `A`
and **never fails**.
interface Task<A> {
(): Promise<A>
}

This way, we separated an async context (Promise) from an actual domain context (returned value of risk) that now includes a possiblity of failure.

Great!

Should you always use Eithers?

No.

Firstly, if you don't have a good error, you can use Option which has very similar semantics and powers to Either but doesn't represent the exact error and only models if the value is or isn't there. Option may also be handy if the error doesn't make any sense for the caller (like some library internals crashed).

Secondly, there are some errors that really are true exceptions. For them, it's more convenient not to include them in type signatures (and return values as a consequence). It puts you at risk of unexpected failures but if you can handle this risk (ie. maybe catching all the "this-should-never-happen" errors somewhere higher in the app), it might be a better approach. Sometimes it's just worth to trade higher ergonomics for lower safety - but you need to judge yourself for your cases.

Can we do better?

Surprisingly, yes! :D But that... that we'll cover that in a separate post.

Thank you for your time.

I hope you enjoyed the post 😊.

If you'd like to discuss something in more detail or simply let me know what do you think, connect with me on Twitter or reach out via email.


Until the next one,
@catchergeese




Read more