Better Programming

Advice for programmers.

Follow publication

Typed Error Handling in Kotlin

Mitchell Yuwono
Better Programming
Published in
11 min readApr 16, 2023

Photo by Jason Ortego on Unsplash

Error handling is not rocket science. It is simple, but not always straightforward. This article studies some popular logical error handling patterns in Kotlin programming language. We will look into practical examples and the impact they might have to the cognitive complexity and maintainability of a program.

Introduction

Choosing an error handling strategy is one of the most fundamental decisions that can either make or break larger software projects. Poor error handling strategy compounds. It impacts not only readability, but also reliability and long term maintainability of a software project. Professional software teams need to pause, make a conscious decision and objectively analyze the tradeoffs when deciding what strategy to use. They need to make sure that the decision fits their best principles, as well as software engineering best practices. Software teams would benefit from one that embraces simplicity and enhances developer productivity: One that is succinct, readable and easily understood.

A large chunk of the time and effort spent in professional software development is about reading and understanding programs. Studies have shown that empirically code that are easier to read and understand are more likely to be maintained and stay correct as opposed to those that aren’t. Code that are easier to understand directly correlate to various developer productivity measures. This includes time taken to complete a task, correctness of solution provided, as well as the psychological wellbeing of the subject.

In 2018, SonarSource proposed a metric known as Cognitive Complexity. This metric takes into account several aspects of code shown to impose mental / cognitive strain that degrades understandability. This includes decision points, conditional statements, loops, when statements, and many more, with an additional weight for each nesting level.

Succinctness and effectiveness are two important indicators to better cognition and readability. Complexity can also stem from over-splitting a functionality into multiple small classes, or functions or sub-routines. This is often a byproduct of brain overload due to a program that is either overly complex, too big or too deeply nested. Over-splitting may result in accrual of complexity that may be higher overall due to the need to jump between many small functions to understand a particular piece of functionality.

Exceptions

The usage of exceptions for control flow is often considered bad practice. Overusing exceptions tends to create programs that are difficult to debug. It creates less performant programs. For these reasons, modern languages such as Kotlin discourages this style of programming.

Kotlin does not have checked exceptions. Programs written with this style hides the error scenarios. It is not possible to understand whether a function would throw from the type signature alone.

interface PetService {
suspend fun updatePet(...): Pet // underlying implementation throws
}

Nevertheless, there are developers who still find exception-based logic control practical in their Kotlin application. One proponent of the technique has argued that it is easy to adopt, and that it works just as well when managed properly through adequate test regime.

Typed Error Handling

Kotlin recommends a practice called typed error handling, also known as functional error handling. This approach models domain error boundaries as explicitly typed constructs, helping developers minimize some classes of programming bugs by ensuring contract correctness at compile-time.

You should design your own general-purpose Kotlin APIs in the same way: use exceptions for logic errors, type-safe results for everything else. Don’t use exceptions as a work-around to sneak a result value out of a function. […] This way, your caller will have to handle the error condition right away and you avoid writing try/catch in your general application code. — https://elizarov.medium.com/kotlin-and-exceptions-8062f589d07

In practice, developers have different approaches to applying this recommendation. One common approach is to model the result as a sealed class. Sometimes these types are also tagged with failure / success types.

interface PetService {
suspend fun updatePet(...): UpdatePetDetailsResult
}

// type 1: flat sealed class hierarchy
sealed class UpdatePetDetailsResult {
data class Success(val pet: Pet) : UpdatePetDetailsResult()
data object PetNotFound : UpdatePetDetailsResult()
...
}

// type 2: sealed of sealed for failure type
sealed class UpdatePetDetailsResult {
data class Success(val pet: Pet) : UpdatePetDetailsResult()
sealed class Failure : UpdatePetDetailsResult() {
object PetNotFound : Failure()
...
}
}

The major downside of this approach is the conflated failure and success channels. This can lead to leakage of domain boundaries.

Another well known technique is to use a dedicated generic type container to model successes and failures such as Either<L, R>. This approach encourages domain driven design as domain boundaries are clearly defined within the failure L type. Either is a sealed class with two members as follows.

sealed class Either<out L, out R> 
data class Left<L>(val value: L): Either<L, Nothing>() // error channel
data class Right<R>(val value: R): Either<Nothing, R>() // success channel

Being a separate concrete class, it comes with some benefits including reducing boilerplate codes and uniformizing error handling approach in a codebase. The function signature can be seen as follows.

interface PetService {
suspend fun updatePet(...): Either<UpdatePetDetailsFailure, Pet>
}

