Building React Components Using Unions in TypeScript

Jesse Warden - Sep 30 '23 - - Dev Community

Update October 4th, 2023: Added an additional explanation on how you can more easily create Unions making them more readable, and compared them to other languages.

The common theme in a lot of React examples utilizes 2 types of data to build React components around: primitives and Objects. In this article, we’re going to talk about a 3rd called Discriminated Unions. We’ll go over what problems they solve, what their future looks like in JavaScript and possibly TypeScript, and what libraries can help you now.

Unions in TypeScript can help prevent a lot of common bugs from happening so they’re worth using, especially for UI developers where we have to visualize complicated data. While Unions help narrow what you have to draw, they also expose edge cases that your UI Designer/Product Owner may not have accounted for in their visual design. In JavaScript this would be a blank screen, a null pointer, or an error boundary that you’re confused about. Most importantly, they can help you make impossible situations impossible.

Introduction – Definitions

Let’s create a glossary of our 3 terms first because how programmers, various programming languages, and mathematicians define them has resulted in many words for the (mostly) same thing.

Primitive Type

A primitive data type, also called an atomic data type, is typically 1 thing defined by 1 variable. Examples include Booleans, Strings, and Numbers. While it’s good to not have primitive obsession, especially in any type of UI development, we interface heavily with primitives to show that data visually.

We won’t be covering React components using these.

// Examples of primitives in JavaScript
const name = "Jesse"
let animalCount = 3
var isAwake = false 
Enter fullscreen mode Exit fullscreen mode

Product Type

A Product data type, also called an Object or Record. Most Product types developers are used too is when they combine many Primitive types together into a single variable. These are also the most common way to build React components and are often what props are; an Object to hold all your Primitives, or other Product types.

Developers utilize Product types to group related pieces of data together. Programmers who design with data will utilize Product types to mirror what the Business or Users call something such as a “Shipping Manifest” or “Cart” or “Person”.

The most important concept to take away from a Product type when thinking about modelling data for your React components is “And”; like “a Person is a first name AND a last name” or “a dog has a name AND an age AND a breed”.

// Example of a Product type, or Object in React JavaScript
const Greeting = props => {
  return ( <div>Hello {props.person.firstName} {props.person.lastName}!</div> )
}

// creating the data
const person = { firstName: 'Jesse', lastName: 'Warden' }

// then giving it to the component
<Greeting person={person} />
Enter fullscreen mode Exit fullscreen mode

Discriminated Union Types

What TypeScript calls a Discriminated Union, others call a tagged union, a variant, or a sum type. I’ll shorten to just Union here (don’t tell the Mathematicians). They are not Enums, but do have some things in common. A Union type is when you have multiple pieces of data as a single variable, much like a Product type, except it can only be 1 at a time. Examples include a dog breed, the color of a wall, or the loading state of a React component.

The most important concept to take away from Union types is to think of them as an “Or”. A “thing can be this OR this, but not both at the same time”.

Union Examples

There are many different dog breeds, but a dog can only be 1 at a time.

// Example TypeScript breed Union type

type Breed = 'Sheltie' | 'Lab' | 'Mutt'
Enter fullscreen mode Exit fullscreen mode

While a dog Product type can have multiple properties that are other primitive values such as name and age, it can only have 1 breed at a time.

type Dog = { 
    name: string 
    age: number
    breed: Breed
}
Enter fullscreen mode Exit fullscreen mode

Using that type above, we can create my dog, Albus:

const albusMan = {
    name: 'Ablus Dumbledog',
    age: 8,
    breed: Sheltie
}
Enter fullscreen mode Exit fullscreen mode

Notice in the above example, we’ve composed a Union inside of a Product type. Again, Product types are a collection of data in a single variable; a Union is also a piece of data so can go in a Product type.

Unions “Unifying” Product Types

You can also go the other way around and have a Union “unify” some common Product types we React developers are used to: loading remote data.

type RemoteData
  = { type: 'waitingOnUser' }
  | { type: 'loading' }
  | { type: 'failed', error: string }
  | { type: 'success', data: Array<string> }
Enter fullscreen mode Exit fullscreen mode

