DEV Community

loading...

Make impossible states impossible - Kotlin edition

psfeng profile image Pin-Sho Feng ・Updated on ・5 min read

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 not null (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 a Long? 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, bypassing createUserName.
  • 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 ContactInfos 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:

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)

Discussion

pic
Editor guide
Collapse
le0nidas profile image
le0nidas

That is a clever usage of interfaces!

I recently had to deal with a similar case while refactoring some legacy code.
My approach to create valid entities was achieved via factory functions. In a nutshell you can have a

fun UserName(value: String): UserName? {
    return if (value.isEmpty()) null else UserName(value)
}

which tricks the dev to think that is using a constructor but at the same time keeps everything valid and readable.

If you are interested I wrote a blog post here.

Collapse
psfeng profile image
Pin-Sho Feng Author

Interesting blog post, thanks! I like that increasingly more people are aware of this problem :)

As for the suggestion, I'd advise against doing it that way because it goes against the conventions:

  • Naming: function names should start with a lowercase letter, see here.
  • From outside it looks like a constructor and I wouldn't expect a constructor to return null.

My 2 cents.

Collapse
le0nidas profile image
le0nidas

Both arguments are valid. Especially the second one didn't even crossed my mind. Thank you!

Collapse
patroza profile image
Patrick Roza

Big fan of such (Functional) domain modelling.
What's your take on the Null vs Option vs Either?
From my perspective:

  • Null and Option have a similar semantics; they both can model the availability or absence of a value
  • Either can help you document the error cases in more detail to expose what is wrong and perhaps have decisions made based on that, or to report it back to the user.

My interest is in when to choose Null or Option. I'm facing the dilemma in Typescript; if I don't need the resolution of Either, shall I use Option or just keep it simple with Null...
That is ignoring for a moment that we also have undefined ;-)

In languages without good type support, null can be a real pain, but in Typescript and I suppose Kotlin, things are better. So I guess it just is a matter of null can make it more difficult to compose, yet easier to consume as you only need to assert, not unwrap like Option/Either.

Collapse
psfeng profile image
Pin-Sho Feng Author

I've had the exact same dilemma and for the moment my loosely-held conclusion is that Option is unnecessary in Kotlin. Perhaps if you use Arrow it provides syntax sugar with monad comprehensions but I wouldn't say that's a good enough reason to use it over built-in nullables.

The only case where I think using Option or Optional has an advantage is when your code needs to be used from Java and you want to keep null-safety. Would this apply to Typescript and Javascript?

Collapse
cubiclebuddha profile image
Cubicle Buddha

Great article. However, I will say if you want first party support for unions it’s likely better to use a language that has unions like TypeScript.

Collapse
psfeng profile image
Pin-Sho Feng Author

I'd say that multiple reasons need to be evaluated before choosing one language or another :)