Write Beautiful JS — with Union Types
--
A few months ago, I published an article titled Dealing with Remote Data in JS, in which I showed how we can use the concept of union types to manage the state of some data over the network. While that’s useful, I realized that it only touches on one way union types can help us.
Here, I hope to paint a better picture as to what union types are, and how leveraging this programming concept, common to many functional programming languages, can beautify your code…
So, what is a union type? To me it is best explained as a programmer-defined data type that can be represented by one of a set of distinct values (think: the type is the union of this set of values).
As a simple example, the Boolean
type in JavaScript can be considered a union type with the set of values True
and False
. We represent this union type like this:
type Boolean = True | False
Of course, that example doesn’t do much for us. Instead, suppose we’re creating a survey app, and need some way to store an answer from the user. The answer state may be “Yes”, “No”, or “Undecided”, which we may represent with the following union type:
type Answer = Yes | No | Undecided
What’s nice about this is that any value of type answer can only be one of those three values. If it were a different type (say, a String
), we wouldn’t have any such guarantee. At the same time, it’s more expressive than using a boolean, since we would have to give implicit meaning to true
, false
, and null
(to Yes
, No
, and Undecided
, respectively).
Types Within Types
So we have a nice Answer
type, but we can do better. For example, what if we want to let the user type out their response?
Well it just so happens that union types can “wrap” other values. This means we can store additional data inside of the types values we defined. Now if we want to allow someone to be more descriptive in their response (as opposed to just “yes” or “no”), and to allow them to decline to respond, we might modify our type as follows:
type Answer = Response String | Declined | Undecided
Now, our type can be in one of three states. An Undecided
, meaning the user hasn’t picked an answer, a Declined
, if they opt out of responding, or a Response String
, indicating that they have written a response, stored as a string.
Many functional programming languages make heavy use of this concept for replacing features common to other, more object-oriented languages, such as null references (Maybe
) and error throwing (Either
).
type Maybe a = Just a | Nothingtype Either e a = Left e | Right a
Create a Union Type — JavaScript
That’s very nice conceptually, but what does this look like in a practical sense? In our code, we need to be able to 1) construct values of our type and 2) match on a value of our type. If you haven’t heard the term “match” before, think of it as deciding what to do with a value of type Answer
, depending on whether it’s Undecided
, Declined
, or Response
(similar to a switch
).
First, we’ll tackle the issue of constructing. To do this, I’ll pretend we have a function called construct
, which will be implemented later. Here is how constructing could look in JavaScript:
const construct = (type, values) => {
/* construct type value */
}const Answer = {
Response: (response) => construct('Response', [response]),
Declined: construct('Declined', []),
Undecided: construct('Undecided', [])
}const answer = Answer.Response('sure thing!')
const declinedAnswer = Answer.Declined
You may have noticed that Response
is a function, whereas Declined
and Undecided
are not. This is because Response
needs extra information provided to it as it is constructed (the response string). The others do not, so they may as well be constant.
Now we need to be able to match on our value, since right now there’s no way to tell which of our type values it represents. For this we’ll need to finish defining our construct function:
const construct = (type, values) => ({
case: (cases) => cases[type].apply(null, values)
})
What’s going on here? This construct
function returns an object with a single property: case
. This case
property is a function that we use to unwrap our type by providing it an object containing all possible cases, all of which are functions that describe what we want to return in each case.
For example, here’s how we might use case
to get a String
from a value of our Answer
type:
let answer = Answer.Response('sure thing!')let answerString = answer.case({
Response: (response) => `I answered: ${response}`,
Declined: () => `I'd rather not say`,
Undecided: () => `I'm still thinking...`
})console.log(answerString) // prints "I answered: sure thing!"
The above will print "I answered: sure thing!"
as the result of the call to case
, since the type of answer
is Response('sure thing!')
. However, if answer
were to be set to Answer.Undecided
, the result would be "I'm still thinking..."
. Likewise if it were Answer.Declined
, we would have "I'd rather not say"
.
Note: creating union types this way has some disadvantages, so feel free to use a library instead of defining your own types. I’m a fan of the union-type module.
This is cool and all… But again, why are we doing this? The value of union types is that they make the intent of your data explicit. How else might we have described our simple Answer
situation above? Most likely as an object, like this:
let answer = {
response: "sure thing!", // null if undecided
declined: false
}
This might be fine at first, but it comes with some real problems. You see, this object can be in one of approximately four states:
- response:
null
, declined:false
(undecided) - response:
null
, declined:true
(declined) - response:
"some response"
, declined:false
(responded) - response:
"some response"
, declined:true
(huh???)
What’s wrong with that last option? There’s a response, but the user also declined to answer? That shouldn’t be possible, but it is given the way we defined our data. And, this doesn’t even account for the even more unusual cases, like what does it mean if declined is null
? Do we interpret that as false
, or does that mean this answer cannot be declined?
Union types avoid this problem entirely, by clearly defining all of the known categories our state can be in, and only allowing for exactly the data required for each category to be present (this is how we avoid the issue of a Declined
state containing a response string).
Not to mention that using an object, as in the case above, there’s a good chance that somewhere we’re going to forget to account for these edge cases. Whereas with a union type, we’re required to consider every state our value can be in. From this it’s easy to see how this pattern can make applications more robust.
A Practical Use Case — React
Let’s look at another practical example. A common pattern when developing an interface containing several steps is to have a step
counter that tracks which part of a given flow a user is on. What if instead, we used a union type to break up these steps?
const Step = {
ViewingCart: (items) => construct('ViewingCart', [items]),
ConfirmOrder: (details) => construct('ConfirmOrder', [details]),
ProcessingOrder: construct('ProcessingOrder', []),
SubmittedOrder: construct('SubmittedOrder', []),
CancelledOrder: (reason) => construct('CancelledOrder', [reason])
}
With this Step
type defined, we could imagine adding this into a React component as such:
Now, that’s not bad! Each step has a clearly defined type, containing the values it needs to render appropriately. No ternary expressions littering our JSX, and every part of the flow is perfectly clear, with access to exactly the data it needs. You could imagine that this would scale as more and more steps had to be added.
It’s important to note, union types are by no means specific to UI only. Much of this logic could be moved to a state management system (like Redux) as our mock application grows. If that were to happen, we would see our state management become equally as expressive and easy to reason about as our UI is now.
Final Thoughts
To wrap up, I’ll borrow this excerpt from Elm’s union-type documentation:
Many languages have trouble expressing data with weird shapes. They give you a small set of built-in types, and you have to represent everything with them. So you often find yourself using
null
or booleans or strings to encode details in a way that is quite error prone.
I find this to be absolutely true. When building anything non-trivial, you’re going to run into all kinds of cases where your data is in a “weird shape”, and doesn’t quite fit with the primitive tools provided by the language. A union type-like abstraction lets you represent these cases more naturally, so that you can focus on expressing your intent rather than fussing over your data.
On that note, I encourage you to go and give union types a try today!