Union types, pattern matching and exhaustiveness in today's JavaScript

Pattern matching and union types are, in my view, one of the concepts in programming which don't get the attention they deserve.

In this post, I'm going to show how they can help us write more declarative code which, oftentimes, is much easier to comprehend than purely imperative code.


Just by looking at declarative code we find out what's happening much faster

These ideas will change the way you think about designing the code of your next project and show how declarative code can make it much easier to maintain.

Union types are readily available to you in TypeScript

If you work with TypeScript today, you may already be familiar with union types.

They make it trivial to express a value could belong to one set of values or another,  e.g. a status could be any string or null. This guarantees we cannot assign a number or boolean to a variable of this type.

type Status = string | null;

Even better, we can restrict possible choices to a subset of a wider set of values, i.e. a status of could either be string "Done"  or "Pending" but no other strings should be allowed.

type Status = 'Pending' | 'Done';

This helps prevent mistakes such as typos, e.g. "Dnoe" vs "Done".

When used well, union types help provide extra type safety around the code by narrowing down what can be done without getting a warning or an error from the compiler, and thus, their use can limit the amount of human errors to minimum.

const status: Status = 'Dnoe'; // <- typo
// => Type '"Dnoe"' is not assignable to type 'Status'

const status: Status = 'Done';
// => OK

Union Types in JavaScript

Union Types are common amongst many functional programming languages. Some call them unions, sum types, variants, disjoint unions, tagged unions. They can be found in Haskell, PureScript, OCaml, Reason, ReScript, F# and Elm.

Some non-functional languages adopted them as well. Some treat union types as first-class citizen in the language's syntax (e.g. Rust, TypeScript). Native support in the language isn't the only way of adopting union types.

Functional programming enthusiasts in the JavaScript ecosystem already created some libraries readily available to install through npm.

Daggy is one of my favourites. It's small and very easy to use:

const daggy = require('daggy');

const Status = daggy.taggedSum('Status', {
  Pending: [],
  Done: [],
});

The above example does a couple of things:

  • It defines a union type, we name it Status
  • It defines two constructors, Pending and Done, accepting no values

Now, let's explore how we could work with the new data type. We could start with an array of jobs:

const jobs = [
  { id: 1, status: Status.Pending },
  { id: 2, status: Status.Done },
  { id: 3, status: Status.Dnoe }, // <- notice the typo
]

So far so good, except, there was no type checker or compiler to warn us about the wrong status used in the last example. We'll soon learn how to deal with these types of errors.

Evolving Design

Requirements change and so does code. At some point we decide to extend the Status type with one extra constructor, Failed, signalling failure.

const Status = daggy.taggedSum('Status', {
  Pending: [],
  Done: [],
+ Failed: ['reason'],
});

The Failed  variant will be ideal for these jobs with invalid statuses. It will accept a parameter, reason, indicating the cause of failure.

function normalize(job) {
  if (!Status.is(job.status)) {
    return { ...job, status: Status.Failed('Invalid job status') };
  }

  return job;
}

Given a job with a wrong status, e.g. Dnoe, we return a copy of that same job with a new status  Status.Failed('Invalid job status').

Now, are there any places where we didn't cater for the new variant?

Pattern Matching

A lot of functional programming languages, especially these in the ML-family of languages (e.g. OCaml, Haskell, and their derivatives), have a very useful tool in their toolbox, pattern matching.

Pattern matching, in a declarative way, allows to structurally match against every possible value or a combination of values in our data structure, and define a desired behaviour for each.

With proper type inference, some languages can quickly detect uncovered cases and warn the programmer about patterns which are not exhaustive, e.g. because a new constructor was introduced, or perhaps, the programmer didn't think about a possible edge case.

Pattern matching in Daggy

While we wait for the pattern matching proposal to possibly become a standard in JavaScript one day, its minimalistic variation has already been implemented in Daggy.

We pattern match in Daggy with cata method. Let's see how we can make use of it in a function which executes jobs.

function execute(job) {  
  return job.status.cata({
    Pending: () => {
      console.log(`Executing job#${job.id}..`);

      return { ...job, status: Status.Done };
    },
    Done: () => job,
  });
}

Daggy detects we missed the Failed constructor and screams at us:

"TypeError: Constructors given to cata didn't include: Failed"

From now on, as the design of our code evolves, we will be able to detect all non-exhaustive pattern matches and tackle them on a case-by-case basis:

