Do-notation for Either in TypeScript

Artiom Poberejnyi
3 min readJan 18, 2021

This article assumes that you are familiar with Either data type, which allows modeling your error cases in a type-safe manner.

Problem

Let’s say you have several functions that return an Either…

function getFirstName(): Either<string, string> {
return Right("John");
}
function getLastName(): Either<string, string> {
return Right("Doe");
}
function getNamePrefix(): Either<string, string> {
return Right("Mr.");
}

… and you need to compose them together while keeping results of intermediate calculations. You might end up with code that looks something like this:

function getFormattedName() {
return getNamePrefix().flatMap((prefix) =>
getFirstName().flatMap((firstName) =>
getLastName().map((lastName) => {
return prefix + " " + firstName + " " + lastName;
})
)
);
}

Even this simple example has three nested callbacks. In a real application, this might quickly become difficult to work with.

A code like this can be simplified with the Applicative pattern or by further decomposition, yet these are not always convenient or possible. The optimal solution to avoid the callback hell would be using something similar to the do notation, as in the Haskell language.

Solution

Before explaining the details, let’s see how working with Either might look like in TypeScript:

function getFormattedName() {
return doEither((run) => {
// note that type of `getNamePrefix()` is Either<string, string>
const prefix: string = run(getNamePrefix());
const firstName = run(getFirstName());
const lastName = run(getLastName());
return Right(prefix + " " + firstName + " " + lastName);
});
}

Fells much more like idiomatic JavaScript, isn’t it?

The combination of doEither and run behave similarly to async/await for Promises. With doEither we declare that we run the function in the Either context (similar to async). At the same time, run allows us to extract the values from this context (similar to await).

Whenever an argument passed to the run function is a Left, the computation will short-circuit and return the Left value. However, if we receive a value wrapped in a Right, this value will be returned from the run function. The run function will type-check the Left side of whatever we pass to it.

Now let’s assume that our sample functions return a Promise with an Either. In my practice, this seems to be a much more common case. With the doEitherAsync, the code still looks very readable:

function getFormattedNameSequential() {
return doEitherAsync(async run => {
const prefix = run(await getNamePrefix());
const firstName = run(await getFirstName());
const lastName = run(await getLastName());
return Right(prefix + " " + firstName + " " + lastName)
})
}

This also works well with Promise.all:

function getFormattedNameConcurrent() {
return doEitherAsync(async (run) => {
const [prefix, firstName, lastName] = Promise.all([
getNamePrefix().then(run),
getFirstName().then(run),
getLastName().then(run),
]);
return Right(prefix + " " + firstName + " " + lastName);
});
}

Explanation

The example uses the monet.js package, yet the technique described here applies to any Either implementation you prefer.

The main idea is to leverage the JavaScript exceptions mechanism to short-circuit the callback execution as soon as Left is encountered. If an either is Right, we extract the value and return it.

Full implementation:

function doEither<L, R>(
callback: (run: <K>(either: Either<L, K>) => K) => Either<L, R>
): Either<L, R> {
try {
return callback(runEither);
} catch (exception) {
if (exception instanceof RunEitherError) {
return Left(exception.eitherError);
}
throw exception;
}
}
function runEither<L, R>(either: Either<L, R>) {
if (either.isRight()) {
return either.right();
}
throw new RunEitherError(either.left());
}
class RunEitherError<L> extends Error {
constructor(readonly eitherError: L) {
super((eitherError as any).message || String(eitherError));
}
}

A version for an Either in wrapped in Promise is very similar.

Why not generators?

Using generators for unwrapping Either (or any other monad) is fine for JavaScript.

However, as for TypeScript 4.0.2, all yielded values of a generated must be of the same type. At least I’m not aware of any technique to bypass that. This means that the following snippet would be impossible to properly type with generators (note different types of incremented and formatted):

function performExample(num: number): Either<string, string> {
return doEither((run) => {
const incremented: number = run(increment(num));
const formatted: string = run(format(incremented));
return Right(formatted);
});
}

--

--