Designing Errors with Kotlin

December 18, 2018

Fun fact — the area of the Java island is 138 793 km², the Kotlin island occupies 15 km². Of course, it is blatantly incorrect to compare languages based on same-named island areas. At the same time, it brings things in perspective. Java is the cornerstone of the JVM platform. The platform itself overshadows everything it hosts: Groovy, Ceylon, Scala, Clojure and Kotlin. It brings a lot to the table — error handling is no exception (pun intended).

Exceptions! Developers adore exceptions. It is so easy to throw an error and forget about consequences. Is it a good idea though? Should Kotlin follow the same path? Fortunately enough there are many good languages we can learn from. Let’s dive in!

Java

There are checked and unchecked exceptions. Checked ones are not favored among developers since their handling is forced by the function signature. I think the root of the checked exceptions discontent is the duality it introduces. The caller is forced to work not only with the function result but with the possible exception. This creates a lot of friction. At the same time, checked exceptions are useful to enforce a desired behavior. From that perspective, unchecked exceptions are actually worse — unchecked ones are implicit and easy to miss, checked ones are explicit and defined in the declaration.

File open(String filename) throws IOException

Another option is to return null or magic values when something went wrong. It can be called either a C-style or a documentation-driven error handling — the caller is expected (but not enforced) to check the function result for a special case. The case itself is either documented or not — in such situations the process transforms into a goose chase for implicit errors.

Swift

Errors in Swift resemble Java checked exceptions. Error-producing functions are required to be annotated with the throws keyword.

func open(filename: String) throws -> File
do {
    try open("file.txt")
} catch {
    print("Gotta catch them all!")
}

The neat part is that only throwing functions are allowed to throw. Non-annotated functions are required to handle their exceptions themselves. There are no unchecked exceptions.

Another neat detail — there are no exceptions in Swift. Yep. In fact, the documentation is very careful to not even mention exceptions and uses the Error term instead. throws and throw keywords are just a syntax sugar for working with additional return value. throw writes an error to a register and try reads it. Essentially it is like returning a Pair or an Either.

📖 Technical details are explained in the excellent article by Mike Ash.

At the same time, there is an accepted proposal to introduce the Result type to the standard library as an alternative to throw-driven workflows. It can be seen as a more explicit, manual approach to propagating errors to callers. Result eliminates the function result vs. error duality via combining both of them.

enum Result<Value, Error> {
    case value(Value)
    case error(Error)
}

Go

Fortunately or not Go doesn’t have either Java-like exceptions or Swift-like syntax sugar. And it is this way by design. Explicit return values are used instead.

func open(filename: String) (*File, error)
file, err := open("file.txt")

if err != nil {
    println("YARRR!")
}

Basically, each function which might result in an error returns a pair of a value and an error. That’s it! Looks like a C-style error handling, but at least it is explicit and type-safe. The style is verbose, but it works surprisingly good due to the universal application. Plus, Go 2.0 most likely will introduce syntax sugar for error handling.

handle err { println("YARRR!") }
file := check open("file.txt")

There is a panic function which might look like a Java unchecked exception. Using enough hacks makes it possible to catch panic errors but it is considered non-idiomatic. panic is the last call for help when things got really, really bad.

panic("on the streets of London")

📖 There is a great article on coping with Go error handling by Sergey Alexandrovich.

Rust

Rust takes what Go has and moves it to the next level, defining two error categories right in the documentation:

Rust groups errors into two major categories: recoverable and unrecoverable errors. For a recoverable error, such as a file not found error, it’s reasonable to report the problem to the user and retry the operation. Unrecoverable errors are always symptoms of bugs, like trying to access a location beyond the end of an array.

That’s what I like about Rust — the straightforward declaration of principles.

panic!("at the Disco")
enum Result<Value, Error> {
    Ok(Value),
    Err(Error),
}

Kotlin

Welcome back to the JVM world!

Kotlin does not have checked exceptions. Well, actually it does, mostly for Java interop purposes.

@Throws(IOException::class)
fun open(filename: String): File {
    throw IOException("This is a checked, checked world.")
}

Since Kotlin runs on the JVM platform, there are unchecked exceptions. However, Kotlin is not Java — it has a couple of benefits. Specifically — it is possible to use sealed class to return a union of values and use it. Yep, just like the Result type.

sealed class Result<Value, Error> {
    data class Success(val value: Value) : Result()
    data class Failure(val error: Error) : Result()
}
fun open(filename: String): Result<File, String>
val result = open("file.txt")

when (result) {
    is Success -> println(result.value.path)
    is Failure -> println(result.error)
}

In fact, there is an accepted proposal to include a similar type in the standard library, but it is a bit weird since it is scoped to coroutines. Not to worry! It is still possible to introduce a project-specific one. Even better — create various sealed class for domain-specific tasks, which might include more than two states.

sealed class Result {
    data class Progress(val percent: Int) : Result()
    data class Success(val file: File): Result()
    sealed class Failure : Result() {
        object Disconnect : Failure()
        data class Undefined(val code: Int) : Failure()
    }
}

fun download(url: HttpUrl): Result

Unfortunately, there are no ways to ban the catch keyword and mentally map it to the panic invocation. It is still possible to control this in the scope of a codebase, but it doesn’t scale well with the growing number of developers. In-house Lint checks might help.

Bonus: RxJava

The Observable Contract introduces three basis notifications: onNext, onComplete and onError. Unfortunately, the onError notification is often abused by a domain-related error handling. This behavior introduces the result handling duality we’ve talked before. The solution is obvious if Kotlin is available — use result types as onNext and do not use errors.

fun download(url: HttpUrl): Observable<Result>

This approach simplifies interactions by a huge margin. onError becomes a reactive panic — a way to notify the caller about significant system failures that require developer attention. Fail-fast, right?

💡 Use Relay instead of Subject to stop thinking about onError and onComplete.

Results

I see a clear benefit in using result-driven error handling and a strict recoverable-unrecoverable paradigm.

📖 Talking side effects and functional programming — Haskell follows a similar approach to avoid exceptions in favor of result types.

Exceptions provide an easy way to deal with errors. Not necessary the simple one.


Thanks to Artem Zinnatullin and Igor Gomonov for the review!