Exceptions for control flow in Kotlin
Posted on 2023-12-08
Summary
Using exceptions for control flow is generally a bad practice in many programming languages and considered an anti-pattern. This approach is often compared to go-to statements in lower-level languages. Kotlin in particular has no concept of checked exceptions, which makes the situation even more challenging and unpredictable.
Exceptions are best used for checking programmer errors at runtime such invalid arguments and terminating the app with a meaningful error message and location when an unexpected state is reached. Kotlin handles control flow with sealed types, when statements and smart casts in an elegant way.
Control flow
Control flow describes the order of statements being executed in a program. Most often statements are executed from the top to the bottom with conditional branches, loops and function calls:
fun hello() {
print("Hello ")
world()
}
fun world() {
print("world!")
}
Above code is easy to read in sequential order and the output Hello world!
is no surprise.
Exceptions break the sequence of statements and the program continues running at a rather random position. Sometimes statements are wrapped in a try-catch block and the flow is more obvious. Other times exceptions bubble up all the way to the thread exception handler. The policy of this handler will determine how the program continues, with options ranging from stopping the thread to crashing the application.
Some programming languages such as Java have the concept of checked exceptions. With this paradigm exceptions and their types become part of the function signature. Callers must handle them or bubble them up further. The advantage of checked exceptions is that callers cannot ignore them or otherwise the compiler will throw an error. Their downside is that code becomes verbose especially when many of the APIs expose checked exceptions. As a result, checked exceptions are often handled in outer layers rather than at the right spot, e.g.
fun sendRequest(): Response? {
return try {
val connection = httpClient.openConnection()
connection.readResponse()
} catch (exception: IOException) {
// Generic message.
println("Couldn't read response: $exception")
null
}
}
// Compared to better descriptive log statements:
fun sendRequest(): Response? {
val connection = try {
httpClient.openConnection()
} catch (exception: IOException) {
// More precise error message.
println("Coulnd't open connection to server: $exception")
return null
}
return try {
connection.readResponse()
} catch (exception: IOException) {
// More precise error message.
println("Coulnd't read response from server: $exception")
null
}
}
Kotlin does not support checked exceptions and therefore programmers must rely on documentation to call out when an API throws an error. Rather than enforcing handling errors at compile time, Kotlin must rely on good intentions by API authors and consumers of them.
Another downside of exceptions is that creating and allocating a stacktrace is an expensive operation even on modern JVMs. If done frequently such as in a loop, the penalty is measurable and slows down the application, e.g. as highlighted in this example for Gradle.
When to use exceptions
Exceptions are meant for exceptional situations, when something went terribly wrong and the program cannot recover nor continue. In these moments it’s preferred to fail fast, crash the application and track an error rather than continuing in a half-broken state and poison other systems such as the backend. This helps developers identifying the problem and provide a safe solution.
Another good use case for exceptions is when the the data or input are invalid. This can be considered a programmer error, because the arguments weren’t checked before passing them. A common example is when converting a String to an Int:
fun String.toInt(): Int {
require(isNotEmpty()) {
"An empty string cannot be converted to an integer."
}
...
}
The Kotlin standard library often provides overloaded functions to return a null value instead of throwing an exception. If the caller is unclear about the input and skips the check, then it’s more performant and safer to call the function with nullable return type rather than using a try-catch. This pattern is elegant and often can be adopted in our own APIs.
fun String.toInt(): Int = ...
fun String.toIntOrNull(): Int? = ...
fun <T> List<T>.first(): T = this[0]
fun <T> List<T>.firstOrNull(): T = if (size > 0) this[0] else null
Modeling expected results in types
Since Kotlin doesn’t support checked exception, all expected scenarios and flows should be modeled as types to guarantee safe handling. Imagine this example:
interface Connector {
fun connectDevice(): Connection
}
interface Connection {
fun sendRequest(request: Request): Response
}
This API is problematic. Callers of connectDevice()
must assume a successful result based on types alone. Connection failures
are returned in the form of an exception. In this case the caller doesn’t know what kind of exception to catch. Even when the
exception type is documented on the interface as KDoc, there’s no contract in place that implementations will do the right thing
and throw the right exception.
One escape hatch is extending the Connection
type:
interface Connection {
val connected: Boolean
fun sendRequest(request: Request): Response
}
This solution isn’t very elegant. Callers are required to check the connected
flag before calling other APIs such as
sendRequest()
. Implementations of Connection
must check their internal state before executing functions
like sendRequest()
and cannot assume they’re connected either.
For Kotlin a better solution is a sealed type:
interface Connector {
fun connectDevice(): ConnectionAttempt
}
sealed interface ConnectionAttempt {
interface Connection {
fun sendRequest(request: Request): Response
} : ConnectionAttempt
data class Error(val reason: String) : ConnectionAttempt
}
This solution is preferred, because callers are enforced by the type system to check whether the connection attempt was
successful or failed. If the connection was successful, then they can proceed safely and call appropriate functions. Kotlin
supports this pattern through when
statements and smart casts seamless:
when (val attempt = connector.connectDevice()) {
is Connection -> {
val response = attempt.sendRequest(buildRequest())
println("Sent request, response: $response")
}
is Error -> println("Connection failed: ${attempt.reason}")
}
Through sealed hierarchies Kotlin gives developers more power to model scenarios and flows within their applications.
Result<T>
The Result class is a union type either
with a successful result or an exception. It clearly indicates that a call may fail and the consumer has to handle the exception.
While Result
has its place, stronger types and sealed hierarchies are still preferred, because modeled errors provide
richer information, can be extended, aren’t bound to the Throwable
type and don’t carry the overhead of allocating
a stacktrace to indicate a failure happened.