A smooth refactor using sealed classes and a factory function

The problem

Lets say we have a contacts app and one of the screens shows the contact’s phone number.

// domain:
class PhoneNumber(val value: String)
class Contact(val phoneNumber: PhoneNumber)
// screen:
class PhoneNumberScreen(
private val phoneNumber: PhoneNumber
) {
fun render() {
println("Phone number: ${phoneNumber.value}")
}
}
// presentation layer:
fun main() {
val contact = Contact(PhoneNumber("12345"))
show(contact.phoneNumber) // "Phone number: 12345"
val contactWithInvalidPhoneNumber = Contact(PhoneNumber(""))
show(contactWithInvalidPhoneNumber.phoneNumber) // "Phone number: "
}
private fun show(phoneNumber: PhoneNumber) {
val phoneNumberScreen = PhoneNumberScreen(phoneNumber)
phoneNumberScreen.render()
}

The problem with that code is that we can easily end up with instances that contain invalid state:

A phone number screen with an empty phone number!

Approach #1:

One way to prevent it is to add some logic in the presentation layer:

Open the phone number screen only if the phone number is not empty

fun main() {
val contact = Contact(PhoneNumber("12345"))
show(contact.phoneNumber) // "Phone number: 12345"
val contactWithInvalidPhoneNumber = Contact(PhoneNumber(""))
show(contactWithInvalidPhoneNumber.phoneNumber) // does not show anything
}
private fun show(phoneNumber: PhoneNumber) {
if (phoneNumber.value.isEmpty()) {
return
}
val phoneNumberScreen = PhoneNumberScreen(phoneNumber)
phoneNumberScreen.render()
}

Unfortunately this approach does not provide an actual solution but a patch. Our main goal is to have a PhoneNumberScreen that handles ONLY valid phone numbers.

Approach #2:

What we need is to move the necessary checks inside the PhoneNumberScreen class.

We could check the number’s validity on render and show some kind of message when there is no phone number.

class PhoneNumberScreen(
private val phoneNumber: PhoneNumber
) {
fun render() {
if (phoneNumber.value.isNotEmpty()) {
println("Phone number: ${phoneNumber.value}")
} else {
println("Invalid phone number")
}
}
}
fun main() {
val contact = Contact(PhoneNumber("12345"))
show(contact.phoneNumber) // "Phone number: 12345"
val contactWithInvalidPhoneNumber = Contact(PhoneNumber(""))
show(contactWithInvalidPhoneNumber.phoneNumber) // "Invalid phone number"
}
private fun show(phoneNumber: PhoneNumber) {
val phoneNumberScreen = PhoneNumberScreen(phoneNumber)
phoneNumberScreen.render()
}

It is quite clear that this solution provides a bad UX. Why open a screen when the user cannot use it? Also, in any additional usage of phoneNumber inside the PhoneNumberScreen we need to make the same check as in render() and handle both of its states.

Approach #3:

What we really need is to make sure that if the screen is created, then it is certain that it has a valid phone number. There are two ways to achieve that. The first one is by checking upon creation that the phone number is valid and throw an exception if it is not.

class PhoneNumberScreen(
private val phoneNumber: PhoneNumber
) {
init {
require(phoneNumber.value.isNotEmpty()) { "cannot handle invalid phone number" }
}
fun render() {
println("Phone number: ${phoneNumber.value}")
}
}
fun main() {
val contact = Contact(PhoneNumber("12345"))
show(contact.phoneNumber) // "Phone number: 12345"
val contactWithInvalidPhoneNumber = Contact(PhoneNumber(""))
show(contactWithInvalidPhoneNumber.phoneNumber) // prints nothing
}
private fun show(phoneNumber: PhoneNumber) {
try {
val phoneNumberScreen = PhoneNumberScreen(phoneNumber)
phoneNumberScreen.render()
} catch (ex: IllegalArgumentException) {
}
}

It works but we need to document it and add a try-catch wherever we create a screen instance.

The second one is by having a helper function that creates the screen only if the phone number is valid.

class PhoneNumberScreen private constructor(
private val phoneNumber: PhoneNumber
) {
fun render() {
println("Phone number: ${phoneNumber.value}")
}
companion object {
fun create(phoneNumber: PhoneNumber): PhoneNumberScreen? {
return when {
phoneNumber.value.isNotEmpty() > PhoneNumberScreen(phoneNumber)
else > null
}
}
}
}
fun main() {
val contact = Contact(PhoneNumber("12345"))
show(contact.phoneNumber) // "Phone number: 12345"
val contactWithInvalidPhoneNumber = Contact(PhoneNumber(""))
show(contactWithInvalidPhoneNumber.phoneNumber) // prints nothing
}
private fun show(phoneNumber: PhoneNumber) {
val phoneNumberScreen = PhoneNumberScreen.create(phoneNumber)
phoneNumberScreen?.render()
}

This also works as expected but once again we need to document it and on top of that handle any null values returned by the helper function.

Nevertheless this solution seems fine and for a very small code base is quite acceptable. The problem is that it does not scale alongside the code base. Imagine how many helper functions we need to implement every time we have to use a phone number instance in our components if we want to keep them “clean”.

The actual problem

The actual problem lies in the PhoneNumber itself. It represents more than one states and each time we use an instance of it we must “dive” in its value and translate it to that state.

What we really need is a better representation of a valid and invalid phone number.

Final approach

This is where we use sealed classes and separate the two states:

sealed class PhoneNumber
object InvalidPhoneNumber : PhoneNumber()
data class ValidPhoneNumber(val value: String) : PhoneNumber() {
init {
require(value.isNotEmpty()) { "the number cannot be empty" }
}
}

This way we accomplish our main goal: we change the PhoneNumberScreen to accept only instances of ValidPhoneNumber and can now be certain that the screen will be used only with valid data. The code is self documented and any further development in the screen class will not have to consider other states for the phone number:

class PhoneNumberScreen(
private val phoneNumber: ValidPhoneNumber
) {
fun render() {
println("Phone number: ${phoneNumber.value}")
}
}

One big drawback of this change is that every usage of the PhoneNumber class has just broke (see: creation of Contact instances).

Fortunately there is a quick solution for that! Factory function:

A function that has the same name with the previously used class (PhoneNumber) and takes a single string parameter. If the parameter is not empty it returns a ValidPhoneNumber. In any other case it returns an InvalidPhoneNumber:

fun PhoneNumber(value: String): PhoneNumber =
when {
value.isNotEmpty() > ValidPhoneNumber(value)
else > InvalidPhoneNumber
}

The end result is almost the same as the starting point but this time we have clear states and components that can guarantee that they will not crash because of erroneous data:

fun main() {
val contact = Contact(PhoneNumber("12345"))
show(contact.phoneNumber) // "Phone number: 12345"
val contactWithInvalidPhoneNumber = Contact(PhoneNumber(""))
show(contactWithInvalidPhoneNumber.phoneNumber) // prints nothing
}
private fun show(phoneNumber: PhoneNumber) {
if (phoneNumber is ValidPhoneNumber) {
val phoneNumberScreen = PhoneNumberScreen(phoneNumber)
phoneNumberScreen.render()
}
}

2 thoughts on “A smooth refactor using sealed classes and a factory function

  1. I really like your final solution, especially the trick with creating a helper function that’s just named as the original class. In general, it was really helpful how you introduced your solution step by step and I will totally use your approach in the next project. Thanks a lot!

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s