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 the 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 there is only one error possible, you should use nullable type. Language, its functions and operators are very well prepared for the nullability.
fun getUserOrNull(id: Int): User? = TODO()
The function tries to find a user. If the user is not found, it returns a null. Obviously, there are usecases when you do expect a non-null User — you are quite sure that the user will be found. In such case it is always better to provide a nullable function and then if the non-nullable usecase becomes often, introduce a non-null function/extension function, which 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 really 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 the Kotlin’s Result. So let’s enumerate 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 generic definition;
- Cons:
- you have to redefine all possible operators and Resource itself;
- non-inlined version not much 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 layer may provide result 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 an 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 proper error type – we do not have any information about the error.
From these reasons I would rather not use Result
and any custom Resource
type. Because 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 may 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’s 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 which will well describe returned values.
- Use custom return types.
- If there is more processing of the result along the way, use custom Resource type. Be aware of its drawbacks and don’t overuse.
- 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).