The DEVLOGGLE

Javascript Futures and Result Monads Belong Together

By Jason Victor

Why we care about this

At Toggle, we're building an app to allow users to pause and resume streaming subscriptions with a single tap. At the moment the user taps the pause or resume button, a chain of complex actions is initiated in order to process the user's requested change, where the result of each step is an input to the following step.

We felt Node.js was a natural choice for wiring together these many asynchronous steps, since the async/await framework has made futures an idiomatic and fundamental part of Javascript. So, we modeled each step as a function that returns a Javascript future. And this type of thing can be modeled perfectly well using Javascript futures:

async function doWork() {
  // do some long running stuff
}

The thing is, each step in this chain can fail at random, which needs to be intelligently handled. This, again, is a perfectly natural thing in Node.js:

let result = await doWork().catch(e => null);

This code defaults the return value of "doWork" to null in the event of an exception. And, we can do more sophisticated handling by examining the exception "e" and returning different values based on that.

So what's the problem?

Depending on the return type of "doWork" in this example, it might be difficult for the next step in the processing chain to distinguish whether "result" is the return value of "doWork" or the catch clause, and it may want to process the two things differently. For example, imagine a function that resolves a user ID into a User entity object by querying a database. It may return null if it doesn't find the user -- in such a case, null is a valid return value. If we caught an exception and returned null as above, the subsequent processing steps wouldn't be able to tell whether the user simply doesn't exist or whether there was an error connecting to the database.

This becomes even more problematic when we want to chain together a number of these functions, a la:

let result = await doWork().catch(e => null).then(postProcessResult);

If we handle potential errors in "doWork" with a catch in this position, the return value of the catch will be passed into the post-processing step, creating the kind of ambiguity we are worried about.

Enter: the Result (a.k.a. Either) monad!

For this type of situation, we use the Result monad, and we don't think it gets enough love! The Result monad wraps an arbitrary type with a bit that indicates whether it represents an error or a result, and it implements its map and join functions accordingly. Typically, there are two cases for a Result monad: Left(exception) and Right(result).

So to update our above example, we could have something like:

async function doWork() {
  return await getFromDb().map(x => Right(x)).catch(e => Left(e)));
}

Instances of Right represent successes and instances of Left represent failures. And this is useful enough, since downstream code can now disambiguate the two.

But the Result is even slicker than that, since its map and join implementations encode the idea that we want to stop processing at the first error in a given chain. In other words, the implementations of map and join for Left are no-ops -- they don't do anything. Meanwhile, the implementations for Right actually apply the provided function. So that lets you do things like:

let result = await doWork();
return result.map(postProcessSuccess);

In this situation, if doWork returns a successful response, that response will be passed into a post-processing step and then return as a Right. If either doWork or the post-processing step returns an error, the error will be returned as a Left.

Node.js implementation of a Result (a.k.a. Either) monad

The following is the implementation we use in our codebase:

/**
* Left represents the failure case
*/
export class Left {
    constructor(val) {
        this._val = val;
    }
    map() {
        // Left is the sad path
        // so we do nothing
        return this;
    }
    join() {
        // On the sad path, we don't
        // do anything with join
        return this;
    }
    chain() {
        // Boring sad path,
        // do nothing.
        return this;
    }
    get() {
        return this._val;
    }
    isRight() {
        return false;
    }
    toString() {
        const str = this._val.toString();
        return `Left(${str})`;
    }
}
/**
* Right represents the success case
*/
export class Right {
    constructor(val) {
        this._val = val;
    }
    map(fn) {
        return new Right(
            fn(this._val)
        );
    }
    join() {
        if ((this._val instanceof Left)
            || (this._val instanceof Right)) {
            return this._val;
        }
        return this;
    }
    isRight() {
        return true;
    }
    chain(fn) {
        return fn(this._val);
    }
    get() {
        return this._val;
    }
    toString() {
        const str = this._val.toString();
        return `Right(${str})`;
    }
}

export function left(x) {
    return new Left(x);
}

export function right(x) {
    return new Right(x);
}

(Please feel free to use it however you want under the terms of the Modified BSD License.)

Hey there!

If you love streaming but you're tired of being overcharged for shows you don't watch, consider heading over to our website and signing up for our app. It's free and we save users an average of $165 per year!

Let's go