hazybits

home09 November 2021

Better promises (with error handling) in TypeScript? Yes!

TypeScriptFunctional Programmingfp-tsTaskEither

Error handling in Promises in TypeScript is not that much easier than with JavaScript. Let's see how to use TaskEither from fp-ts to make it substantially better.

Recap

In the previous post, we covered how we can use Task and Either from fp-ts library to make expected errors explicit in an asynchronous code.

Using Task<Either<E, A>> instead of Promise<A> comes with benefits of:

  • a lazy execution
  • explicit errors (visible in types)
  • separation of asynchronous context (Task) and possibly-failing computation context (Either)
  • great composition capabilities

Until now, we didn't touch the last point. And we'll fix that in just a while.

Task & Either = TaskEither

The great fp-ts library comes with a TaskEither<E, A> interface which:

represents an asynchronous computation that either yields a value of type A or fails yielding an error of type E

(read more here)

It may sound scary but it's actually an Either<E, A> in a Task<_> context so you can treat it just as a convenient alias:

type TaskEither<E, A> = Task<Either<E,A>>

Using TaskEither

The main benefit of using TaskEithers is their composition capabilities, or superpowers, I should say.

TaskEither allows composing your tasks in sooo many ways.

Without further ado, we jump straight to the examples. To make things succinct, we'll define:

type E = string;
type SimpleTaskEither<A> = TaskEither<string, A>

and for traceability we'll use:

import * as T from "fp-ts/lib/Task";
const delayedTask =
(ms: number, taskId: string) =>
<A>(a: A): T.Task<A> => {
return () => {
console.log(`Starting task '${taskId}'`);
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Finished task '${taskId}'`);
resolve(a);
}, ms);
});
};
};

Which will allow us to create tasks that will be resolved only after ms milliseconds has passed with additional console.logs at the start and finish of the task so that we can easily trace the execution.

We start with:

import * as TE from "fp-ts/lib/TaskEither";
const getNumberTE: SimpleTaskEither<number> = TE.fromTask(
delayedTask(100, "task-number")(1)
);
const getBooleanTE: SimpleTaskEither<boolean> = TE.fromTask(
delayedTask(50, "task-boolean")(true)
);

which creates two data-independent computations that are not yet executing (because they're lazy). When executed, they will return either (Either) a value of type (respectively) number and boolean or an error of type string (E).

And now the composability game truly begins!

How to get a value from both TaskEithers?

We'd like to start both tasks and then compute something based on a result of both of them (a number and a boolean). In Promise-terms, that would be a Promise.all() call. But we're also handling explicit errors in the example so we need something more powerful. And you're probably not surprised we have something we can use.

But you may be surprised we can actually use (at least) two things! We can either execute both of the tasks in parallel (Par) or in sequence (Seq):

import * as A from "fp-ts/lib/Apply";
import * as TE from "fp-ts/lib/TaskEither"
const sequenceArrPar = A.sequenceT(TE.ApplicativePar)
const sequenceArrSeq = A.sequenceT(TE.ApplicativeSeq)
const resultPar: SimpleTaskEither<[number, boolean]>
= sequenceArrPar(getNumberTE, getBooleanTE);
const resultSeq: SimpleTaskEither<[number, boolean]>
= sequenceArrSeq(getNumberTE, getBooleanTE);

And when executed:

import * as E from "fp-ts/lib/Either";
const eitherPar: E.Either<string, [number, boolean]> =
await resultPar();
console.log("---");
const eitherSeq: E.Either<string, [number, boolean]> =
await resultSeq();

we'll get:

Starting task 'task-boolean'
Starting task 'task-number'
Finished task 'task-boolean'
Finished task 'task-number'
---
Starting task 'task-number'
Finished task 'task-number'
Starting task 'task-boolean'
Finished task 'task-boolean'

Wow, that's something.

We were able to change the execution flow by flipping just one variable (TE.ApplicativePar and TE.ApplicativeSeq). And we still preserve the error (the first one) if one occurs.

How to get both values with a fallback

Sometimes, we just have a good fallback we can use if things go south.

Let's say that in our example, we can use [42, false] as a good fallback in case any of tasks returns an error (left from Either). Reusing the example from previous point, we have:

import * as F from "fp-ts/lib/function"
const eitherPar: E.Either<string, [number, boolean]> =
await resultPar();
const result: [number, boolean] = F.pipe(
eitherPar,
E.getOrElse(() => [42, false])
)

Et voilà! We get both values from our tasks but in case any error occurs, we use the a [42, false] fallback.

TaskEither FTW!

TaskEither is simply great at encoding asynchronous computations that may fail with a known and expected error. So far, we've only saw the tip of the iceberg here. There are so many more possibilities can use - we'll cover them in the next parts so stay tuned.

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