Why Kotlin’s Result Type Falls Short for Handling Failures
The Kotlin standard library warns against using Result for domain logic—here’s why you should listen.
I had set a goal to release a blog post each month, but unfortunately, I missed January ⏳. Life and work sometimes get in the way, and I appreciate your patience. I’m excited to share this deep dive into a topic that has been on my mind for a while: why Kotlin’s Result
type is not the best choice for error handling in application code. I hope you find it insightful—let’s dive in!
A couple of days ago, I had a discussion about Kotlin’s Result
type vs. arrow-kt
's Either
. There are upsides and downsides to both, but I am a big fan of Either
over Result
, mainly because I think the design of Result
comes with some flaws. Before we discuss these flaws, let’s take a step back and look at Result
and why one might want to use it.
Understanding Result
Result
is a class from the Kotlin standard library. The KDoc states that it is:
A discriminated union that encapsulates a successful outcome with a value of type T or a failure with an arbitrary Throwable exception.
In other words, we can use Result
in places where we would throw an exception, with the difference that returning a Result
will not change the control flow as a thrown exception does.
Take a look at this example:
fun unsafeParseInt(str: String): Int {
return str.toInt() // This throws NumberFormatException for invalid input
}
fun main() {
val numbers = listOf("1", "2", "abc", "4")
val parsedNumbers = numbers.map { unsafeParseInt(it) } // 💥 CRASH on "abc"
println(parsedNumbers)
}
If we use a function that throws an exception inside a collection operation like map
, it can cause an unexpected crash. We could mitigate that by returning null
instead of throwing an exception, but this weakens our API if there might be several different causes for that Exception. Let’s see how Result
can help us here:
fun safeParseInt(str: String): Result<Int> {
return try {
Result.success(str.toInt())
} catch (e: NumberFormatException) {
Result.failure(e)
}
}
fun main() {
val numbers = listOf("1", "2", "abc", "4")
val parsedResults: List<Result<Int>> = numbers.map { safeParseInt(it) }
parsedResults.forEach { result ->
result.onSuccess { println("Parsed: $it") }
.onFailure { println("Error: ${it.message}") }
}
}
Instead of letting the function throw, we return a Result
. If everything worked fine, we can get the parsed number from the Result
; otherwise, the Result
will contain the exception. We can even write this more concisely:
fun safeParseInt(str: String): Result<Int> = runCatching { str.toInt() }
This behaves the same as the try-catch version above.
So far, so good. Result
might be overkill for this example, but it has its uses. Think about API calls, which might fail regularly.
Issues with Result
The most significant issue for the described use case with Result
is its signature:
@JvmInline
value class Result<out T> : Serializable
As you can see, Result
only has one generic parameter, yet it covers two cases: success and failure. In case of failure, Result
cannot tell you exactly what went wrong, as it only returns a Throwable
. Why is this bad? Going back to the API call example, you definitely want to know what went wrong. Is the server not reachable? Did you provide an incorrect payload? Were the credentials invalid? Sure, you can use when
expressions to check error types, but you might never know if you missed a case.
Because Result
only explicitly knows the success type, it does not provide a meaningful map
operation for the failure case. Additionally, Result
lacks a flatMap
function, making it cumbersome to chain multiple Result
values.
Finally, failures in Result
are always Throwable
instances, which implies that something unexpected happened. However, this is not always the case, especially in the domain layer. Validation errors, for example, are not unexpected. They are not exceptions; they are the rule and can be modeled as domain classes. Result
does not support this.
Why is Result
designed this way? Because it was not designed to be used in application code. The Kotlin KEEP proposal specifically states:
The
Result
class is designed to capture generic failures of Kotlin functions for their latter processing and should be used in general-purpose API like futures, [...] TheResult
class is not designed to represent domain-specific error conditions.
Alternative: Either
from Arrow
There are some alternatives to Result
, but first, let’s discuss what to do if your code already uses Result
. The Arrow library provides some utilities to make Result
more usable in your code. First, it adds a flatMap
function, making chaining Result
instances easier. For more advanced cases, Arrow includes an implementation of its Raise
API for Result
.
fun result1(): Result<String> = TODO()
fun result2(): Result<String> = TODO()
fun workWithResults(): Result<String> = result {
val string1: String = result1().bind()
val string2: String = result2().bind()
string1 + string2
}
Here, bind
returns the success value if available or propagates the failure. If result1()
returns a failure, workWithResults()
will also return that failure, and result2()
will never be called.
But this is just a workaround. The real solution is Either
.
Why Either
?
Either
is not an invention of Arrow; it is a general-purpose solution from functional programming. Unlike Result
, Either
does not limit failure cases to Throwable
. It has two generic types, one for success and one for failure:
object NotANumber // typed error
fun parseIntSafely(str: String): Either<NotANumber, Int> = try {
str.toInt().right()
} catch (e: NumberFormatException) {
NotANumber.left()
}
Either
is a more flexible alternative to Result
, as it allows defining a specific failure type instead of relying on Throwable
. It follows a functional approach and supports useful operations like map
, flatMap
, and fold
, making error handling more structured and predictable.
While Either
is powerful, using it effectively requires understanding functional programming principles, which is beyond the scope of this post. If you want to dive deeper, check out Arrow’s documentation.
Rolling Your Own Solution
If Either
feels too abstract, you can roll your own Result
-like classes. However, I recommend this only for smaller applications, as you may end up reimplementing what Either
already provides. For our string parsing example, it could look like this:
sealed class ParseResult {
data class Success(val value: Int) : ParseResult()
data object NotANumber : ParseResult()
}
fun parseIntSafely(str: String): ParseResult = try {
ParseResult.Success(str.toInt())
} catch (e: NumberFormatException) {
ParseResult.NotANumber
}
Verdict
If you can, avoid Result
in your application code. It is not designed for domain-specific error handling. Rolling your own solution is feasible for small projects, but for medium to large applications, Either
is the better choice.
That being said, it's always worth considering whether you need something like this at all. If you're dealing with a simple success-or-failure scenario, returning null
for failure might be a reasonable and lightweight alternative. This pattern is already used in many Kotlin standard library functions, such as toIntOrNull
, which:
Parses the string as an Int number and returns the result or
null
if the string is not a valid representation of a number.
Often, the simplest solution is the best.
What do you think about Result
? Do you use it extensively in your code? Let me know in the comments below! Thanks for reading! 😊
Kotlin is something I have to give it a try this year! 😅