loading...
Cover image for Human Conditionals: Pattern Matching in JavaScript

Human Conditionals: Pattern Matching in JavaScript

cmstead profile image Chris Stead ・4 min read

You have been working on a new project for the past couple of months. As work is presented to the users, they ask for ever more complicated conditions around how the application interacts and displays data they enter. The conditional load around the user data gets deeper and harder to read, and all you're really doing is just verifying different shapes of the same data.

Eventually you start to feel it in the back of your brain. You're burning out on the project. You want to look at something different. All you want to do is purge all of that conditional context from your brain and do something new.

I've been on those same projects. Often conditionals end up looking like a long list of deep object references and the outcome is just selecting a data transformation action. The code either gets abstracted to the point that you no longer know what each condition represents, or the cognitive load gets so great you end up reading code for minutes to make small changes.

The Problem

Let's suppose we have a user object, and it contains a number of properties including givenName, familyName, phoneNumber which is an object containing a locale property, and address which is also an object containing a postalCode property. If we wanted to make sure that each of these properties were constructed correctly, each resulting in a string, we could write code like this:

if (
    typeof user?.givenName === "string" &&
    typeof user?.familyName === "string" &&
    typeof user?.phoneNumber?.locale === "string" &&
    typeof user?.address?.postalCode === "string"
) {
    // do something
} else {
    throw new Error("Missing user information");
}
Enter fullscreen mode Exit fullscreen mode

This is supposing that we can use the conditional chaining operator. Without it, we end up with something longer, like this:

if (
    typeof user === 'object' &&
    user !== null &&
    typeof user.givenName === 'string' &&
    typeof user.familyName === 'string' &&
    typeof user.phoneNumber === 'object' &&
    user.phoneNumber !== null &&
    typeof user.phoneNumber.locale === 'string' &&
    typeof user.address=== 'object' &&
    user.address!== null &&
    typeof user.address.postalCode === 'string'
) {
    // do something
} else {
    throw new Error("Missing data blob information");
}
Enter fullscreen mode Exit fullscreen mode

I affectionately refer to this as the wall-of-text conditional, and it's a lot to read. Your eyes probably glazed over just looking at it. Mine did, and I wrote it.

Not only is this hard to read, there are so many comparisons it's easy to make a mistake. We all lose track of what we're doing from time to time, and I've single-handedly elevated fat-fingering keys to an art form.

Data-rich applications tend to be littered with this kind of conditional code. None of it can really be reused because each condition is unique in some way. As this kind of hard-to-read conditional code grows, software development slows. In time the application becomes "that application" and people dread working on it at all.

Enter Pattern Matching

Pattern matching is a conditional approach that is common to functional programming languages and is often used to solve problems like this when working in Scala, Elixir, Elm, and others. JavaScript doesn't have standard pattern matching, yet, but we can still use a library to solve our data shape problem.

Pattern matching is designed to express solutions in a way that is closer to how people think. Instead of an imperative approach, pattern matching allows you to convey meaning through data shapes, and human intent. This expressiveness allows us to solve our problem in a more human-centric way:

const { match, types: { STRING }} = matchlight;
const expectedUserData = {
    givenName: STRING,
    familyName: STRING,
    phoneNumber: { locale: STRING },
    address: { postalCode: STRING }
};

match(user, function (onCase, onDefault) {
    onCase(expectedUserData, (user) => {
        // doSomething
    });
    onDefault(() => {
        throw new Error("Missing user information");
    });
});
Enter fullscreen mode Exit fullscreen mode

This code clearly asserts what the developer cares about, and eliminates the need for intermediate testing of different values. We can glance at the user data object sketch and know what to expect of the object we are planning to interact with. If the user data object expectations change, it becomes a simple add, remove, or update of properties in the sketch.

Patterns Aren't Just For Big Objects

The previous example was intentionally dramatic, but pattern matching isn't just for large objects with lots of required properties. We can solve smaller problems as well. Fibonacci is a well-known problem, and it's popular as an interview question, so it's great problem to use for looking at something new.

const { match, types: { NUMBER }} = matchlight;

function fibonacci(n) {
    return match(n, function(onCase, onDefault) {
        onCase(0, () => 1);
        onCase(1, () => 1);
        onCase(NUMBER,
            (n) => fibonacci(n - 1) + fibonacci(n - 2));
        onDefault(() => {
            throw new Error('Fibonacci can only accept numbers.');
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

Let's unpack our implementation.

The first thing you likely noticed is, there is a single return at the top of the function. Pattern matching introduces an expression syntax which saves us from needing early-exit conditions in our code. In fact, this entire function is described with just a single match expression!

Next, you'll note this is structured similarly to a switch statement. The structure allows us to read our conditions more like English. Our case statements express intent in a way that is constructed for the reader. The benefit we get, beyond a switch, is we can provide rich representations of our cases. This provides deep context at a glance.

We can see that, given the case of 0, we return 1. The same is true for the case where n is 1. Given any other number, we do our recursion. All of these cases are called out clearly with context provided in-line.

Additionally, we are no longer tied to common conditionals, so adding error handling is simply another case. Pattern matching can actually make it easier to communicate information back to your user!

What Else?

This is just the beginning of the work you can do with pattern matching. It can be combined with destructuring, complex logic, and even your own predicate functions to clean up and simplify your programs.

Pattern matching alone might not slay the "dreaded legacy program" dragon, but it can help. By using pattern matching to convey intent over brute-forcing an imperative solution, you can make your programs a little more human, and provide better context clues to your reader.

(Pattern matching examples are using the Matchlight library)

Discussion

pic
Editor guide