TypeScript Enum's vs Discriminated Unions
Jesse Warden
Posted on December 12, 2022
In this article I wanted to compare and contrast Enum‘s and Discriminated Unions in TypeScript and compare them to each other. In my Object Oriented Programming days, we’d occasionally to never use Enums, whether they were native to the language or emulated. Nowadays, in my Functional Programming groove, I use Discriminated Unions all the time, whether native to the language or emulated. TypeScript is interesting in that it supports both natively.
Update: I have a video version of this article.
I’ve recently been ripped from doing Functional Programming in Elm on the front-end and ReScript on the back-end in a Serverless Environment into Object Oriented Programming using Angular & TypeScript on the front-end and TypeScript on the back-end in a server full environment. It’s been quite a shock to go back to what I moved on from 7 years ago. Thankfully TypeScript has started to incorporate some Functional Programming constructs so I can keep my sanity (or at least try).
By the end of this post, you'll see why Unions are superior to Enums despite extra typing, and how they can help you in server and UI development.
Why Even Care?
Enum’s and Discriminated Unions are for a specific data modelling type: the Or. There are basically 3 types we programmers use to model data:
-
Atomics: a single data type (e.g.
var firstName = "Jesse"
) “This variable equals this.” -
Ands: combining 2 types like Object or Class (e.g.
{ name: "Albus", age: 6 }
) “These 2 things together are my variable.” "A dog has a name AND an age." -
Ors: allowing 1 of a set of options (e.g.
Breed = Sheltie | Lab | Husky
) “My value is 1 of these 3 options”. "The Breed is a Sheltie OR a Lab OR a Husky."
Using these 3 options, we can model our program. In the case of name, it’s a string because names can be anything… and so can strings. Age, however, is a number, and while numbers are infinite, we’ll be dealing with something along the lines of 0 to 12. For Breed, we know all the breeds we’re handling. However, our program can only handle 3 of the 360+ breeds at first, so we’ll just use those 3. The proper data type is an Or; the breed of a dog is ONLY 1 breed; it can’t be both a Lab and a Husky at the same time; it’s either a Lab OR a Husky.
Before Enum
Back in my ActionScript days, we didn’t have Enum natively in the language, nor was it in JavaScript. We’d typically use a set of constants denoted by their all uppercase spelling:
var SHELTIE = 0
var LAB = 1
var HUSKY = 2
if(dog.breed === SHELTIE) {
...
Once OOP started to influence our designs, we started attaching those constants to Objects:
var Breed = {
Sheltie: 0,
Lab: 1,
Husky: 2
}
Once we got native class supported, we started using static vars with helper methods, usually on a Singleton:
class Breed {
static Sheltie = 0
static Lab = 1
static Husky = 2
#inst
constructor() {
throw new Error("You cannot instantiate this class, use getInstance instead.")
}
function getInstance() {
if(this.#inst) {
return this.#inst
}
this.#inst = new Breed()
return this.#inst
}
if(dog.breed === Breed.getInstance().Sheltie) {
...
Once we got the const
keyword, we started using that instead of var
or let
.
The switch Problem
The challenge, however, was always “forgetting” all the ones you had. Some people would create helper functions or methods, but the typical scenario was, you’d often want to check all of those possibilities. I say “all the possibilities”, but the great thing about defining these emulated Enums is we were saying “It can be only one of these possibilities”. We’d typically check that via a switch statement:
switch(dog.breed) {
case Sheltie:
...
case Lab:
...
The problem with the above code is 2 things. First, we’re forgetting one; Husky. That always happened as the code base grew or changed. Writing tests for that didn’t fix it because you had to remember to add to the test, and if you had remembered, you wouldn’t had forgotten it in the code in the first place.
Second, there wasn’t any default to catch in case you forgot, or someone ninja added a new Breed. That was super problematic because ActionScript/JavaScript are dynamic languages, and there is no runtime enforcement here, nor a compiler to help you beforehand, just a bunch of unit tests going “We green, Corbin Dallas!”
The whole point of an Or is to answer “Is it this or that?”. Not is it this or that or OMG WHAT IS THIS, THROW! Not, “it’s this or that or… dude, what was that thing… did you know about that, I sure didn’t?”
Enter Typed Enum
Once we got typed Enum’s, our compiler started helping us. If we forgot one in the switch, she’d let us know. TypeScript, assuming you’ve got strictNullChecks enabled, will do the same.
enum Breed {
Sheltie,
Lab,
Husky
}
type Dog = {
name: string,
age: number,
breed: Breed
}
const printDog = (dog:Dog):string => {
switch(dog.breed) {
case Sheltie:
return `${dog.name} the sheltie, super sensitive.`
case Lab:
return `${dog.name} the lab, loves fetch.`
}
}
Because the switch
is missing Husky
, the compiler will give you an error:
Function lacks ending return statement and return type does not include 'undefined'.
Horrible compiler error, I know. Another way to read that is “Your function says it returns string, not string OR undefined”. Since you are missing Husky, the switch statement falls through. Since there is no default
at the bottom, it just assumes the function is returning undefined
. You can’t do that because the function ONLY returns string. You have 2 choices; fix it by adding the missing enum, have the function either have a default that returns a string, or below the switch statement return the string… or change the function to return a Discriminated Union; a string
OR undefined
. That last one is horrible, though, because it defeats the purpose of using Enums and Discriminated Unions to ensure our code handles only the Ors we’ve specified.
While the compiler error is rife with implementation details, it at least gives you a hint you missed the Enum Husky.
Creating Discriminated Unions Without Defining Them
You’ll notice the TypeScript docs immediately start using Discriminated Unions as parameter type definitions or function return value definitions. I think it’s better to compare them to Enum’s first, but I guess they were trying to show how easy it is to use them without you having to define anything beforehand. So we’ll go with it. The padLeft
function, instead of the padding argument being of type any
:
function padLeft(value: string, padding: any) {
They instead improve it; “padding isn’t anything, it’s technically a string OR a number”. I say “improve” here, but really they’re narrowing the types. All types are thought of as narrowing our program’s inputs and outputs, sure, but Enums and Discriminated Unions in particular narrow to “only a set of these things”. So the docs narrow padding’s type from any’s anything to only string or number:
function padLeft(value: string, padding: string | number) {
Notice we didn’t have to define the Discriminated Union to use it; we just put a pipe in there between string
and number
. Same goes for return values. Now, you can define it if you want to:
type PaddingType = string | number
function padLeft(value: string, padding: PaddingType) {
Can we do this with Enum’s? Let’s try creating a function that will convert our Enum’s to strings:
enum Breed {
Sheltie,
Lab,
Husky
}
const breedToString = (breed:Breed):string => {
switch(breed) {
case Breed.Sheltie:
return 'sheltie'
case Breed.Lab:
return 'lab'
case Breed.Husky:
return 'husky'
}
}
Now if you misspell it, or if your function forgets one of the Enum values, the compiler will yell at you in a good way. Notice the key difference, though? We had to define the Enum first to use it. Discriminated Unions can create one on the fly if you already have types you want to bring together in an Or, like we did with string
and number
above.
Discriminated Unions as Type Gatherers, Not Just Values
Also notice, though, we’re using the primitives as the type. You can’t go:
enum PaddingType {
string,
number
}
Since that’s defining the words string and number as an Enum value, not a type. That’s another huge difference; Discriminated Unions unify a type, and can use primitive types to do that, not just your own. Enum’s are typically a group of number or string values.
Defining Discriminated Unions as Or Types
Let’s use them like we use Enum’s. We’ll copy our Breed example, except use a Discriminated Union instead. This’ll show how they work just like Enum’s do in regards to Or like data:
type Breed
= 'Sheltie'
| 'Lab'
| 'Husky'
Notice unlike Enum, we have to use quotes as a string. Now our switch:
const printDog = (dog:Dog):string => {
switch(dog.breed) {
case 'Sheltie':
return `${dog.name} the sheltie, super sensitive.`
case 'Lab':
return `${dog.name} the lab, loves fetch.`
case 'Husky':
return `${dog.name} the husky, loves sitting on snow.`
}
}
Like Enum’s, if we forget one, or misspell it, the compiler will tell us with a compilation error.
Discriminated Union as Strings
In practice, though, it looks exactly like Enum, sans the quotes. Almost. Notice both our type ‘Sheltie’ and the use of it in the case statement, ‘Sheltie’ is the same. Notice in our Enum example, it’s actually Breed.Sheltie
, not just Sheltie
. You can make Enum work that way via destructuring it immediately:
enum Breed {
Sheltie,
Lab,
Husky
}
const { Sheltie, Lab, Husky } = Breed
If you’re curious why, Enum’s in TypeScript are compiled to Objects whereas Discriminated Unions that are simple strings are compiled to strings; there is nothing to destructure, it’s just a string.
Functional Programming languages call these “tagged unions”; meaning the string is just a tag; a label defining what it is. You’ll define a bunch of tags, in this case 3 of them, and “unify them” into a single type, a Breed.
Where Enum’s End and Unions Begin
We’ve already shown how Enum’s are values, but Discriminated Unions can be both values and types. Let’s show you how Union’s can be more than just a tag, or a string as it were. It can be a completely different Object/Class. We’ll use something practical, like a return value from an HTTP library that wraps the fetch
function in Node.js and makes it easier to use by emulating Elm’s HTTP library and only returning the 5 types that matter:
- the URL you put into fetch is bogus
- your fetch took too long and timed out
- we got some kind of networking error; either your disconnected from the internet, your Host file is mucked up, or something else networking related is wrong
- We got a response from the server, but it was an error code of 4xx – 5xx
- We got a successful response from the server, but we failed to parse the body
The above is super hard to simplify in Fetch, but let’s assume Axios, undici, node-fetch, and all the other JavaScript libraries joined forces to make it simpler to use. How would they model that using an Enum? Maybe something like this:
enum FetchError {
BadUrl,
Timeout,
NetworkError,
BadStatus,
BadBody
}
That’s kind of cool. Now, you never need try/catch with an async/await fetch
, nor a catch
with a Promise
. You can just use a switch statement with only those 5, and the compiler will ensure you handled all 5. However, we’re missing some data here… note my whining code comments:
switch(err) {
case BadUrl:
// ok, but... what was the bad url I used?
case Timeout:
// cool
case NetworkError:
// cool
case BadStatus:
// ok, but... what was the error code?
case BadBody:
// ok, but... what _was_ the body? Perhaps I can parse it a different way, or interpret it to get more information of what went wrong?
}
You can see the problem here. Enum’s are just values; meaning “BadUrl” is just a number or a string; it’s just an atomic value, just one thing. What we need is an And, either an Object or Class.
If we defined those as types, they’d look like the below (yes, you can use interface
below or a class if you wish, I’m just using type
to be consistent and from my FP background).
type BadUrl = {
url: string
}
type BadStatus = {
code: number
}
type BadBody = {
body: string | Buffer
}
Let’s go over each one:
- The
BadUrl
is an Object with 1 property, url. It’ll be the URL we called fetch with, and the URL fetch is whining isn’t a good URL, like{url: 'moo cow 🐮' }
- The
BadStatus
is an Object with 1 property,code
. It’ll be any number between 400 and 599, whatever the HTTP server sends back to us. - The
BadBody
is an Object with 1 property,body
. It’s either a string or a binaryBuffer
; we’re not sure which, so we use a Discriminated Union in the Object to say “The body is either a string OR a Buffer”. Notice we didn’t define another Union here, we just did it inline using the single pipe.
However, the above isn’t enough and won’t work. TypeScript needs the same property name for all Objects to have a unique value so it can tell them apart from a type level. You get this as instanceof using a class for example:
class BadUrl {
constructor(public url:string){}
}
class BadStatus {
constructor(public code:number){}
}
You can then at runtime figure out those types by asserting:
const getThing = (classThing:any):string => {
if(classThing instanceof BadUrl) {
return `The URL is invalid: ${classThing.url}`
} else if(classThing instanceof BadStatus) {
return `Server returned an error code: ${classThing.code}`
}
return 'Unknown error type.'
}
Again, though, no compiler help to know if you’ve handled all cases, the above is all at runtime, not compile time.
The common thing to do is give them a property name they all have with a unique value for each. Let’s enhance those Objects with errorType
. For brevity, leaving out Timeout & NetworkError:
type BadUrl = {
errorType: 'bad url',
url: string
}
type BadStatus = {
errorType: 'bad status',
code: number
}
type BadBody = {
errorType: 'bad body',
body: string | Buffer
}
K, understand our 3 Objects? Now, let’s unify it into a single type:
type FetchError
= BadUrl
| Timeout
| NetworkError
| BadStatus
| BadBody
Looks about the same as the Enum, though. What happens if we use it in a switch statement?
const getErrorMessage = (httpError:FetchError):string => {
switch(httpError.errorType) {
case 'bad url':
return `The URl is invalid: ${httpError.url}`
case 'timeout':
return 'Took too long.'
case 'network error':
return 'Some type of networking error.'
case 'bad status':
return `Server returned an error code: ${httpError.code}`
case 'bad body':
return `Failed to parse body server returned: ${httpError.body}`
}
}
If you’re willing to do the work of adding an extra property to each Object to help identify it in a switch statement, you get the same exhaustive check features that you get with Enum, or basic string Discriminated Unions with 1 additional feature; the ability to use various data, confidently, depending on the type!
Notice if we have a BadUrl
, we can confidently access it’s url
property:
case 'bad url':
return `The URl is invalid: ${httpError.url}`
But, if it’s instead a BadStatus
, we can instead access the code
property:
case 'bad status':
return `Server returned an error code: ${httpError.code}`
If it were a BadUrl
and you tried to access the code
property, you’d get a compiler error:
Property 'code' does not exist on type 'BadUrl'.
You can do that at runtime using classes and instanceof, but using Discriminated Unions, the compiler can help you before you compile to ensure your code is correct.
UI Uses Too
This concept of switch statements checking every possible case, and the compiler ensuring you’ve included all of them, as well as ensuring the data associated with each case is correct can help make your user interfaces more correct as well. Using Or’s to model types in UI development has an extremely common use case there: what screen to show the user.
We UI developers only build a few screens. Some of those only show certain screens or components to the user based on the state of our application. The most common is when loading data. We’ve all seen the common Loading, Failed to Load, and Error screens. Regardless of framework/UI library, it’s often modeled like this:
{
isLoading: true,
data: undefined,
isError: false,
error: undefined
}
If we get data successfully, the Object is now:
{
isLoading: false,
data: ["our", "data"],
isError: false,
error: undefined
}
Impossible States
However, there are 2 problems with this, one of which I’ve covered before.
The first is impossible states. If you don’t modify the Object correctly, or have some kind of bug, you can end up with this Object:
{
isLoading: false,
data: ["our", "data"],
isError: true,
error: new Error("b00m 💣")
}
What does it mean to have both data successfully AND an error? The UI code may, or may not, handle that with some leniency, but probably will show either stale data or an error screen when successful. Either is madness.
The Loading Bug
The 2nd problem is the missing waiting state. Many UI developers, myself included for over a decade, simply use those 3 states to model UI applications. However, they’re missing a 4th state which correctly models what actually happens in larger UI’s, called Waiting. The reason you don’t hear about it is your internet is super fast, the data is cached, or you just never notice.
But the bug is prevalent in many popular applications. Kris Jenkins covers the bug for Twitter and Slack in his post describing why he built the Remotedata library for Elm. I’m stealing his images here to showcase the bug. Here’s a Tweet of his, notice there are no retweets or likes:
And here’s his Slack showing no Direct Messages, which is also untrue (he has lots of Direct Messages in his Slack):
Both correct themselves after the data loads. UI developers will typically default to “Loading…” in both the UI and in their data. Whether React’s initial hook/constructor, Angular’s ngOnInit, or some type of “run this code once when the component first renders”, you’ll kick off some data fetch for the component. The user will see a loading screen while it loads. However, many UI developers, such as Twitter & Slack developers, only render 1 state. Yes, one: success. If it failed, they ignore it. If it loads forever, they ignore it.
Others will show success and error. Many don’t show a loading screen. For a Designer, it’s not as simple as designing loading screens or spinners for every part. You’re designing an experience around what the user is trying to accomplish, and you constantly battle between the extremes of “show all the things happening” and “only show what they need to know”. The developer may have to make many calls and the designer may not be aware of each of those discrete loading states. This is not straightforward and is hard. They then have to work with the developer on what they can/can’t do, what worked well vs. not with the user. It’s a challenging, constantly changing, process.
The Waiting State
So just use an Enum or Discriminated Union to ensure the UI shows all 3 states, right?
Well… not quite. 2 issues with that. Let’s tackle the first since it’s been a problem in the UI industry for awhile. There is actually a 4th state we need. Kris Jenkins advocates for a 4th state, called “Not Asked Yet”, which I’ll call “Waiting”. This is separate from “Loading”. It means no data fetch has been kicked off yet.
Now most UI developers will just ensure all UI’s always start in a Loading state. However, as your UI grows, that gets harder to ensure. Additionally, some UI’s need to handle a retry, which is either the error state, or a 5th state. Sometimes UI’s will give the user an opportunity to cancel a long running network operation, which again is another possible state.
However, for brevity’s sake, Waiting is important not for your user, but you the developer, to know you forgot to kick off the fetch call somewhere, or perhaps the user or some other process has to kick it off. You can get really confused as a dev when you see a loading screen, but look in your browser’s network panel and there is fetch call made, or worse, 50 of them and you’re not sure which one belongs to your component. Instead, if your component is in a Waiting state, you know for a fact you did not kick off the loading. Much better place to be debugging from.
Drawing UI’s Using Discriminated Unions
In TypeScript, the types would look like this:
type Waiting = {
state: 'waiting'
}
type Loading = {
state: 'loading'
}
type Failure = {
state: 'failure',
error: Error
}
type Success = {
state: 'success',
data: Array<string>
}
type RemoteData
= Waiting
| Loading
| Failure
| Success
Then in your UI code, (showing React) you’d render in a switch statement to ensure you draw every possible case:
function YourComponent(props:RemoteData) {
switch(props.state) {
case 'waiting':
return (<div>Waiting.</div>)
case 'loading':
return (<div>
<p>Loading...</p>
<button onClick={cancel}>Cancel</button>
</div>)
case 'failure':
return (<div>Failed: {props.error.message}</div>)
case 'success':
return (<div>{renderList(props.data)}</div>)
}
}
You’d see one of these 4 views, waiting, loading, error, or success:
Again, unlike Enum, a Discriminated Union allows your switch statement case to utilize the data, confidently, on that particular value. Above that would be the error state utilizing the error message, and the success data utilizing the data. Additionally, we won’t get an impossible state because Discriminated Unions, like Enum’s, can only be in 1 value at a time.
Conclusions
As you can see, Discriminated Unions have a lot in common with Enums, and can replace them. Below is a table of the pro’s and con’s comparing them:
Enum Pros | Enum Cons | Union Pros | Union Cons |
---|---|---|---|
Single native word | No data association | Both string and data | No single native word |
Exhaustive Checking | destructure values | Exhaustive Checking | Objects require common property |
data association | |||
Can be used without defining | |||
Don’t have to destructure names |
As someone coming from Functional languages, it’s disappointing TypeScript requires you to make Unions as strings vs. the native word Enum’s get to use (e.g. ‘Sheltie’ vs Sheltie
). For example, here’s what it’d look like in Elm or ReScript (the Functional version of TypeScript with sounder, less forgiving types):
type Breed
= Sheltie
| Lab
| Husky
Also, the common property is annoying, and while worth it for ensuring sound types, again, I’m spoiled in Elm/ReScript/Roc/any FP language that has Discriminated Unions/Tags/Variants. For example, here’s how you’d define our HTTP Errors in Elm:
type HttpError
= BadUrl String
| Timeout
| NetworkError
| BadStatus Int
| BadBody String
That Int
and String
parts, it’s just an Object that has a number, or an Object that has a string. Here’s how we’d switch statement on it, called pattern matching:
case httpError of
BadUrl url ->
"URL is invalid: " ++ url
Timeout ->
"Fetch took too long."
NetworkError ->
"Some unknown networking error."
BadStatus code ->
"Server sent back an error code: " ++ (String.fromInt code)
BadBody body ->
"Failed to parse the body the server sent back: " ++ body
You’ll notice they look like Enums, and the compiler/runtime can “tell them apart”. TypeScript doesn’t have this so you have to give it some kind of property that’s the same between all union types so the compiler can switch on it.
If you’re new to TypeScript, or come from other FP languages, you may be surprised to see TypeScript’s common use of strings as data types. The joke amongst ML language people when they see String as a type is “Why is this untyped?”. However, TypeScript has super powerful compiler guarantee’s with strings that CSS and UI developers utilize all over the place, and Discriminated Unions are just one example of that. If it really gives you heartburn, you can change those to Enum’s and use keyOf in the switch statement, but… that’s overkill for what the compiler is already giving you.
All in all, I think Discriminated Unions give you the powers of Enum with strong-typing, the with the added flexibility of including data with your Or types that is worth the extra typing.
Posted on December 12, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.