The power of sealed interfaces in Kotlin
I’m a strong advocate of the separation of logic principle. In the service layer, we often deal with various I/O operations and interactions with external systems. Not only is it essential to correctly handle exceptions but also make a clear distinction between successful responses and different error scenarios. Kotlin offers sealed classes and interfaces as a powerful tool to establish a predefined hierarchy of subtypes. In this post, I will show you how this feature has proven invaluable, allowing me to keep my code concise without the burden of excessive boilerplate.
Table of Contents
- The Result Object
- Representing Success and Error States
- Abstracting The Result Object
- Representing Numerous Error States
- All Together
- Summary
The Result Object
What I have missed in Java for the longest time was the ability to easily represent various states of an operation. Instead of (re-)throwing exceptions, I’d prefer representing the operation’s state with data, specifically a Result
object. At a minimum, the result of an operation can be either a Success
or a Failure
.
What immediately caught my attention when I first started experimenting with Kotlin was the built-in Result class. However, after the initial excitement, I realised that this might not the best approach due to certain limitations. In essence, the Result
can be expensive to instantiate and it lacks flexibility in terms of representing error state. If you are curious to learn more, I recommend going through this discussion on Redit.
I quickly moved on and began exploring the benefits of sealed classes and interfaces. In the world of Java, this concept was introduced as a preview feature in Java 15 and became fully available as of Java 17 (JEP 409). Kotlin allowed me to adopt this pattern much sooner.
Representing Success and Error States
Consider a classic example, a calculator.
interface Calculator {
fun multiply(a: Int, b: Int): MultiplicationResult
fun divide(a: Int, b: Int): DivisionResult
}
sealed interface MultiplicationResult {
data class Success(val response: Long) : MultiplicationResult
data class Failure(val reason: String) : MultiplicationResult
}
sealed interface DivisionResult {
data class Success(val response: Double) : DivisionResult
data class Failure(val reason: String) : DivisionResult
}
Instead of returning the calculated value and potentially throwing an exception, the outcome is wrapped into a custom Result
object. To avoid writing repetitive try-catch
blocks I’ve created a utility function that automatically converts any exception into a Failure
.
fun <T> tryRun(onError: (String) -> T, block: () -> T): T {
return try {
block()
} catch (t: Throwable) {
onError(t.message ?: "Unknown error")
}
}
Equipped with the Result
construct and an automatic exception handler, the implementation of a Calculator
looks as follows.
class CalculatorImpl : Calculator {
override fun multiply(a: Int, b: Int): MultiplicationResult =
tryRun(MultiplicationResult::Failure) {
MultiplicationResult.Success(a * b.toLong())
}
override fun divide(a: Int, b: Int): DivisionResult =
tryRun(DivisionResult::Failure) {
if (b == 0) {
return@tryRun DivisionResult.Failure("Division by zero")
} else {
DivisionResult.Success(a.toDouble() / b)
}
}
}
As you can see both of the operations leverage the exception handler and wrap the computed value into a Success
object. The division operation validates the input and instead of throwing an exception it explicitly returns a Failure
.
I appreciate the simplicity of this approach, but the drawback is the repetition in code. Creating a dedicated result class for each individual operation can feel like overkill.
Abstracting The Result Object
The pattern of returning either a wrapped value or an error message is so common that it makes sense to abstract it and reuse a single Result
construct.
sealed interface CalculationResult<out T> {
data class Success<T>(val response: T) : CalculationResult<T>
data class Failure(val reason: String) : CalculationResult<Nothing>
}
Now I can reuse it in both of my calculator operations.
class CalculatorImpl : Calculator {
override fun multiply(a: Int, b: Int): CalculationResult<Long> =
tryRun(::Failure) {
Success(a * b.toLong())
}
override fun divide(a: Int, b: Int): CalculationResult<Double> =
tryRun(::Failure) {
if (b == 0) {
return@tryRun Failure("Division by zero")
} else {
Success(a.toDouble() / b)
}
}
}
Representing Numerous Error States
Imagine you’re developing a service that interacts with an external API. Many things can go wrong, and it’s beneficial to represent various failure states. With reusability in mind, I’ve created a single ApiCallResult
class that is generic enough and well-suited for HTTP communication.
sealed interface ApiCallResult<out T> {
data class Success<T>(val data: T) : ApiCallResult<T>
data class Failure(val reason: String) : ApiCallResult<Nothing>
object BadRequest : ApiCallResult<Nothing>
object Unauthorized : ApiCallResult<Nothing>
object NotFound : ApiCallResult<Nothing>
}
Since the ApiCallResult
will be used across various API calls it pays off to write an extension that converts a HTTP response into the result object.
import io.ktor.client.statement.HttpResponse
suspend inline fun <reified T> HttpResponse.toApiCallResult(): ApiCallResult<T> =
when (this.status.value) {
200 -> ApiCallResult.Success(this.body())
400 -> ApiCallResult.BadRequest
401 -> ApiCallResult.Unauthorized
404 -> ApiCallResult.NotFound
else -> ApiCallResult.Failure("Unknown error")
}
This allows my service to easily interpret a result of an API call without having to work with low-level HTTP details.
Similarly, each of the Result
outcomes knows how to convert back to an HTTP response.
suspend fun ApplicationCall.success(data: Any? = null) =
data?.let { this.respond(HttpStatusCode.OK, it) } ?: this.respond(HttpStatusCode.OK)
suspend fun ApplicationCall.failure(reason: String = "Unexpected error") =
this.respond(HttpStatusCode.InternalServerError, reason)
suspend fun ApplicationCall.badRequest() =
this.respond(HttpStatusCode.BadRequest)
suspend fun ApplicationCall.unauthorized() =
this.respond(HttpStatusCode.Unauthorized)
All Together
To cover all aspects of working with a RESTful API, I’ve developed both a server and a client using Ktor. Exploring this fascinating framework in detail will be the topic of another post, but in the meantime, please feel free to examine the source code on GitHub and experiment with my implementation.
For now, let’s just appreciate how neat and concise the implementation becomes. Suppose an external service that provides a /hello
endpoint that takes an input in a query string and returns a simple message. Here is a simple client implementation.
import io.ktor.client.HttpClient
class ApiClient(private val baseUrl: String, private val httpClient: HttpClient) {
suspend fun hello(request: Request): ApiCallResult<Response> =
tryCall(::Failure) {
httpClient.get("$baseUrl/hello") {
parameter("from", request.from)
}.toApiCallResult()
}
}
object Greeting {
data class Request(val from: String)
data class Response(val greeting: String)
}
suspend fun <T> tryCall(onError: (String) -> T, block: suspend () -> T): T {
return try {
block()
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (t: Throwable) {
onError(t.message ?: "Unknown error")
}
}
The client makes a call to the external service using baseUrl
and using toApiCallResult
extension it converts a raw HTTP response into a custom ApiCallResult
. This allows any subsequent service that works with this client to easily interpret the outcome and act upon it.
Suppose a web server that uses the client and exposes the outcome of the /hello
call.
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.server.application.*
fun main() {
// Ktor HTTP client
val httpClient = HttpClient(CIO)
// A custom API client that internally makes calls to an external service
val apiClient = ApiClient("https://api.example.com", httpClient)
// A server that exposes its own /hello endpoint
embeddedServer(Netty, port = 8080) {
routing {
get("/hello") {
call.toGreetingRequest()?.let { req ->
when (val result = apiClient.hello(req)) {
is ApiCallResult.Success -> call.success(result.data)
is ApiCallResult.Failure -> call.failure(result.reason)
is ApiCallResult.Unauthorized -> call.unauthorized()
else -> call.failure()
}
} ?: call.badRequest()
}
}
}.start(wait = true)
}
fun ApplicationCall.toGreetingRequest(): Greeting.Request? =
this.request.queryParameters["from"]?.let { Greeting.Request(it) }
suspend fun ApplicationCall.badRequest() =
this.respond(HttpStatusCode.BadRequest)
A lot is happening in the code, but I hope it reads well and effectively conveys the message. Notice how we can elegantly handle various outcomes using pattern matching.
when (val result = apiClient.hello(req)) {
is ApiCallResult.Success -> call.success(result.data)
is ApiCallResult.Failure -> call.failure(result.reason)
is ApiCallResult.Unauthorized -> call.unauthorized()
else -> call.failure()
}
Pattern matching provides me with the flexibility to select which result outcomes to handle explicitly. In this case, when the API call is successful, I’ll return the response along with its data. Conversely, if the API call fails, I want to capture the reason. Given that I’m making a call to an external service, it’s possible that my authorization could expire, and I want to account for that scenario as well. Any other errors will be categorized as a generic internal server error response.
Summary
Sealed classes and interfaces are powerful tools for representing a finite set of outcomes. When combined with generics and extension methods, they contribute to writing concise and readable code, eliminating the need for throwing exceptions as a side effect.
Check out the source code on GitHub.