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.

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
andDone
, 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:
- a
job
- a
job
with astatus
- a
job
with astatus
which could either bedone
orpending
- ... 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.