DEV Community

Cover image for Implementing a Maybe Pattern using a TypeScript Type Guard
Josh Wulf
Josh Wulf

Posted on • Edited on

Implementing a Maybe Pattern using a TypeScript Type Guard

You’re sat at a restaurant, and you order a beer. The waiter disappears into the kitchen, and returns a couple of minutes later, empty-handed. “I’m sorry,” he says. “I could not get your beer.

Now here is the six-million dollar question: is the restaurant out of the beer that you ordered (business logic), or is the restaurant kitchen on fire (infrastructure failure)?

And how do you model those two distinct failure states in your API? (Credit to Kory Nunn for the restaurant metaphor).

Recently, I was working on a database library in TypeScript. It provides a strongly-typed abstraction over stored procedures to allow the application to declaratively interact with the data model without leaking the implementation details.

In this way we can implement caching, and even switch out the database without any change to the application logic or unit tests — which are easy to write because we can mock the data layer interface.

I took inspiration for this approach from a workshop I attended with Mark Seeman at NDC Sydney this year. In that workshop, we looked at modelling multiple return states using monoids.

Success and Failure: not a Binary State

A database call to retrieve a record may succeed or fail. It may fail to retrieve anything due to a fatal exception — like a database going away. If the database call succeeds, however, we have two states that we need to represent: either “here is the record”, or “no record was found”. So we have three states that we need to represent: fatal exception, success with data, and success with no data.

These can be thought of as a failure mode (infrastructure failure), a success mode, and a mixed success/failure mode (infrastructure success / application data failure).

Kory Nunn describes the issue of the nuanced states like this: I go to a restaurant and order a beer. The waiter goes to the kitchen and then comes back and says: “I’m sorry, I couldn’t get you a beer.”

The issue here is: Why couldn’t you get me a beer? Are you out of beer (no record found)? Or is the kitchen on fire (database disappeared)?

This is a collapse between the two possible failure modes.

Kory has developed the Righto library for lazily evaluating asynchronous operations and modelling these two failure states.

For this library, however, we took a different approach, taking inspiration from the Maybe monad.

The Kitchen Is Definitely On Fire!

First, we model database failures as exceptions. If the kitchen is on fire, we throw. This will interrupt the application logic flow, and it is the responsibility of the caller to handle this. If the kitchen is on fire, stop the action.

If the kitchen is not on fire, however, we need to model two states: we got a record back (“here is your beer!”), or there was no matching record found (“sorry, we’re all out of Fosters lager. Actually, we don’t stock it in Australia.”).

A simple way to do this is to test the return from the database call to see if it is null. However, this cannot be checked statically, as it relies on the runtime return value. We also can’t enforce that a developer handle this case (with one caveat that I will address).

The Kitchen Isn’t On Fire — This May Be Your Beer, Or Not…

So we implement the return value as a MaybeRecord — maybe you got a record back, and maybe not.

We can use TypeScript’s type guards to enforce handling both cases. There are TypeScript Maybe Monad implementations, like TS-Monad. However, these burden developers with a verbose syntax that obscures the code.

As an example, here is some idiomatic JavaScript where the developer tests for a value before attempting to use it:

if (age) {
    var busPass = getBusPass(age); // might be null or undefined
    if (busPass) {
        canRideForFree = busPass.isValidForRoute('Weston');
    }
}
Enter fullscreen mode Exit fullscreen mode

Here’s my caveat from earlier: if you use strict null checking and define the return signature of getBussPass() as BussPass? or BussPass | undefined, then you will get an error that busPass may be undefined. You can use a type guard on this, but your intent is not explicit.

With the TS Monad, you write this as:

var canRideForFree = user.getAge() 
    .bind(age => getBusPass(age))   
    .caseOf({
        just: busPass => busPass.isValidForRoute('Weston'),
        nothing: () => false
    });
Enter fullscreen mode Exit fullscreen mode

It’s explicit, but verbose — and not idiomatic JavaScript or TypeScript. Not ideal.

Using the TypeScript Type Guard to Type “Maybe”

We used TypeScript’s literal types and type guards to build a Maybe pattern. It is not a monad — it is not composable — but it does provide us with null safety and forces the application developer to think about the logic around the two success states.

We defined the signature of our method as MaybeRecord where:

type MaybeRecord = Record | RecordNotFound
Enter fullscreen mode Exit fullscreen mode

We then define the two types like this:

class RecordNotFound {
    found: false = false;
}

class Record {
    found: true = true;
    name: string;
    age: number;
    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}
Enter fullscreen mode Exit fullscreen mode

In the method implementation we return either new RecordNotFound() or new Record(res.name, res.age).

Then in the consuming code, the return value has only the intersection property found. So if we make a small test implementation:

function returnMaybeRecord(exists: boolean): MaybeRecord {
    if (exists) {
        return new Record('Joe Bloggs', 42);
    } else {
        return new RecordNotFound();
    }
}
Enter fullscreen mode Exit fullscreen mode

We can see what happens in VSCode when we do this:

const maybeExists = Math.random() > 0.5;
const maybeRecord = returnMaybeRecord(maybeExists);
Enter fullscreen mode Exit fullscreen mode

The only property that reliably exists on the returned value is found. If we now write a type guard:

if (maybeRecord.found) {

}
Enter fullscreen mode Exit fullscreen mode

And now, inside the type guard we have a Record:

This enforces in the application that the maybeRecord must be explicitly checked before it can be used.

Top comments (0)