A few weeks ago we looked at one of my pain points of working with Kotlin Data Classes: nested copy and we improved on a basic Kotlin solution using a Lens. Today we look at another type of nesting: Sealed Classes nesting.

When we wanted to create some sort of restricted hierarchy in Java we were using enum. They are kinda OK for a few things, and at the time we had only Java so 🤷🏻‍♂️

The Kotlin way

When Kotlin came in, it brought a new concept: Sealed Classes. These type of classes are known also as “Choice types“, if you are familiar with Scott Wlaschin’s work, or “Sum types“, if you prefer a more mathematical definition. I like “choice types” more, because I feel it conveys the idea in a better way:

You have an object that can be one of a few possible choices, i.e. a type in a small set of possible types.

A classic example for a Sealed Class can be the PhoneNumber class:

sealed class PhoneNumber {
    data class Mobile(val mobile: String) : PhoneNumber()
    data class Office(val office: String) : PhoneNumber()
    data class Home(val home: String) : PhoneNumber()
}

If you pair it with a data class of sort, you can start modelling your Domain:

sealed class PhoneNumber {
    data class Mobile(val mobile: String) : PhoneNumber()
    data class Office(val office: String) : PhoneNumber()
    data class Home(val home: String) : PhoneNumber()
}

data class User(
    val name: String,
    val phone: PhoneNumber
)

Our user has a name and a phone. The name is a String and the phone is a PhoneNumber. In reality it could be any of these three possible choices: Mobile, Office or Home.

One thing that I love about sealed classes is that they make your code more deterministic and they can leverage the Kotlin compiler to help you avoiding subtle bugs. A nice example is an exhaustive when(){}

val bob = User(
        name = "Bob",
        phone = PhoneNumber.Mobile("123456789")
)

val number = when (bob.phone) {
    is PhoneNumber.Mobile -> bob.phone.mobile
    is PhoneNumber.Office -> bob.phone.office
    is PhoneNumber.Home -> bob.phone.home
}
println(number)

We created a new User called Bob and we added Bob’s mobile phone number. Not knowing if the PhoneNumber is Mobile, Office or Home, to extract the number from the data class, we use a when.

We also assigned the value from the when so the compiler is helping us if something is off:

We are missing a choice and the compiler is helping us to see it. I love it! 😍

When things get real

The previous one was more of a toy example. In reality I have seen more often classes like this one:

sealed class ContactInformation {
    data class Email(val value: String) : ContactInformation()

    sealed class Phone : ContactInformation() {
        data class MobilePhone(val number: String) : Phone()
        data class OfficePhone(val number: String) : Phone()
        data class HomePhone(val number: String) : Phone()
    }

    data class HomeAddress(val street: String) : ContactInformation()
    object Pigeon : ContactInformation()
}

data class UserProfile(
    val name: String,
    val contact: ContactInformation
)

Our UserProfile class has a contact: ContactInformation property and ContactInformation is a sealed class with 4 choices. Inside this class we have Phone with three more choices.

If you look closely to your code base I bet you will find something like this, especially in the network layer where we convert JSON to our Kotlin DTOs.

This looks like a good design and, apart from the Pigeon thing, it looks legit. I might need to have a chat about that with my backend colleagues 😂

Modelling is nice and everything, but what if I have a “Frank” object

val frank = UserProfile(
    name = "Frank",
    contact = ContactInformation.Phone.MobilePhone("0987654321")
)

and I want to modify the MobilePhone number? Well, you will probably start checking types all the way down through the rabbit hole and end up with something like this:

val updatedFrank: UserProfile = when (frank.contact) {
        is ContactInformation.Email -> frank
        is ContactInformation.HomeAddress -> frank
        is ContactInformation.Phone -> when (frank.contact) {
            is ContactInformation.Phone.HomePhone -> frank
            is ContactInformation.Phone.MobilePhone -> frank.copy(contact = ContactInformation.Phone.MobilePhone("123456789"))
            is ContactInformation.Phone.OfficePhone -> frank
        }
        ContactInformation.Pigeon -> frank
    }

Nested whens are a beast 😕 You can probably do the same thing using if-else, but it would look even more horrible. As always, this works and it’s perfectly fine, but lately in my projects I’m using a better tool, I’m using a Prism:

val updatedFrankUsingPrism = UserProfile.contact.phone.mobilePhone.modify(frank) { MobilePhone("123456789") }

Now, if you are wondering if that’s it and it’s one line, I can tell you “Yes, it’s one line and that’s it” 😂

How do we get there?

The hilarious part is that we are already there because a couple of weeks ago we started using Arrow Optics, so now our data class and sealed class look like this:

@optics
sealed class ContactInformation {
    companion object {}

    data class Email(val value: String) : ContactInformation()

    @optics
    sealed class Phone : ContactInformation() {
        companion object {}

        @optics
        data class MobilePhone(val number: String) : Phone() { companion object }

        @optics
        data class OfficePhone(val number: String) : Phone() { companion object }

        @optics
        data class HomePhone(val number: String) : Phone() { companion object }
    }

    data class HomeAddress(val street: String) : ContactInformation()
    object Pigeon : ContactInformation()
}

@optics
data class UserProfile(val name: String, val contact: ContactInformation) { companion object }

and this is all we need to start using a Prism. Simply let your IDE help you explore the autocompletion and everything is gonna be there.

A little more

The idea behind a Prism is that we want to focus on a special case of the sealed class. Prism lets you do exactly that without the hassle of writing all those whens manually.

Let’s see how we can use everything we learned so far. At some point somebody gives us a joe: UserProfile object and we don’t really know what’s inside. We know how UserProfile looks like: it has a name property and contact property.

We want to create a copy of Joe where their contact is their mobile phone. So we start typing and our IDE starts helping us until we hit the harsh reality:

Shit! There is no .copy() method for Sealed Classes! 🤦🏻‍♂️

val updateJoe = joe.copy(contact = joe.contact. TAB TAB TAB 😭)

We are back to our when and we will probably end up with something horrible like this:

val phone = when (joe.contact) {
    is ContactInformation.Email -> joe.contact
    is ContactInformation.Phone.MobilePhone -> MobilePhone("1234567890")
    is ContactInformation.Phone.OfficePhone -> MobilePhone("1234567890")
    is ContactInformation.Phone.HomePhone -> MobilePhone("1234567890")
    is ContactInformation.HomeAddress -> joe.contact
    ContactInformation.Pigeon -> joe.contact
}
val updateJoe: UserProfile = joe.copy(contact = phone)
println("Mobile Joe with when: $updateJoe")

Or we can use what we learned so far:

val mobileJoe = UserProfile.contact.phone.modify(joe) { MobilePhone("1234567890") }

Yep, one line. I’m telling you, immutability and robust code don’t need to be painful. We have incredible tools provided by JetBrains, 47Deg and the Kotlin community. We only need to be brave and keep learning new things every day.

Happy learning 😉