Handle process statuses with Kotlin Sealed Classes
Do you have scenarios where different handling needs to be done according to the status of a process? Something like:
data class Result(
val isSuccess: Boolean,
val value: String?, // non-null when isSuccess is true
val errorMessage: String? // non-null when isSuccess is false
)
private fun process(): Result {
return ... // do something and return a Result
}
fun main() {
val result = process()
val text: String = if (result.isSuccess) {
println(result.value!!)
} else {
println(result.errorMessage!!)
}
println(text)
}
This approach has several shortfalls:
- The consumer of
Result
always has to remember to check theisSuccess
attribute before handling it, otherwise it may access the wrong attribute. - The
Result
class contains the union of all the attributes of all the possible statuses (in this case, success or failure). It’s not possible to know which attributes belong to which status, and which attributes are present. We’ll have to use comments. - The non-status attributes have to be nullable, or have some default/dummy value.
- Since the attributes are nullable, some sort of null handling needs to be done (ie, we still need to handling nulls even though we may be sure that when we get a success result, the value will never be null)
If we extend a bit further, let’s say that Result
can have 3 status types:
enum class Status {
SUCCESS,
FAILURE,
PENDING
}
data class Result(
val status: Status,
val value: String?, // non-null when SUCCESS
val errorMessage: String? // non-null when FAILURE
)
private fun process(): Result {
return ... // do something and return a Result
}
fun main() {
val result = process()
val text: String = when(result.status) {
Status.SUCCESS -> result.value!!
Status.FAILURE -> result.errorMessage!!
Status.PENDING -> "no attributes are available"
else -> "undefined status"
}
println(text)
}
This has even more shortfalls:
- We now need to define an enum
Status
to maintain the possible statuses - We need an
else
clause in thewhen
conditional, even though we know that we have handled all the statuses and theelse
clause will never be reached.
There’s a simpler, more elegant approach using Sealed Classes. The description in the documentation is quite confusing, so hopefully this is clearer: Sealed Classes are a set of parent/children classes that must be defined on the same file, the usual superclass/subclass relationship and restrictions apply. The parent class is defined with a sealed
modifier. Due to this, the complier “knows” the exhaustive list of children classes and can perform some magic as we will see. As a result, the children classes share similar traits as enums.
With Sealed Classes, we can make the following changes to the code above.
- 1) Since they act like enums, we don’t need another
enum class
. We can defineResult
as asealed class
and then represent the status as its children. - 2) As each status is a subclass, it can define attributes specific to itself, including defining the nullability.
sealed class Result
data class Success(val value: String) : Result()
data class Failure(val errorMessage: String) : Result()
object Pending : Result()
- 3) The consumer of
Result
is now mandated by the compiler to check for statuses, otherwise it only has aResult
class with no attributes - 4) The actual check is simplified as the compiler is smarter now — it knows the full list of possible statuses.
- 5) Kotlin has smart-casts, and paired with the
when
clause, we can access the attributes directly, and without unnecessary null checks. More importantly, there is no way to access the wrong attribute accidentally.
val text: String = when(result) {
is Success -> result.value
is Failure -> result.errorMessage
is Pending -> "no attributes are available"
}
Pros
- We got rid of the redundant
Status
class. The status metadata is now “encoded” into the type itself. - We got rid of redundant null types! It is also very clear now which status owns which attributes, and it is immediately apparent to the consumer which attributes are available.
- The consumer is forced by compiler to check the status, it doesn’t have the chance to forget and misuse attributes.
- If we add a new status, the compiler will force us to add it to the
when
clause. Previously when using enums, we may forget to check the new status because the compiler doesn’t care! - The code is slightly less verbose and slightly more fluent. Compare reading
when result.status == Status.SUCCESS
vswhen result is Success
. - Since each status is a class by itself, we can define different functions on it, or implement different behaviour without further conditional checks for status. (Eg, not saying it’s a good idea, but we could implement different
toString()
for each status)
Full code with sealed classes
sealed class Result
data class Success(val value: String) : Result()
data class Failure(val errorMessage: String) : Result()
object Pending : Result()
private fun process(): Result {
return ... // do something and return a Result
}
fun main() {
val result = process()
val text: String = when(result) {
is Success -> result.value
is Failure -> result.errorMessage
is Pending -> "no attributes are available"
}
println(text)
}