ADT's and polymorphism
What is an ADT?
An ADT (algebraic data type) is basically a type made of other types. There a multiple types of ADT's, but the one we care about here is called a sum type. A sum type is a type where a value must be one of a fixed set of types. For example, an Animal
type that could be a Cat
type or a Dog
type would be a sum type. It cannot be both a Cat
and a Dog
at the same time.
Why do I care?
Well, it turns out that this concept is incredibly powerful and allows us to write very strict type definitions to model our business domain. A great use case for this is implementing types for a polymorphic relationship. For example, the Favorite
type has a favoritable
relationship that can be a Photo
, Post
, or Group
. It can never be both a Photo
and a Post
, or any other combo. It must only be one of those (thus it is a sum type).
Let's look at some code!
Example - Favorite model without ADT
Let's say we implemented the Favorite
type as a regular interface that handles all the possible types. Notice there are a lot of union types (types with |
in them) being used.
// this is based on a real example from a project I work on.
// the domain has been changed for ease of understanding.
interface Favorite {
favoritable_emoji: string | null
favoritable_id: string | number
favoritable_title: string
favoritable_type: 'Photo' | 'Post' | 'Group'
id: number
favoritable_slug: string | null
}
This is sort of correct, right? Nothing in there is untrue. favoritable_id
is a string
or a number
. But really, favoritable_id
is a string
when the favoritable_type
is Post
or Group
, and a number
when the favoritable_type
is a Photo
.
This is an important distinction to make for a few reasons:
- I have to know that in my brain, which is an unreliable chunk of meat.
- We have to use type casting (i.e.
favorite.favoritable_id as string
) to make our code compile, which means our types are not correct and breaking changes will not get caught by the compiler, which defeats the purpose of using types at all.
Let's say we want to get the url for each of these types. If we use this sort-of-correct type definition, we end up with code like this:
const pathForFavorite = (favorite: Favorite) => {
switch (favorite.favoritable_type) {
case 'Post':
if (favorite.favoritable_slug) {
return generatePath(POST_PATH, {
slug: favorite.favoritable_slug
})
}
else {
return ''
}
case 'Photo':
return generatePath(PHOTO_PATH, {
id: favorite.favoritable_id,
})
case 'Group':
return generatePath(GROUP_PATH, {
location: favorite.favoritable_id,
})
}
}
Notice how we need to check for the presence of favoritable_slug
before using it? This is because we said it was string | null
, but in reality, it should never be null
when the type is Post
. Those if
statements are just ceremony to get the code to compile because we didn't define our Favorite
type correctly.
The alternative would be to use !
to cast string | null
into just string
. This is less code, but still problematic because it means we (the developers) know that our types are incorrect, and are trusting our memory and discipline to always write perfect code.
Let's fix this!
Example - Favorite model with ADT
Here's how we can define our Favorite
type using and ADT (in Typescript, these are called discriminated union types). This is more code in our type definition, but it is also more correct, which is way more important than brevity.
interface BaseFavorite {
favoritable_emoji: string | null
favoritable_title: string
id: number
}
interface PostFavorite extends BaseFavorite {
favoritable_id: string
favoritable_type: 'Post'
favoritable_slug: string
}
interface PhotoFavorite extends BaseFavorite {
favoritable_id: number
favoritable_type: 'Photo'
favoritable_slug: null
}
interface GroupFavorite extends BaseFavorite {
favoritable_id: string
favoritable_type: 'Group'
favoritable_slug: null
}
type Favorite =
| PostFavorite
| PhotoFavorite
| GroupFavorite
Nice! Now I, the developer looking at this code, know everything about this model. I know when the favoritable_id
is string
and when it is a number
. I know when there is a favoritable_slug
and when there isn't. I also know that the favoritable_emoji
is optional for every favorite type (and not required on some and missing on others, like the slug is).
The best part of this is that my application code can be way less ceremonious and I know that, if I am typecasting or using !
for anything, I am doing something wrong.
Let's look at that pathForFavorite
function again:
const pathForFavorite = (favorite: Favorite) => {
switch (favorite.favoritable_type) {
case 'Post':
return generatePath(POST_PATH, {
slug: favorite.favoritable_slug
})
case 'Photo':
return generatePath(PHOTO_PATH, {
id: favorite.favoritable_id,
})
case 'Group':
return generatePath(GROUP_PATH, {
location: favorite.favoritable_id,
})
default:
absurd(favorite)
return ''
}
}
Now we have no typecasting and none of the if
statements (which were just there to make the compiler happy).
We also were able to add the absurd
function to ensure that the switch statement is exhaustive and future updates to this model (like adding another FavoritableType
) will prevent this function from compiling without handling the new Favorite
type. The absurd
function is defined as a function that requires a single argument of type never
, meaning that it can never be called.
const absurd = (x: never) => {}
That's it!
Thanks for reading!
Want more of that sweet, sweet content? Here ya go.