Philipp Hauer's Blog

Engineering Management, Java Ecosystem, Kotlin, Sociology of Software Development

Sealed Classes Instead of Exceptions in Kotlin

Posted on Feb 5, 2019. Updated on Jun 12, 2022

Exceptions are a common mean to handle errors. However, they have some drawbacks when it comes to compiler support, safety and traceability. Fortunately, we can leverage Kotlin’s sealed classes to create result objects that solve the mentioned problems. This way, we get great compiler support and the code becomes clean, less error-prone, easy to grasp and predictable.

TL;DR

Exceptions vs (Sealed) Result Classes.

Exceptions vs (Sealed) Result Classes.

  • Code with exceptions can become more error-prone, less predictable and harder to understand, especially when multiple exceptions are involved.
  • Instead of throwing an exception, you can return different result objects representing either “success” or “error”. Utilizing Kotlin’s sealed classes for this result class hierarchy is powerful.
  • Benefits:
    • Safety and fewer errors. The compiler guides us to handle the error cases.
    • Readability due to Kotlin’s concise when expression and self-explanatory names of the result classes.
    • Traceability. The execution flow becomes straight-forward and easy to understand.
    • Failure Awareness. We put the emphasis on the case of failure and proper handling.
    • Better support for FP idioms.
  • It’s useful to define a generic sealed result class hierarchy which can be reused in most cases.
  • The real power of sealed classes (the compiler checking that you’ve covered all cases) is only unleashed if when is used as an expression. You can define a small helper property in order to turn every when into an expression.
  • We use sealed result classes for calls to remote systems, where failure is likely and the error handling is more sophisticated than just “log & exit”. In other cases, exceptions are still doing a good job.

Drawbacks of Exceptions

Let’s assume that client.requestUserProfile() calls an HTTP endpoint to retrieve a user’s profile.

val avatarUrl = client.requestUserProfile(userId).avatarUrl

What’s wrong with this code?

It’s error-prone. Do you know how to handle a failing request? The compiler won’t force you to handle the error cases, because Kotlin has only unchecked exceptions. It’s so easy to forget to catch the correct exceptions. And even if you mind that you have to catch an exception, you have to look up the exceptions name in the method implementation or documentation:

@Throws(UserProfileClientException::class) // this (or javadoc) may help to document the exception...
fun requestUserProfile(userId: String): UserProfileDTO = try {
    restTemplate.getForObject<UserProfileDTO>("http://domain.com/userProfiles/$userId")!!
} catch (ex: IOException) {
    throw UserProfileClientException(
        message = "Server request failed due to an IO exception. Id: $userId, Message: ${ex.message}",
        cause = ex
    )
} catch (ex: RestClientException) {
    throw UserProfileClientException(
        message = "Server request failed. Id: $userId. status code: ${(ex as? RestClientResponseException)?.rawStatusCode}. body: ${(ex as? RestClientResponseException)?.responseBodyAsString}",
        cause = ex
    )
}

class UserProfileClientException(message: String, cause: Exception? = null) : RuntimeException(message, cause)

Ah, we have to catch a UserProfileClientException! At least, this is the only one we have to take care of.

val avatarUrl = try {
    client.requestUserProfile(userId).avatarUrl
} catch (ex: UserProfileClientException) {
    "http://domain.com/defaultAvatar.png"
}

That was easy. Let’s take a look at an example which is more complex and closer to the reality.

try {
    val profile = client.requestUserProfile(userId)
    try {
        val image = client.downloadImage(profile.avatarUrl)
        processImage(image)
    } catch (ex: ImageDownloadException) {
        queueForRetry(userId, ex.message)
    }
} catch (ex: UserProfileClientException) {
    showMessageToUser(userId, ex.message)
} catch (ex: SuspiciousException) {
    // which method throws this exception?
    // requestUserProfile()? downloadImage()? processImage()? queueForRetry()?
}
// have we forgot to catch an exception? Who knows.

Again, what’s wrong here?

  • The code is hard to read due to the try-catch-ceremony.
  • It’s not obvious anymore which exception comes from which method. Yes, good naming can help here.
  • It’s hard to say if we have caught all possible exceptions. It’s easy to introduce a bug here.
  • It becomes hard to follow the execution flow because it can stop at some point and continues at another point (and depth in the call stack!). Code with multiple exceptions which are thrown and caught at multiple points can lead to code which is hard to understand. I often had to jump forward and back in our code base to understand the path that an exception has made.

Sealed Classes to the Rescue!

Let’s introduce a dedicated result object UserProfileResult to model a successful or failed remote call. Now, requestUserProfile() returns an instance of the UserProfileResult instead of the actual value. UserProfileResult can either be a Success or an Error.