Note that each Object has a type with a hard coded string value. Unlike other languages that support Unions, TypeScript needs some kind of way to differentiate the different Objects from each other, and so you have to pick a name, then give it a different value for each Object. We used “type” and for the values used “waitingOnUser”, “loading”, “failed”, and “success”. After those, you can add whatever else you want to the rest.

Notice as well that the type is an actual string value, not a type, like type: string. This is a TypeScript specific feature.

Now that you have the only 4 possible states a React component can draw, you create a React component around that.

const View = ({ remoteData }) => {
  switch(remoteData.type) {
    case 'waitingOnUser':
      return ( <Waiting /> )
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice in the View component, we’re using a switch statement on the type; since there are 4 possible types, we’ll have 4 possible cases. More on that in a bit, we’re just using 1 for now.

The data we initialize and feed it to our React component would look like so:

let remoteData = { type: 'waitingOnUser' }
<View remoteData={remoteData} />
Enter fullscreen mode Exit fullscreen mode

A Hint of Exhaustiveness in Switch Statements for Union Types/Tags

When you attempt to compile the above, a magical thing happens; TypeScript let’s you know your View component is missing 3 cases for ‘loading’, ‘failed’, and ‘success’. Let’s add loading first.

const View = ({ remoteData }) => {
  switch(remoteData.type) {
    case 'waitingOnUser':
      return ( <Waiting /> )
    case 'loading':
      return ( <Loading />
  }
}
Enter fullscreen mode Exit fullscreen mode

Now TypeScript will complain you’re missing only 2. Using the data above, the View component would still only show to the user the <Waiting /> component.

Safe Property Access

Let’s add failed next:

const View = ({ remoteData }) => {
  switch(remoteData.type) {
    case 'waitingOnUser':
      return ( <Waiting /> )
    case 'loading':
      return ( <Loading />
    case 'failed':
      return ( <Failed error={remoteData.error} /> )
  }
}
Enter fullscreen mode Exit fullscreen mode

Our compiler would now complain we’re only missing 1 case. However, something mind bending is happening in our above code, let’s talk about it. Notice how in the failed case we access remoteData.error? It may look like remoteData is acting like an Object or Product type where “it’s a thing, and it has this error property, and I access it”. And you are… but you aren’t.

Notice in ‘waitingOnUser’, ‘loading’, and ‘success’, there is no ‘error’ property? The next question in your mind is “How do it know!?”. “It know” because TypeScript, because Union, because awesome. If TypeScript has confirmed through your case statement that you are in fact looking at a ‘failed’ type, then it’s safe to access the error property because the remoteData in that block of code is a ‘failed’ type with an ‘error’ property. Doesn’t that make you feel good and confident now 😀?

You may have just realized it yourself, but I’ll reiterate it now: you just prevented a bunch of potential null pointers in the form of “remoteData.error is undefined”. That is one of the many super powers of Unions and TypeScript working together.

Let’s add our success state:

const View = ({ remoteData }) => {
  switch(remoteData.type) {
    case 'waitingOnUser':
      return ( <Waiting /> )
    case 'loading':
      return ( <Loading />
    case 'failed':
      return ( <Failed error={remoteData.error} /> )
    case 'success':
      return ( <Success dogNameList={remoteData.data} /> )
  }
}
Enter fullscreen mode Exit fullscreen mode

Our compiler now no longer complains. More on that in a minute. Notice, too that we’re accessing data in our ‘success’ case. Our RemoteData Union type doesn’t have both error and data like an Object/Product type would… yet we use it like that. Again, the power of Union types and TypeScript working together; once we’re in that case statement, we’re safe. If you screw up like accidentally copy pasta’ing code, and use remoteData.error like below, TypeScript would yell at you:

case 'failed':
  return ( <Failed error={remoteData.error} /> )
case 'success':
  // TypeScript will be mad at this line of code using .error
  return ( <Success dogNameList={remoteData.error} /> )
Enter fullscreen mode Exit fullscreen mode

So you’re UI now draws all 4 states, but ONLY 1 at a time. That’s because (say this out loud) “Our data can only be in a ‘waiting on user’ state, a ‘loading’ state, a ‘failed’ state, OOORRRRR a ‘success’ state”. Get it? You’ve used the types to “narrow” what can possibly happen in your UI. This removes the next most common bug in UI development; impossible situations.

Impossible Situations

A lot of component developers, regardless of framework (React, Angular, Svelte, Solid, Vue, Elm, etc) will utilize Objects to represent the state of their component or app. However, these “flags” will need to be flipped/changed in a specific configuration, else bad things will happen. Real world examples include starting your automatic transmission car without pressing on the brake, or starting a manual transmission vehicle when it’s not in neutral first.

Let’s show a common example by rebuilding the above component using JavaScript and procedural code. We’ll take out the “waitingOnUser” to make it only 3 states: loading, failed, or success. First, our Object/Product type to represent state:

const remoteData = {
  isLoading: true,
  isError: false,
  error: undefined,
  data: undefined
}
Enter fullscreen mode Exit fullscreen mode

Next, we’ll make our View component utilize this Object:

const View = ({ remoteData }) => {
  if(remoteData.isLoading) {
    return ( <Loading /> )
  }

  if(remoteData.isError) {
    return ( <Failed error={remoteData.error} /> )
  }

  return ( <Success data={remoteData.data}
}
Enter fullscreen mode Exit fullscreen mode

In the above example, she’ll draw the <Loading /> component. If we want to show an error, we’d create our remoteData in an error state by swapping the isBlah flags, and adding an error message string:

const remoteData = {
  isLoading: false,
  isError: true,
  error: 'something failed',
  data: undefined
}
Enter fullscreen mode Exit fullscreen mode

Cool, so our View component would draw the <Failed> component. So far so good, no need for TypeScript. But what happens if you’re doing Object destructuring only updating 1 value, or setting the remoteData 1 property at a time and forget 1 boolean flag, or even setting a useState or useReducer and you accidentally end up with an Object like this:

const remoteData = {
  isLoading: false,
  isError: true,
  error: undefined,
  data: ['Albus', 'Pixie', 'Apollo']
}
Enter fullscreen mode Exit fullscreen mode

What do you think the View component would draw? If you said <Failed> you are correct, despite your browser clearing giving an HTTP 200 with your data successfully parsed on your remoteData Object. This happens all the time with multiple states that need to be updated at the same time. If it’s just a single boolean, you only have a 50% to screw it up each time you use it. Now, however, we have 2, which means there are 4 possible combinations… but that assumes components are only looking at the isLoading and isError; notice our View component is referencing them as well as the error and the data properties. That’s … anywhere from 24 possible scenarios to mess up!

The author attempted to narrow those down using if statements; if it’s an error, only then attempt to access the error property, and ignore the data property. However, looking at our remoteData Object above, it’d still throw a null pointer because error is undefined.

You know what prevents all that? Union types. It can ONLY be in Loading OR Failed OR Success; not some weird in between state “by accident because JavaScript dynamic typing and/or tired programmer”.

Exhaustive Pattern Matching Revisited

Remember when we finished adding the “success” case in the switch statement and I said now our compiler no longer complains? Anyone with extensive experience in dynamic languages and switch statements knows you always add a default case at the bottom, even if it’s just a console.log to say “We should never be here, yet here we are”.

If you turn on strict in TypeScript’s compiler settings, when you utilize Union types in switch statements, you DON’T HAVE TOO. TypeScript “knows” our Union type only has 4 possible scenarios, so it’s impossible to have another.

Now, caveat here; if you’re entire code base is written in TypeScript with strict types, and and your code is only ever run by itself; this is true. However, as soon as you interface with JavaScript, such as using a JavaScript library that you integrate, or you’re publish your own library written in TypeScript that’s compiled to JavaScript for others to use, this isn’t true anymore. At runtime, JavaScript can do whatever it wants, and all the types are lost at runtime, and you’re back in dynamic dangerous land.

A common example is using the above technique of Union types to build Redux reducers. Here’s an example where we have 2 possible Redux Actions:

type Action = 'Show Table View' | 'Show List View'
Enter fullscreen mode Exit fullscreen mode

And our reducer function using that Union should in theory be short and safe in TypeScript:

const showReducer = (initialState = 'Table View', action:Action) => {
  switch(action) {
    case 'Show Table View':
      return 'Table'
    case 'Show List View':
      return 'List'
  }
}
Enter fullscreen mode Exit fullscreen mode

Seems legit, right? Well, at runtime you’ll an error because your switch statement actually isn’t handling all scenarios. Unbeknownst to the Redux n00bs, the first thing Redux does is call your reducer functions with some probe messages and an initialization one. You’re TypeScript is valid, but JavaScript has no idea and doesn’t respect types. So just something go be aware of when you’re using Union types on methods exposed to the outside world, make sure you put a default there, or abstract away the outside world.

More on pattern matching in the conclusion.

Easier to Read Unions

If you come from other languages that support Unions, while you may be pleasantly surprised TypeScript allows simple strings as Union types, you’ll most likely be disappointed that when unifying Product types (e.g. our Union is a bunch of Objects), it’s just a bunch of JavaScript Object looking things without actual names.

However, there is a way to write it like you normally would, it’s just a bit more work, but you still retain type safety, and VSCode “knows” where to take you when you Command/Control + Click something as well as code hints, with or without Copilot. Also be careful using Copilot; occasionally it’ll get the type of a Union completely wrong. Let’s rewrite our above RemoteData Union into individual type aliases to make it easier to read. Here’s what she is now:

type RemoteData
  = { type: 'waitingOnUser' }
  | { type: 'loading' }
  | { type: 'failed', error: string }
  | { type: 'success', data: Array<string> }
Enter fullscreen mode Exit fullscreen mode

Let’s take all 4 of those anonymous looking Object/Interface type looking things and make them a type alias. Note you can use <a href="https://www.typescriptlang.org/docs/handbook/2/objects.html">Interface</a> if you want, but that implies Object Oriented things, and that’s note what we’re doing here. (For you OOP heads who feel left out, check out Intersection Types).

type WaitingOnUser = { type: 'waitingOnUser' }
type Loading = { type: 'loading' }
type Failed = { type: 'failed', error: string }
type Success = { type: 'success', data: Array<string> }
Enter fullscreen mode Exit fullscreen mode

Now that we have individual type aliases for each out our possible states, let’s unify them into our existing Union:

type RemoteData
  = WaitingOnUser
  | Loading
  | Failed
  | Success
Enter fullscreen mode Exit fullscreen mode

Much easier to read! Additionally, you can be more clear what a function returns if it is a specific type alias:

// old way
const createSuccess = (data:Array<string>):RemoteData =>
  ({ type: 'success', data })

// new way
const createSuccess = (data:Array<string>):Success =>
  ({ type: 'success', data })
Enter fullscreen mode Exit fullscreen mode

However, that defines them, there is one thing left...

Creating Them

One last visual addition is when you create Unions. In our code above, we create a Success like so:

{ type: 'success', data }
Enter fullscreen mode Exit fullscreen mode

However, there are a few disappointments to this. First, we have to “know” in our head what the type is. Typically you’ll often mirror some spelling similiar to the type, like the Success is the type, and the ‘success’ string lower cased is the tag. Even with something like Copilot, though, it “looks like an Object”, not a Union.

Those of you from OOP are used to creating instantiations of your class types. If Success was a class, we could easily create it:

new Success(data)
Enter fullscreen mode Exit fullscreen mode

That’s a lot more obvious, easier to read, and less to type, and thus more fun to use. The way to get that style in Unions is to simple make a function that makes it, like so:

const Success = (data:Array<string>):Success =>
  ({ type: 'success', data })
Enter fullscreen mode Exit fullscreen mode

Now, whenever you want to create a Success, you can simply call the function:

Success( [ 'some data' ] )
Enter fullscreen mode Exit fullscreen mode

Final Thoughts on Defining and Creating

You’ve seen the 2 additional things we can do to make Union types easier to read and use: define individual type aliases then put them in a union and then create functions so when you create them, those are also easier to read.

Here is our all our code for our RemoteData union:

type RemoteData
  = WaitingOnUser
  | Loading
  | Failed
  | Success

type WaitingOnUser = { type: 'waitingOnUser' }
type Loading = { type: 'loading' }
type Failed = { type: 'failed', error: string }
type Success = { type: 'success', data: Array<string> }

const WaitingOnUser = () => { type: 'waitingOnUser'}
const Loading = () => { type: 'loading'}
const Failed = error => { type: 'failed', error }
const Success = data => { type: 'success', data }
Enter fullscreen mode Exit fullscreen mode

Now, you may hear from Functional Programmers that the above is quite verbose. It is. For example, below are the Elm and ReScript equivalents to give you context.

-- Elm
type RemoteData
  = WaitingOnUser
  | Loading
  | Failed String
  | Success (List String)
Enter fullscreen mode Exit fullscreen mode

… and that’s it. Notice the definition, the aliases, and the creation, all 3, are in one statement in Elm.

Here’s ReScript:

// ReScript
type remoteData
  = WatingOnUser
  | Loading
  | Failed(string)
  | Success(Array.t<string>)
Enter fullscreen mode Exit fullscreen mode

Other languages like F# and Rust are similiar.

Why Do Some Developers Use Tag instead of Type?

You may have seen some developers using Union types with tag instead of type like so:

{ tag: 'loading' }
Enter fullscreen mode Exit fullscreen mode

This is because a “Tagged Union”, another word for TypeScript’s Discriminated Union, is a way to “tag which one is in use right now… we check the tag to see”. Just like when you’re shopping and check the tag of a piece of clothing to see what the price is, what size it is, or what material it’s made out of. Languages like ReScript compile many of their Unions (called Variants) to JavaScript Objects that have a tag property.

Also, some type think the use of “type” is redundant:

type Loading = { type: 'loading' }
Enter fullscreen mode Exit fullscreen mode

“The Loading type is of type loading.”
“Bruh… do you hear yourself?”
“Type Loading type loading OT OT OT :: robot sounds ::”

You can use whatever you want for the tag; I just like type because it’s not a reserved word, and indicates that “This Objects’ type is X”. Whatever you choose, be consistent.

A Switch Statement is Not Pattern Matching

The same reason Functional Programming has taken decades to get just some of it’s many features into traditionally OOP or Imperative languages is because the industry, for a variety of reasons, can only take some much change at a time. So those of you from Functional languages may be saying to your screen “Jesse, a switch statement is NOT even close to pattern matching”.

I get it. However, the whole point of using Unions to narrow your types, ensure only a set of possible scenarios can occur, and only access data of a particular union when it’s safe to do so. That’s some of what pattern matching can provide, and 100% of what using switch statements in TypeScript with their Discriminated Unions can provide. Yes, it’s not 100% exhaustive, but TypeScript is not soundly typed, and even Elm which is still has the same issue TypeScript does: You’re running in JavaScript where anything is possible. So it’s good enough to build with and much better than what you had.

More importantly, TypeScript typically commits to build things into itself when the proposal in JavaScript reaches Stage 3. The pattern matching proposal in JavaScript is Stage 1, but depends on many other proposals as well that may or may not need to be at Stage 3 as well for it to work. This particular proposal is interested on pattern matching on JavaScript Objects and other primitives, just like Python does with it’s native primitives. These are also dynamic types which helps in some areas, but makes it harder than others. Additionally, the JavaScript type annotations proposal needs to possibly account for this. So it’s going to be awhile. Like many years.

That said, there are many other libraries out there that provide pattern matching, with or without types.

TypeScript supported:

JavaScript supported:

Naturally I’d recommend using a better language such as ReScript or Elm or PureScript or F#‘s Fable + Elmish, but “React” is the king right now and people perceive TypeScript as “less risky” for jobs/hiring, so here we are.

Conclusions

Using TypeScript’s Discriminated Unions to build your React components prevents multiple types of bugs such as null pointers, impossible states, and not handling all possible cases in a switch statement. This helps your React components only draw what they need to, and have less of a need for multiple Error boundaries. Using Unions to model all your data helps narrow your types, so you have less situations, and state, to worry about since it can “only be in this set”. Product types are still useful, and remember you can put Unions in Product types and Product types in Unions as you need.

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