Make impossible states impossible - Kotlin edition
Pin-Sho Feng
Posted on March 16, 2020
Sometimes called "functional domain modelling", there are a bunch of articles and videos titled like that but I haven't found a Kotlin edition, so took the chance and hope it's useful :)
Let's state our goal clearly again: make impossible states impossible.
What does that mean? In a statically typed language like Kotlin, it means we want to leverage the compiler to ensure that we can only have valid models so that it's impossible to have incorrect cases. In other words:
- Use the type system to describe what is possible or not. If something is not possible, it should not compile.
- Another way to look at it is: use types as if they were tests that you won't need to write. Think about
nullable
types in Kotlin, for example... if it's not marked with?
you know it's notnull
(unless someone cheated along the way... but you can't control who uses reflection either).
Let's start with an apparently normal example of what could be a user profile in some platform:
const val ACCOUNT_TYPE_BASIC = 0
const val ACCOUNT_TYPE_PREMIUM = 1
const val ACCOUNT_TYPE_PRIVILEGE = 2
data class UserProfile(
val name: String,
val age: Long,
val accountType: Int,
val email: String?,
val phoneNumber: String?,
val address: String?
)
What problems can you spot?
-
name
: there could be a number within the string. What about the surname? Special characters? What if it's empty? -
age
: can anybody live so long to fill aLong
? Can it be 0? Negative? -
accountType
: what if someone puts a number that's not in our constants list? -
email
: an email needs a specific format, but we could write anything in there and it'd compile. -
phoneNumber
: same... -
address
: same again...
How could we use the type system to make these cases impossible?
Let's start with name
.
Name
We'd normally have some sort of function that validates the input before putting it into the model, but a String
doesn't reflect any of those validations. What can we do? How about a type that represents a validated name? Let's explore that.
data class UserName(val name: String) {
companion object {
fun createUsername(name: String): UserName {
// perform some validations on name
if (name.isEmpty()) throw IllegalArgumentException()
return name
}
}
}
So with a specific type we can represent a validated model. But still, there are problems in the proposed solution...
- Anyone can just create a
UserName
, bypassingcreateUserName
. - It blows in runtime if there's any problem
For the first case, an attempt could be to make the constructor private:
data class UserName private constructor(val name: String)
But then we get a warning telling us that "private data class constructor is exposed via the generated 'copy' method". Bummer... it's a reported bug and it doesn't seem to be a priority for JetBrains. At this point for simplicity I think we could just ignore the warning, rely on convention and call it a day...
Now, if it still doesn't feel right, we could hack it using some interface magic:
interface IUserName {
val name: String
companion object {
fun create(name: String): IUserName {
// perform some validations on name
if (name.isEmpty()) throw IllegalArgumentException()
return UserName(name)
}
}
}
private data class UserName(override val name: String) : IUserName
More verbose than desirable... but it works. Now, there's still the problem of the IllegalArgumentException
. Ideally we want to handle all our cases and make it impossible to blow up. We could use something like Either, or if we don't want to add Arrow we can just use Java's Optional.
fun create(name: String): Optional<IUserName> {
// perform some validations on name
if (name.isEmpty()) return Optional.empty()
return Optional.of(UserName(name))
}
Or since this is Kotlin, you could just make it nullable:
fun create(name: String): IUserName? {
// perform some validations on name
if (name.isEmpty()) return null
return UserName(name)
}
For the rest of this post I'll just use the non-interface version for simplicity, but hopefully the point has come across.
age
Pretty much the same as for name
, we can create a validated type for it.
accountType
For this one we can just use a common construct available in many languages: Enum
.
enum class AccountType {
ACCOUNT_TYPE_BASIC,
ACCOUNT_TYPE_PREMIUM,
ACCOUNT_TYPE_PRIVILEGE
}
email, phoneNumber, address
At this point it should be obvious that these fields share the same problem, so we can create validated types for each of them.
But is that the only problem? Is it possible to have a user that we can't contact in any way? According to the model, this is perfectly valid:
val profile = UserProfile(
...,
email = null,
phoneNumber = null,
address = null
)
We probably want to be able to contact the user right? So maybe we can generalize all of them as a ContactInfo
. Is there any way to express that a ContactInfo
can be "an email, a phone number, or an address"? How would you do that with GraphQL? Hmm... union types? In Kotlin we can represent these with sealed classes.
// for simplicity, assume that we have factory methods for those data classes and the constructors are private...
sealed class ContactInfo
data class Email(val email: String) : ContactInfo
data class PhoneNumber(val number: String) : ContactInfo
data class Address(val address: String) : ContactInfo
In GraphQL syntax this would be: union ContactInfo = Email | PhoneNumber | Address
.
So everything's validated... is that enough?
Our UserProfile
might look like this now:
data class UserProfile(
val name: UserName,
val age: Age,
val accountType: AccountType,
val contactInfo: List<ContactInfo>
)
Is that OK? Can contactInfo
be empty? We did say 'no' before, didn't we? We could create a special NonEmptyList
type (or use Arrow):
data class UserProfile(
val name: UserName,
val age: Age,
val accountType: AccountType,
val contactInfo: NonEmptyList<ContactInfo>
)
Now? Hmm... are duplicate ContactInfo
s allowed? 🤔 What's a data structure that can contain only unique elements?
data class UserProfile(
val name: UserName,
val age: Age,
val accountType: AccountType,
val contactInfo: NonEmptySet<ContactInfo>
)
And that's it! (NonEmptySet
is non-standard Java/Kotlin, but should be easy to create)
Conclusion
Making impossible states impossible is about using our data types to represent valid things in our domain, which will ultimately lead to more robust software with less bugs. It's often called "functional domain modelling", probably not because it has anything to do with functional programming per se, but most likely because in the statically-typed functional world we strive for "total functions", which are those that consider all possible inputs and outputs.
Just asking yourself the question of "does my model allow any illegal state?" will get you a long way!
Some resources you can check:
- Design for errors - An introduction to Domain Modeling with a bit of Arrow by Ivan Morgillo
- Scott Wlaschin - Talk Session: Domain Modeling Made Functional
- Making Impossible States Impossible" by Richard Feldman
Bonus
When Kotlin finally gets inline classes, we'll be able to have zero-cost abstractions.
// this needs a data class to wrap a String
data class Name(val name: String)
// inline classes are basically the underlying primitive,
// verified by the compiler
inline class Name(val name: String)
Posted on March 16, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.