// Definition
sealed class UserProfileResult {
    data class Success(val userProfile: UserProfileDTO) : UserProfileResult()
    data class Error(val message: String, val cause: Exception? = null) : UserProfileResult()
}

fun requestUserProfile(userId: String): UserProfileResult = try {
    val userProfile = restTemplate.getForObject<UserProfileDTO>("http://domain.com/userProfiles/$userId")
    UserProfileResult.Success(userProfile = userProfile)
} catch (ex: IOException) {
    UserProfileResult.Error(
        message = "Server request failed due to an IO exception. Id: $userId, Message: ${ex.message}",
        cause = ex
    )
} catch (ex: RestClientException) {
    UserProfileResult.Error(
        message = "Server request failed. Id: $userId. status code: ${(ex as? RestClientResponseException)?.rawStatusCode}. body: ${(ex as? RestClientResponseException)?.responseBodyAsString}",
        cause = ex
    )
}

This way, the compiler forces us to handle the error case. Kotlin’s when statement is extremely handy to handle the different cases concisely.

// Usage
val avatarUrl = when (val result = client.requestUserProfile(userId)) {
    is UserProfileResult.Success -> result.userProfile.avatarUrl
    is UserProfileResult.Error -> "http://domain.com/defaultAvatar.png"
}

The solution for the more complicated example could look like this:

when (val profileResult = client.requestUserProfile(userId)) {
    is UserProfileResult.Success -> {
        when (val imageResult = client.downloadImage(profileResult.userProfile.avatarUrl)){
            is ImageDownloadResult.Success -> processImage(imageResult.image)
            is ImageDownloadResult.Error -> queueForRetry(userId, imageResult.message)
        }
    }
    is UserProfileResult.Error -> showMessageToUser(userId, profileResult.message)
}

What are the benefits?

  • Less error-prone due to type-safety and compiler enforcement.
    • First, the introduction of a result object points us directly to the error case. It’s obvious, that something can get wrong here and how we handle that. It’s way harder to forget.
    • Second, the compiler forces us to handle all cases**. Leaving out the Error case will produce a compile error. That’s great.
    • If we introduce a third result type (like TemporaryUnavailable) all existing when expressions will create a compile error.
  • Readability. Due to the nice naming and the concise when statement, it’s really easy to understand what’s going on here.
  • Traceability and Predictability. The execution flow of the code is easy to grasp because it doesn’t jump anymore in case of errors. The flow is like we read the code: from top to down.
  • Modeling the result of a remote call as a dedicated class.
    • Having an Error object nicely reflects the reality and highlights the importance of error handling. We immediately start to think about the behavior in case of a failure. Error handling becomes a first-class citizen of the business logic.
    • We can pass them around (e.g. for putting them in a retry queue).
    • Better support for functional programming idioms. For instance, requestUserProfile() always returns a value. Nothing else can happen (like throwing an exception). This makes it easier to use it in lambdas and Kotlin’s collection API.
    • Batch processing and parallelization of multiple requests are also easier to implement (compared with methods throwing exceptions).

Generic Result Classes

Having specific result classes like UserProfileResult for each call has some advantages: It’s more readable as is contains domain names, we can add custom properties to the sealed classes and add more subclasses beyond Success and Error. However, in reality, most of these result class hierarchies look pretty similar. So it makes sense to introduce a generic class hierarchy.

sealed class Outcome<out T : Any> {
    data class Success<out T : Any>(val value: T) : Outcome<T>()
    data class Error(val message: String, val cause: Exception? = null) : Outcome<Nothing>()
}

We decided to go with the name Outcome. The name Result is better but is already used by kotlin.Result which leads to annoying import conflicts in daily work.

Be Exhaustive

The superpower of sealed classes is only unleashed if when is used as an expression. Otherwise, the compiler doesn’t force us to handle all cases:

// when is not an expression here.
when (val result = client.requestUserProfile(userId)) {
    is UserProfileResult.Success -> processUserProfile(result.userProfile)
    // NO compile error although we didn't handle UserProfileResult.Error!
}

But there is a little trick, which can be applied to enforce that a when statement is exhaustive. For this, let’s define the generic property exhaustive.

val <T> T.exhaustive: T
    get() = this

Now, we can make every when statement exhaustive:

// compile error: 
// "'when' expression must be exhaustive, add necessary 'is Error' branch or 'else' branch instead"
when (val result = client.requestUserProfile(userId)) {
    is UserProfileResult.Success -> processUserProfile(result.userProfile)
}.exhaustive // !

Alternatively, you can use the damn-it operator !! instead of the exhaustive property. However, this will trigger a warning in IntelliJ IDEA: “Unnecessary non-null assertion (!!) on a non-null receiver of type Unit”

