dev-resources.site
for different kinds of informations.
TypeScript Enum's vs Discriminated Unions
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.
Featured ones: