Success & logic error propagation in Kotlin

In Kotlin, the expected code-flow should not use exceptions. The non-success code flow should be propagated through return type to be well-documented, strictly & statically typed.

This post should be a practical guide after you have read Elizarov’s great post on Kotlin and Exceptions.

Be aware that this post talks about domain errors, as described in Elizarov’s post; IO & logic errors should be handled by a general top-level code.

Code-flow with one error type

If you don’t care what type of error happened or if there is only one error possible, you should use the nullable type. Language, its functions, and its operators are very well prepared for nullability.

fun getUserOrNull(id: Int): User? = TODO()

The function tries to find a user. If the user is not found, it returns a null. There are use cases when you do expect a non-null User — you are quite sure that the user will be found. In such cases, it is always better to provide a nullable function and then if the non-nullable use case becomes often, introduce a non-null function/extension function, that utilizes the nullable one.

fun getUser(id: Int): User = 
    getUserOrNull() ?: error("user $id not found")

Sometimes, we need to convert an exception-designed API to an error-return-designed API. Ideally, utilize Kotlin’s runCatching function. The function provides a lot of extensions to read the value, an error, provide a fallback value, etc.

fun getUserOrNull(id: Int): ?User {
    return runCatching {
        apiService.getUser(id) // call may throw
    }.getOrNull()
}

But there is a catch — you have to not care about the possible exception. The runCatching catches everything — also a CancellationException — so it is problematic in the coroutine world.

In this particular use-case, we don’t need the (fancy?) wrapper. We just need to handle the CancellationException and return a null for the other exceptions.

fun getUserOrNull(id: Int): ?User {
    return try {
        apiServise.getUser(id)
    } catch (e: CancellationException) {
        throw e
    } catch (_: Throwable) {
        null
    }
}

You can extract this behavior to a custom function.

inline fun <T> runCatchingOrNull(
    cb: () -> T,
): T? {
    return try {
        cb()
    } catch (e: CancellationException) {
        throw e
    } catch (_: Throwable) {
        null
    }
}

fun getUserOrNull(id: Int): ?User = runCatchingOrNull {
    apiService.getUser(id)
}

Code-flow with many error types

If you need more than one error type, aka you would like to throw more than one exception, you need a proper return type.

fun logIn(username: String, password: String): LogInResult =
    TODO()

sealed class LogInResult {
    class Ok(userId: Int) : LogInResult()
    object WrongUsername : LogInResult()
    object WrongPassword : LogInResult()
    class AccountBlocked(since: Instant) : LogInResult()
}

This is a nice straightforward solution. But maybe your code implementation behaves differently. It may throw and you would like to convert such exceptions to this sealed class. I’ve already mentioned the runCatching function. We may utilize it:

fun logIn(username: String, password: String): LogInResult {
    val result = runCatching {
         userApi.logIn(username, password)
    }

    return result.fold(
        onSuccess = { LogInResult.Ok(it.userId) },
        onFailure = { exception ->
            when (exception) {
                is CancellationException -> throw exception
                is WrongUsernameException -> LogInResult.WrongUsername 
                is WrongPasswordException -> LogInResult.WrongPassword
                is AccountBlockedException -> LogInResult.AccountBlocked(exception.since)
                else -> throw exception
            }
        }
    )
}

Please note that we have to check for CancellationException, if we want our old userApi to be properly cancellable. It is not much effort, since we already have to convert other exceptions to error types.

Do we need our own generic Resource?

A lot of articles and tutorials introduce their own (generic) Resource object. This object is somehow similar to Kotlin’s Result. So let’s enumerate the pros/cons of both of them:

Kotlin’s Result<T>

  • Pros:
    • inlined class — efficient implementation;
    • many operators — a ton of prepared operators and functionality;
    • native — directly available in Kotlin;
  • Cons:
    • the error type is not typed — the error type is always an exception and is not a generic parameter, i.e. you cannot be sure you have covered all possible errors;
    • cannot be returned in user-land’s code; the type is limited to be returned only from Kotlin’s std-lib; you may disable this limitation by a compiler’s switch;

Custom Resource<T, E>

  • Pros:
    • error type is part of the generic definition;
  • Cons:
    • you have to redefine all possible operators and Resource themselves;
    • non-inlined version is not very efficient;
    • custom solution unknown to newcomers;

As you can see, neither of these types is without issues. But do we need them at all? Maybe we do. Some lower model layers may provide results with custom-typed errors and the higher model layer will process the result, optionally it will map the result or add other errors.

sealed class LogInError{
    object WrongUsername : LogInError()
    object WrongPassword : LogInError()
    class AccountBlocked(since: Instant) : LogInError()
}

fun logIn(username: String, password: String): Resource<Int, LogInError> =
    TODO()

The logIn method returns a success (a userId) and error types (sealed error states). We will convert the userId to a User instance in another layer (e.g. via a user API call). To do so we will utilize the map function — either the Result’s built-in or your Resource’s custom.

suspend fun logIn(
    username: String, 
    password: String,
): Resource<User, LogInError> {
    val resource = userService.logIn(username, password)
    return resource.map { userId -> 
         userService.getUser(userId)
    }
}

Very nice. But with a mistake, our getUser method throws if the user is not found. So we should use getUserOrNull. But then it is quite difficult to convert the null to the proper error type – we do not have any information about the error.

For these reasons, I would rather not use Result or any custom Resource type. When needed, you will be required to redefine the return error type repeatedly.

Alternatively, you may define all possible error types upfront and only add these errors when they happen. (It is not a clean solution, but quite a well-working one.)

sealed class LogInError{
    // ...
    object UserNotFound : LogInError()
}

suspend fun logIn(
    username: String, 
    password: String,
): Resource<User, LogInError> {
    val result = userService.logIn(username, password)
    return result.fold(
        onSuccess = { userId -> 
            val user = userService.getUserOrNull(userId)
            if (user == null) {
                 Resource.Error(LogInError.UserNotFound)
            } else {
                 Resource.Success(user)
            },
        onError = { error -> 
            Resource.Error(error)
        }
    }
}

As you may see, the code gets more complicated, it is less straightforward.

If you have decided to go with Kotlin Result, you will have to enable returning Result by a compiler argument -Xallow-result-return-type. And still, the original runCatching down there somewhere is not handling CancellationException correctly. That may be sanitized by another helper, though.

fun <T> Result<T>.checkCancellation(): Result<T> {
    return when (val exception = exceptionOrNull()) {
        is CancellationException -> throw exception
        else -> this
    }
}

fun logIn(username: String, password: String): Result<Int> {
    val result = runCatching {
         userApi.logIn(username, password).userId
    }.checkCancellation()
}

Conclusion

After going through all the possibilities, I would suggest minimizing over-engineering. Go with the simplest solution, do not try to prepare a solution for everything and rather create custom types that will well describe returned values.

  1. Use custom return types.
  2. If there is more processing of the result along the way, use a custom Resource type. Be aware of its drawbacks and don’t overuse it.
  3. Do not use Kotlin’s Result type. It doesn’t have typed error and it is not (yet?) ready to use for free (runCatching doesn’t handle cancellation, compiler switch needed).