dev-resources.site
for different kinds of informations.
Building React Components Using Unions in TypeScript
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
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} />
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'
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
}
Using that type above, we can create my dog, Albus:
const albusMan = {
name: 'Ablus Dumbledog',
age: 8,
breed: Sheltie
}
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> }
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 /> )
}
}
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} />
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 />
}
}
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} /> )
}
}
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} /> )
}
}
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} /> )
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
}
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}
}
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
}
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']
}
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'
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'
}
}
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> }
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> }
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
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 })
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 }
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)
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 })
Now, whenever you want to create a Success, you can simply call the function:
Success( [ 'some data' ] )
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 }
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)
⌠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>)
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' }
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' }
â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.
Featured ones: