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.