function execute(job) {  
  return job.status.cata({
    Pending: () => {
      console.log(`Executing job#${job.id}..`);

      return { ...job, status: Status.Done };
    },
    Done: () => job,
+   Failed: (reason) => {
+     console.log(`Job#${job.id} failed with "${reason}".`);
+
+     return job;
+   },
  });
}

Complete code listing

A fully interactive version of the code from this post can be found under https://codesandbox.io/s/union-types-in-javascript-today-jrb52

const daggy = require('daggy');

const Status = daggy.taggedSum('Status', {
  Pending: [],
  Done: [],
  Failed: ['reason'],
});

const jobs = [
  { id: 1, status: Status.Pending },
  { id: 2, status: Status.Done },
  { id: 3, status: Status.Dnoe }, // <- notice the typo
];

function normalize(job) {
  if (!Status.is(job.status)) {
    return { ...job, status: Status.Failed('Invalid job status') };
  }

  return job;
}

function execute(job) {
  return job.status.cata({
    Pending: () => {
      console.log(`Executing job#${job.id}..`);

      return { ...job, status: Status.Done };
    },
    Done: () => job,
    Failed: (reason) => {
      console.log(`Job#${job.id} failed with "${reason}".`);
 
      return job;
    },
  });
}


jobs.map(normalize).forEach(execute);
// Executing job#1..
// Job#3 failed with "Invalid job status"

Where can it be useful?

Pattern matching and union types are brilliant ideas and yet, they don't get the attention they deserve.

When incorporated in our code, they help us:

  • make code easier to reason about by enforcing declarative-style
  • improve type-safety by warning about non-exhaustive pattern matches
  • change the way we approach coding by forcing us to think about code design first

Think about it, we started by defining the core language of our application. We did that in stages, the language evolved over time:

  1. a job
  2. a job with a status
  3. a job with a status which could either be done or pending
  4. ... and so on

At that stage, implementation details didn't matter that much, did they?

Potential application of union types

There is a lot of potential use cases for union types. They could be practically used in any project. I'm sharing some two interesting examples below.

1. React

A good use case for union types and pattern matching could be declaring possible states of a React component with Daggy, and, later on, using pattern matching to define what the component should render:

const daggy = require('daggy');

const State = daggy.taggedSum('State', {
  NotRequested: [],
  Loading: [],
  Loaded: ['data'],
  Error: ['error'],
});


const App = ({ state }) => {
  return state.cata({
    NotRequested: () => <NotRequested />,
    Loading: () => <Loading />,
    Loaded: (data) => <Loaded data={data} />,
    Error: (error) => <Error error={error} />,
  })
}


<App state={State.NotRequested} />
<App state={State.Loading} />
<App state={State.Loaded(['item 1', 'item 2'])} />
<App state={State.Error('5xx Internal Server Error')} />

2. Domain Specific Languages (DSLs)

Another use case might be designing a custom domain specific language which clearly defines the domain of the thing we're building. We can later on compose an application from these building blocks.

Here's a definition of an abstract syntax tree of a tiny language embedded into JavaScript, and an interpreter for it:

const daggy = require('daggy');

const Expression = daggy.taggedSum('Expression', {
  Number: ['value'],
  Add: ['left', 'right'],
  Subtract: ['left', 'right'],
});

const evaluate = (expression) => {
  return expression.cata({
    Number: (value) => value,
    Add: (left, right) => evaluate(left) + evaluate(right),
    Subtract: (left, right) => evaluate(left) - evaluate(right),
  });
};

const addExpression = 
  Expression.Add(
    Expression.Number(1), 
    Expression.Number(2)
  );
  
evaluate(addExpression); // => 3


What do you think about these ideas?

Please let me know your thoughts on twitter at @maciejsmolinski.

Leaving implementation details for later with unsafeCrashWith in PureScript

Sometimes, while designing our code, a new idea pops up and we start thinking how well would this thing we currently work on compose with the other bits of our application.

Focusing on implementation details might be a waste of time if we just want to test an idea out.

unsafeCrashWith

In these rare cases, the unsafeCrashWith :: forall a. String -> a function defined in the Partial.Unsafe module comes in handy.

Although, that's not what type safety encourages, it allows us to write function definitions such as this:

capitalize :: String -> String
capitalize = unsafeCrashWith "Not implemented yet"

Type checker does not complain. However, when called, this function would fail miserably:

    throw new Error(msg);
    ^

Error: Not implemented yet

Nevertheless, avoiding the failure is not our point here. We don't want to call this function just yet.

Example

To make a practical example, imagine we had a monolithic function from string to string program :: String -> String.

