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> : SerializableAs 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
Resultclass is designed to capture generic failures of Kotlin functions for their latter processing and should be used in general-purpose API like futures, [...] TheResultclass 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
nullif 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! 😅