Yet another approach is to encode the failures contextual type into the function. Conceptually this can be seen as equivalent to Java’s checked exception that is working on the type level. By marking a function with a context bound, the compiler enforces the caller to provide the correct context in which the function can be invoked and recovered. This approach was presented in KotlinConf ’23 by the Arrow team. The code snippet below would be possible with context-receivers.

interface PetService {
context(Raise<UpdatePetDetailsFailure>)
suspend fun updatePet(...): Pet
}

Comparative Study

In this experiment we aim to understand how different practices behave in varying degrees of complexities in the requirements. The problem that we used to benchmark was a simple task of updating a pet details. The requirements were as follows:

  1. A pet can be recovered from the database;
  2. A pet can only be updated if it is microchipped — the microchip data should be recoverable from the database;
  3. A microchip is valid if it points to the same pet id;
  4. A pet needs to have an owner — the owner data is in the database similarly;
  5. A pet can only be updated by its owner;
  6. If a pet name were to be updated, it should not be updated to an empty string, and;
  7. If updating a pet failed with not found, there is a race condition where the pet is deregistered or deleted by the system and the update should be rejected.

All logic was done within one function to ensure the cumulative cognitive complexity, cyclomatic complexity (approximate minimum number of testcases needed to cover the branches), as well as lines of codes (LOC), are recorded. The approaches that were compared in this article are as follows:

  • Exception based logic control.
  • Vanilla sealed class matching, without early returns
  • Sealed class matching, with early returns
  • Vanilla Either<L, R> flatMap chain
  • Arrow’s typed-error handling using either { }
  • Arrow’s typed-error handling using context(Raise<Err>)

The last 3 approaches were enabled using Arrow https://github.com/arrow-kt/arrow. Arrow is a utility library that is gaining popularity in the Kotlin community. Thoughtworks has marked Arrow for adoption since 2020 and has considered Arrow a sensible default when working with Kotlin. The Arrow version used in this study was 1.2.0-RC. Context receiver was still experimental. Arrow’s library has been greatly simplified in 2.0. It has a smaller and uniform API which reduces learning curve. This lowers the barrier to entry and initial investment to learn and use Arrow.

The repository containing the complete code can be found in https://github.com/myuwono/typed-error-handling-demo

Experimental Result