Could it be split into a composition of smaller functions? What would the types of these smaller functions be?

Let's see an example of turning program into a composition of two functions, capitalize and greet with no actual definitions.

module Main where

import Prelude
import Partial.Unsafe (unsafeCrashWith)

capitalize :: String -> String
capitalize = unsafeCrashWith "Not implemented yet"

greet :: String -> String
greet = unsafeCrashWith "Not implemented yet"

program :: String -> String
program = greet <<< capitalize

This snippet actually compiles and the program function is not monolithic anymore.

Once we're happy with the overall design of our code, all that is left is actually providing the implementation.

Pattern match multiple values without a Tuple in PureScript

To pattern match multiple values in PureScript, it's very tempting to wrap them with Tuple data constructor imported from Data.Tuple as follows:

case (Tuple path role) of
  (Tuple "/admin" Admin) -> Allow
  (Tuple "/admin" _) -> Deny
  _ -> Allow

That's not really necessary!

In fact, it can be easier done with no extra imports and a single comma:

case path, role of
  "/admin", Admin -> Allow
  "/admin", _ -> Deny
  _, _ -> Allow

"TS2307: Cannot find module" with custom modules-folder in yarn

Sometimes we may choose to change the default location of the node modules folder when we work with yarn to benefit from some form of caching (e.g. in CI).

It's as simple as writing --modules-folder /var/cache/node_modules to .yarnrc in the root of a project.

When node packages live outside the project repository, our build may start failing unable to find typings for libraries even if they are present in the package.json file.

TS2307: Cannot find module 'lodash'

To work around this issue, create a node_modules symlink pointing to the custom installation location defined in your .yarnrc file.

ln -s /var/cache/node_modules node_modules

The build should be working again!

Thoughts on Reliability

Not very long ago, I wanted to print a confirmation of the transaction from my bank account.  In fact, I downloaded one a week before. Couldn't be any simpler. This time however, the download button wouldn't listen.

How often does a similar thing happen to you?

Whether it's an online banking, grocery shopping or entertainment, it's inevitable — things break.

What if it happened to me? Empathy is key

The natural first reaction is frustration. Then comes understanding.

Someone might have been in a rush. Or perhaps under a pressure of delivering on time, or fixing a more important bug at a cost of breaking a small feature.

We all make mistakes, some have bigger impact, some smaller. When was the last time you've committed a faulty change on purpose?

I guess your answer is very likely to be never.

Seeking blame is not the answer. Seek understanding.

Could it be prevented? Awareness is important

When we are full of understanding and empathy, it's time to learn.

Could we make system more resilient to similar issues? Can we at least make them less likely to happen? Or at least lower the impact?

Not, if we don't know when they happen.

Be it passively or actively, monitoring the health of the systems we build is key.

Passive monitoring means someone has done the work for us and takes a notice of everything that happens in our system. We pay the price later — blocking some time to analyse the collected data, seeking for patterns and trying to make sense of them.

Does this error look familiar? Is this event important?

Very useful — but quite mundane, we lack the context.

On the other side we have active monitoring. We pay the price in the beginning — defining the set of capabilities we promise to deliver, deciding which of them are crucial to the business, and how are we going to measure if they are healthy. Then we measure.

This capability seems to be under-performing recently, do we know why?

Also useful — but takes an intention and some effort to work.

Implement both. One gives us clues while the other helps ask the right questions.

Could it recover faster? Recovery time matters

Visibility into failures is one thing. Time to recovery is another.

Take a look at the table below and try to answer the following question.

Does every failure require human intervention?

Good Even Better
Check health dashboard daily Receive notification when something goes wrong
Investigate an issue during the incident Follow a playbook from the last time similar thing happened
Follow the playbook Have the system heal itself
Learn about system performance during an outage, under stress Exercise and stress the system, in a controlled environment

While things on the left are very important, things on the right can make the experience even more delightful.

In summary

It's a process. Things will go wrong. Start with empathy and understanding. Help your team develop the necessary skills to stress the system in a controlled environment and monitor it — to discover the foreseen as well as unpredicted consequences. Finally, make it more resilient by helping the system recover by itself.

There are some tools, techniques and good practices which might come in handy, for example:

  • Defined Capabilities
  • Tolerance Thresholds
  • Monitoring / Health Checks
  • Alerts / Notifications
  • Playbooks
  • Self-Healing
  • Game Days

What are your thoughts on reliability — should we pay more attention to it?

Please let me know your thoughts!