You may have heard of Algebraic Data Types (ADT's) before but didn't understand how they can be applied to everyday code - so this article will provide some examples and explanations of why you should start using them.
Before we get into ADT's, let's go over the foundation of what ADT's are made of.
Basic Types
In Javascript, you can not declare a type that prevents other types from being assigned to it. The below example we see that anything can be assigned to the language
variable. We can assign a number
or a boolean
or an object
to it if we later wanted, but that may cause bugs in the future if we were not expecting the variable type to be something other than a string.
let language = 'en'
In Typescript, we get more control over declaring types. See below, now we can only assign a string
to the language
variable, which is much better. Now when we access this variable in the future, we are pretty certain that the value will be a string and proceed accordingly.
let language: string = 'en'
But we can do better...
Union Types
With Typescript Union Types, we can say something can be more than one type. 😮 In the below example, we see that the language
variable can either be a string
OR a number
.
let language: string | number = 'en'
You might be saying to yourself, "cool, but why would I want two different types in one variable?"
This is a great question, but before we figure out why we would need this, we need to understand that anything can be considered a type in Typescript, including specific string values.
So now we can specify exactly which values can be assigned to the language
variable.
let language: 'en' | 'fr' | 'ru' = 'en'
Now we can only assign certain values to language
.
The interesting question now is, "how do we know which type is currently stored?"
If we have a variable that can hold two different types, when we access that value, we must check to see what the type is before we do something with it.
let nameOrID: string | number = 'Jason'
if (typeof nameOrID === 'string') {
// do something with string...
} else if (typeof nameOrID === 'number') {
// do something with number...
}
This is important, because if we do not check the value type then we do not know which one of the types is currently being used - so we might try to do math with a string, or do a string operation on a number...
Knowing these things, now we can explain Typescript's Discriminated Union Type.
Discriminated Union Types
Using the things we learned about Union Types, we can construct a special union that obeys some rules.
The rules that should be followed are:
- All types in the union, share a common property.
- There needs to be a union type declared from the types.
- There must be type guards on the common property.
Here is an example:
type HockeyGame = {
kind: 'hockey' // Rule 1 - common property 'kind'
homeScore: number
awayScore: number
clock: number
isDone: boolean
}
type BaseballGame = {
kind: 'baseball' // Rule 1 - common property 'kind'
inning: number
isTop: boolean
stadium: string
}
// Rule 2 - Union type declared
type Game = HockeyGame | BaseballGame
const gameToString = (game: Game): string => {
// Rule 3 - Type guard on the common property
switch (game.kind) {
case 'hockey':
return `Hockey game clock: ${game.clock.toString()}`
case 'baseball':
const frame = game.isTop ? 'top' : 'bottom'
return `Baseball game is in the ${frame} of inning ${game.inning}`
}
}
In the example above, we see that we used what we learned about assigning specific strings to a type with the kind
property. The kind property can only ever be hockey
or baseball
and never anything else.
This common property acts as an ID for the object and allows us to know what other properties are defined and available to be accessed.
Following these rules will allow the Typescript compiler to know which fields are available. So if you have checked the guard and deemed it to be hockey
then the compiler will only allow you to access the fields from the HockeyGame
type.
This can prevent a lot undefined
errors you may get from accessing properties that may or may not be there at different times.
ADT's with React
Now let's see how we can take advantage of this pattern in React.
Using the Game types declared above, we can safely render different components based on the common property in the union.
const HockeyGameBox = ({ game }: { game: HockeyGame }) => (
<div>
{game.homeScore} - {game.awayScore}
</div>
)
const BaseballGameBox = ({ game }: { game: BaseballGame }) => (
<div>
{game.inning} - {game.stadium}
</div>
)
const renderGame = (game: Game) => {
switch (game.kind) {
case 'hockey':
return <HockeyGameBox game={game} />
case 'baseball':
return <BaseballGameBox game={game} />
}
}
const GamePage = () => {
const [games] = useState<Game[]>([
/* mix of different games */
])
return games.map(renderGame)
}
As you can see, using ADT's can greatly reduce the amount of runtime bugs you get when using dynamic data. It's not a silver bullet for preventing bugs, but it's a step in the right direction.
To learn more about ADT's, check out my post about it in Elm: Elm's Remote Data Type in Javascript
Top comments (1)
This is a really elegant way of cleaning up components that are shared between types!
Thanks!