The outcome of various approaches are summarized in this section. The list will be presented in reverse order, starting from the approach that was identified as most cognitively complex (#6), all the way to the simplest (#1).

#6: Vanilla sealed class matching, without early returns

Kotlin recommendation on error handling suggested users to avoid deeply nested code / logic control. Observing the degradation of readability in this approach, reasonings behind the recommendation became quite apparent.

Solving the problem with vanilla sealed class matching without early returns. LOC = 49, Cognitive Complexity = 34, Cyclomatic Complexity = 13.

#5: Vanilla Either<L, R> flatMap chain

Similarly to the problem with vanilla sealed class nesting, chaining flatMaps suffers the similar consequences in Kotlin. Notice that althoughUpdatePetDetailsResult and Either<UpdatePetDetailsFailure, Pet>are expressed differently, both types are conceptually equivalent. This result aligned with the reasoning behind Kotlin’s preference of a flat program with semantics of aborting further progress on the first failure:

Functional code that uses Try monad gets quickly polluted with flatMap invocations. To make such code manageable, a functional programming language is usually extended with monad comprehension syntax to hide those flatMap invocation. […] Adapting [functions used here] to Kotlin style, one can write this code in Kotlin, with the same semantics of aborting further progress on the first failure. — https://github.com/Kotlin/KEEP/blob/master/proposals/stdlib/result.md#appendix-why-flatmap-is-missing

Solving the problem with vanilla Either<L, R> flatMap without early returns. LOC = 31, Cognitive Complexity = 17, Cyclomatic Complexity = 7.

#4 Exception and Rethrows

Kotlin prefers the use of types to perform logic control. However, there were developers that prefer approaches similar to below or its variant. This code snippet is rather Java-esque and feels home for those used to program in this style in the Java checked-exception world. It has a reportedly low cognitive complexity since it is not deeply nested and rather easy to explain and understand.

Kotlin does not support checked exception. Unfortunately, developers using this approach were forced to check the exceptions manually by navigating to all the dependencies.

Solving the problem with (unchecked) exceptions. LOC = 36, Cognitive Complexity = 9, Cyclomatic Complexity = 13.

It’s important to note that using exception for logic control may not necessarily align with Kotlin recommended best practices.

#3 Sealed class matching with early returns

Sealed class matching with early returns behaves like guard clauses, where the program short circuits with a return value on first failure. Developers used to this practice were also proficient in Javascript / Typescript. This effectively creates a similar effect to the functional error handling scheme. This approach is seen to have increased the ergonomic of sealed class quite significantly when compared with the approach with sealed class without early return.

Solving the problem with sealed classes and early return. LOC = 38, Cognitive Complexity = 6, Cyclomatic Complexity = 10.

It’s important to note there is a common problem in sealed-class based approaches: The failure and success channels are conflated. This characteristic not only makes extracting and extending programs exponentially more verbose, it also causes possible maintainability issues.

The dedicated sealed class CheckNamePolicyResult houses both the success and failure scenarios for checkNamePolicy(...). This was then translated to sealed class UpdatePetDetailsResult by the call-site. Besides being error prone, this practice often contributes to leakage of domain boundaries since it encourages developers to create miniature bounded contexts. It manifests in the declarations of intermediate service classes that escapes the domain isolation. All of which increases indirections and overall complexity that can be problematic.

#2 Arrow either { } builder

The either { } builder was provided by Arrow to chain operations which facilitates direct-style programming with a typed error channel. The builder allows ergonomic composition of Either<L, R> types and simplifying complex control sequence. — https://arrow-kt.io/learn/typed-errors/either-and-ior/

Arrow 1.2.0-RC offered various builders including either { } option { } and nullable { }. All of which were abstractions built on top a simple contextual generic type called Raise<E>. Within the Raise<E> context, developers gain access to various extension functions, including raise(err), .bind() and ensure(...).

  • fun raise(err: L): Nothing simply short-circuits the computation with Either.Left<L>,
  • fun <R> Either<L, R>.bind(): R: returns R if it’s Either.Right<R> otherwise short-circuit with Either.Left<L>.
  • fun ensure(condition: Boolean, ifFalse: () -> L): Unit. Continue if the predicate is true, otherwise evaluate ifFalse and short-circuit with Either.Left<L>.

A short-circuit event fires a special lightweight cancellation exception which ensures cancellation of all coroutines within the scope. This also means it can be safely used in places where nonlocal early returns weren’t possible, such as aborting from within parallel async operation. The final code using arrow either { } builder can be seen as follows.

Solving the problem with Arrow’s either { } builder. LOC = 27, Cognitive Complexity = 3, Cyclomatic Complexity = 4.

This approach further reduce the verbosity of the solution. The benefit of having a separate error channel is immediately observed in both the call site and definition of checkNamePolicy which operate within a common error boundary. In theory this approach should produce a similar cognitive complexity score with sealed classes and early return. However, it seems SonarQube reported a lower score.

#1 Arrow context(Raise<E>) with context-receivers

With context-receivers, declaring context(Raise<E>) on top of the function allows the enforcement of error boundaries using context type at compile time. This simplifies the result type encoding so that it doesn’t require any boxing. This aligns even better to Kotlin recommendation for error handling. Either<L, R>, Option<T> and nullable types T?, can still be used with .bind(). Implementers gets compiler assistance to handle transitions between error boundaries using recover.

Solving the problem with Arrow’s context(Raise<E>) and context-receivers. LOC = 27, Cognitive Complexity = 2, Cyclomatic Complexity = 4.

Context-receivers with context(Raise<E>) reduces the cognitive complexity further by removing the need to box the return type. The cyclomatic complexity is still equivalent and it can be seen that the code within the builder is almost identical to one with either { } builder. An important distinction point is that the .bind() calls were no longer required and that developers can then write program using simple types.

Conclusion

There were various approaches to error handling in the Kotlin community. In this article we’ve explored 6 error handling approaches, summarized from studying various patterns within the circle of developers that I personally surveyed. From the 6 approaches explored, there were three patterns that aligns with Kotlin recommended best practices with relatively low cognitive complexity including: Sealed class matching with early returns, Arrow’s either { } builder, and Arrow’s context(Raise<E>) with context-receivers. Of all 6 approaches explored, Arrow’s context(Raise<E>) achieved the most optimized score on all aspects of developer productivity. This includes having the lowest cognitive complexity, the lowest cyclomatic complexity as well as the most succinct with the least lines of codes.

Sources

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Mitchell Yuwono
Mitchell Yuwono

Software Engineer / Machine Learning Engineer / Open Source Contributor / Data Scientist / Musician / Educator

Responses (8)

Write a response