I’m a big discriminated union fanboy. Type safety gets me giddy, and discriminated unions are a great way to enforce it.
Problem
Sometimes I have a type with a few different possible states, and I don’t want those states getting mixed up. For example, maybe I’ve built out some kind of software that needs to store contact methods. Maybe a class for that looks like this:
data class ContactMethod(
val type: MethodType,
val emailAddress: String?,
val homeAddress: String?,
)
enum class MethodType {
EMAIL,
CARRIER_PIGEON,
}
When I have a ContactMethod object that’s of type EMAIL, I’ll have the emailAddress field populated. Likewise, when I have one of type CARRIER_PIGEON, I’ll have the homeAddress field populated.
I have a function that takes in a ContactMethod as an argument and, depending on the type of method it is, I’ll call the required function.
fun contact(contactMethod: ContactMethod) {
when(contactMethod.type) {
MethodType.EMAIL -> sendEmail(contactMethod.emailAddress)
MethodType.CARRIER_PIGEON -> sendPigeonMessage(contactMethod.homeAddress)
}
}
Hold your rock doves, the compiler doesn’t like this! Both our sendEmail and sendPigeonMessage functions accept as an argument a String. Because both fields are optional, the compiler doesn’t know if they exist. We’d need to do a check every time we want to access one of those fields.
This won’t do. Our ContactMethod class doesn’t care which fields should be filled out, regardless of the method type. This also means we could have a ContactMethod object with what should be impossible states. An email with a home address, or worse, a carrier pigeon with an email address (pigeons can’t use computers!).
Solution
We can fix this! Let’s change our class to a sealed class.
sealed class ContactMethod {
abstract val emailAddress: String?
abstract val homeAddress: String?
data class Email(
override val emailAddress: String,
): ContactMethod() {
override val homeAddress = null
}
data class CarrierPigeon(
override val homeAddress: String,
): ContactMethod() {
override val emailAddress = null
}
}
We’ve gotten rid of our enum. Instead, we have a class with only two possible varieties. Either we have an email with an emailAddress and no homeAddress, or we have a CarrierPigeon with a homeAddress and no emailAddress. This gets enforced at compile time, so now we can change our function to look like this:
fun contact(contactMethod: ContactMethod) {
when (contactMethod) {
is ContactMethod.Email -> sendEmail(contactMethod.emailAddress)
is ContactMethod.CarrierPigeon -> sendPigeonMessage(contactMethod.homeAddress)
}
}
Rejoice, the compiler is happy again! It knows that if our ContactMethod is an email, it always has to have an emailAddress, and if a CarrierPigeon it always has to have a homeAddress. Now, there’s no more risk of an impossible in-between state, and no more pesky null checks.