TypeScript Enum's vs Discriminated Unions

Jesse Warden - Dec 12 '22 - - Dev Community

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) {
...
Enter fullscreen mode Exit fullscreen mode

Once OOP started to influence our designs, we started attaching those constants to Objects:

var Breed = {
  Sheltie: 0,
  Lab: 1,
  Husky: 2
}
Enter fullscreen mode Exit fullscreen mode

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) {
...
Enter fullscreen mode Exit fullscreen mode

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:
    ...
Enter fullscreen mode Exit fullscreen mode

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.`
    }
}
Enter fullscreen mode Exit fullscreen mode

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'.
Enter fullscreen mode Exit fullscreen mode

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) {
Enter fullscreen mode Exit fullscreen mode

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) {
Enter fullscreen mode Exit fullscreen mode

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) {
Enter fullscreen mode Exit fullscreen mode

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'
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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.`
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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?
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 binary Buffer; 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){}
}
Enter fullscreen mode Exit fullscreen mode

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.'
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

K, understand our 3 Objects? Now, let’s unify it into a single type:

type FetchError
  = BadUrl
  | Timeout
  | NetworkError
  | BadStatus
  | BadBody
Enter fullscreen mode Exit fullscreen mode

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}`
    }
}
Enter fullscreen mode Exit fullscreen mode

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}`
Enter fullscreen mode Exit fullscreen mode

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}`
Enter fullscreen mode Exit fullscreen mode

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'.
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

If we get data successfully, the Object is now:

{
  isLoading: false,
  data: ["our", "data"],
  isError: false,
  error: undefined
}
Enter fullscreen mode Exit fullscreen mode

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 💣")
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .