Hopp til hovedinnhold

In this article, we compare error handling methods in Kotlin; specifically: exceptions, Either, and Result.

Traditionally, we may use exceptions to handle errors. We perform some action and throw an exception if something goes wrong. But, if you are calling a function, how do you know if it can throw an exception at all? And if it does, what kind of exception? These are hard questions to answer, enter Either:

Either can be used as a typesafe alternative for error handling. Either holds one of two values, a Left or a Right. When we call a function which returns an Either, we are forced to handle both a successful and an unsuccessful case, and get to know exactly what this function returns. On the other hand, if we were to use exceptions, there is no method signature for the developer to see what cases may occur. Developers may annotate a function with @Throws, but since this is optional, it is not dependable. Therefore, with lack of a complete method signature, developers must either ignore the exception or interpret the function body and all possible underlying method calls.

Let us say that we want to validate a user object using an opaque type. Look at the code below. The only way to obtain an instance of ValidatedUser is to provide a RawUser with a valid email address to the static validate function.

data class RawUser(
    val email: String
)

class InvalidEmailException: RuntimeException("Invalid email")

class ValidatedUser private constructor(
    val email: String
) {
    companion object {
        fun validate(rawUser: RawUser): ValidatedUser =
            if (EMAIL_REGEX.matches(rawUser.email)) {
                ValidatedUser(rawUser.email)
            } else {
                throw InvalidEmailException()
            }
    }
}

And we may call it like so:

try {
    println(ValidatedUser.validate(RawUser(emailAddress)).email)
} catch (e: Exception) {
    println(e.message)
}

You may have already noticed that we catch Exception and not InvalidEmailException. This illustrates the main issue with exceptions, they are dynamically typed. The only way to know which exceptions might be thrown is to manually parse the function body (and the body of any internal function calls). This may not always be trivial, for example, if you are calling a function from an external library.

Luckily, there is a neat solution to this problem. Using the same RawUser class, we can implement it using Either from the Arrow library:

enum class ValidationError {
    INVALID_EMAIL
}

class ValidatedUser private constructor(
    val email: String
) {
    companion object {
        fun validate(rawUser: RawUser): Either<ValidationError, ValidatedUser> =
            if (EMAIL_REGEX.matches(rawUser.email)) {
                Either.right(ValidatedUser(rawUser.email))
            } else {
                Either.left(ValidationError.INVALID_EMAIL)
            }
    }
}

when(val validatedUser = ValidatedUser.validate(RawUser(emailAddress))) {
    is Left -> println(validatedUser.a)
    is Right -> println(validatedUser.b)
}

It is crystal clear what this function does from the signature alone. You either get a ValidationError or a ValidatedUser. Because Either is a sealed class, we also get exhaustive pattern matching using when. Additionally, we get smart casting which automatically gives us access to an a (the error) if it is a Left-instance, or a b (the success value) if it is a Right-instance.

There are cons to this approach as well as with exceptions. The choice of whether the error value is in the left or right slot is completely dependent on convention. It is easy to confuse, especially when you are working with different implementations where they are reversed (for example, Result in Rust and Kotlin-result). Consider this, which of these cases prints the error?

when(val validatedUser = ValidatedUser.validate(RawUser(emailAddress))) {
    is Left -> println(validatedUser.a)
    is Right -> println(validatedUser.b)
}

Either is rather generic, and thus, Left and Right may not make sense for the reader by itself. Additionally, you may also notice that Left and Right suddenly change to a and b. Not a huge problem, but readability suffers nonetheless.

If you want all the advantages of Either, but also want to stay semantically accurate in the context of error handling, check out Result<T, E>. Right changes to Ok, and Left to Err. It makes it significantly harder to confuse Left and Right as Ok and Err makes sense in their own right (pun intended).

class ValidatedUser private constructor(
    val email: String
) {
    companion object {
        fun validate(rawUser: RawUser): Result<ValidatedUser, ValidationError> =
            if (EMAIL_REGEX.matches(rawUser.email)) {
                Ok(ValidatedUser(rawUser.email))
            } else {
                Err(ValidationError.INVALID_EMAIL)
            }
    }
}

when(val validatedUser = ValidatedUser.validate(RawUser(emailAddress))) {
    is Ok -> println(validatedUser.value.email)
    is Err -> println(validatedUser.error.name)
}

Ah, delightful!

Relevant resources recommended by the author

Did you like the post?

Feel free to share it with friends and colleagues