Variations

Consider Singleton objects as a Subtype

We can also use a singleton as a subtype of the sealed class. This is efficient in cases when the concrete type has no state.

sealed class RatingResult {
    data class RatingTypeA(val dataA: DataA) : RatingResult()
    data class RatingTypeB(val dataB: DataB) : RatingResult()
    object NoRatingYet : RatingResult()
}

Enums Can Also Do the Trick

If all result types have no state, you can consider to use enums instead. They are simpler and have the same advantages when used in a when expression.

enum class ImageAvailabilityResult {
    OK,
    TEMPORARY_UNAVAILABLE,
    UNAVAILABLE
}

Utilize Properties in Result Objects

I like to emphasize that we can put any domain properties to a concrete result. We often use enums for the properties to transport additional information about the result type. So we are still able to use the powerful when expressions.

For instance, let’s assume that the method LdapDAO.authenticate(username, password): AuthenticationResult does the authentication and authorization against an LDAP. The result object might look like this:

sealed class AuthenticationResult {
    data class Success(val group: LdapGroup) : AuthenticationResult()
    data class Failure(val reason: FailureReason) : AuthenticationResult()

    enum class LdapGroup {
        READONLY,
        NORMAL,
        ADMIN
    }
    enum class FailureReason {
        BLANK_USER_OR_PW,
        INVALID_USER_OR_PW,
        USER_IS_NOT_IN_GROUP,
        CONNECTION_ISSUES
    }
}

It’s so much fun to use these data structures:

when (val result = ldapDAO.authenticate(name, password)) {
    is Success -> createSession(name, result.group.toAuthority())
    is Failure -> displayMessageToTheUser(result.reason)
}

// Authorities are a Spring Security concept
private fun LdapGroup.toAuthority() = when (this) {
    LdapGroup.ADMIN -> SimpleGrantedAuthority("ROLE_ADMIN")
    LdapGroup.NORMAL -> SimpleGrantedAuthority("ROLE_NORMAL")
    LdapGroup.READONLY -> SimpleGrantedAuthority("ROLE_READONLY")
}

Bye Bye Exceptions?

No. We still need exceptions. For me, always using result objects for every method call is clumsy. Moreover, things can get ugly, when you have to propagate the result object through multiple layers of the call stack. In these cases, you have to adapt the signatures of many methods. This is where (unchecked) exceptions are still very useful.

I reckon this is a matter of taste. My personal rule of thumb is:

  • Use result objects for remote calls (e.g. HTTP requests) or cases where the error handling is more sophisticated than just “log & exit”. For instance, when we call an external system. Distributed systems are not reliable and can be unavailable or don’t respond fast enough. So the failure case should be our first class citizen and we should pay extra attention to this. Modeling the result of a call as dedicated classes reflect this requirement as the compiler forces us to handle failing requests.
  • By default, use exceptions for everything else. If our database is not available there is usually nothing else we can do except “log & exit”. No reason to put additional emphasis on the error handling by modeling a dedicated result object. Moreover, in my experience, the application’s database is less likely to be unavailable (compared with a remote service). Besides, as we are usually querying the database more frequently than we do remote calls, the code base would be full of when statements with an error case that just do “log & exit”.

You might disagree with me and I’m happy to hear your opinion! Please drop a comment.

Thoughts about Sealed Classes and Checked Exceptions

In Java, I highly suggested using unchecked instead of checked exceptions. I argued that, with checked exception, the compiler forces you to handle the exception which can be annoying. Now, with sealed exception, I praise the compiler support as a great advantage. Have I changed my mind? Well, I believe that compiler support is a great thing. But checked exception and sealed result classes are different. I claim that the price of the compiler support with checked exception is too high:

  • Checked (and also unchecked) exceptions require the clumsy try-catch ceremony. Especially multiple nested try-catch blocks are hard to understand. The handling of sealed classes with when is much more concise and easier to grasp.
  • Exceptions are hard to use with functional programming idioms. In Java, using checked exceptions in conjunction with the Stream API is a pain. Sealed classes work perfectly fine with collection APIs.
  • With exceptions, the execution flow can jump and be hard to track. That’s not the case with sealed classes.
  • With sealed classes, the error handling becomes your first class citizen. It’s part of the main path; not placed somewhere else in a catch block.

When I really like to handle an error somewhere higher in the call stack, I don’t want to change all method signatures in between. Unchecked exceptions nicely cover this requirement. They are also useful for “log & exit” handlings. In this cases, the compiler enforcement would just bug me.

Moreover, the discussion “checked exceptions or sealed classes” is highly theoretical because Kotlin doesn’t have checked exceptions.

Contribution and Links