Rethinking Exception Handling with Kotlin's Result Type
Exception handling is a fundamental aspect of robust software development. In Kotlin, while traditional try-catch blocks are common, the Result
type offers an alternative by encapsulating both success and failure. But is this approach always the best? Let's explore the pros and cons of using Result
and consider whether it might be overused in certain scenarios.
An introduction to Kotlin's Result
Type
If you're familiar with Kotlin, you've probably encountered Result
. Dealing with code that can throw exceptions is a common scenario. Traditionally, we would propagate these exceptions in the codebase, and then handle them with a try-catch.
The Kotlin Result
type provides a structured way to encapsulate exceptions into a simple API. Instead of propagating exceptions, we try-catch the exception, and map the result into either Result.success()
or Result.failure():
:
class UserDataSource(private val networkApi: NetworkApi) {
fun getUser(): Result<NetworkUser> {
return try {
Result.success(networkApi.getUser())
} catch (e: Exception) {
Result.failure(e)
}
}
}
This approach leaves us with a concise, easy to understand API, eliminating the need for any further exception handling at higher levels:
class UserRepository(private val dataSource: UserDataSource) {
fun getUser(): Result<User> = dataSource.getUser()
.map { networkUser -> networkUser.toDomainUser() }
}
class UserViewModel(private val userRepository: UserRepository) {
fun getUserState(): UserState =
userRepository.getUser().fold(
onSuccess = { user ->
UserState.Authenticated(user = user)
},
onFailure = { throwable ->
UserState.Unauthenticated(throwable = throwable)
}
)
}
Advantages of using Result
If you're like me, you like Result
. You've been using it everywhere for ages. You like how it reminds you to handle failure cases. The .onSuccess()
block looks nice. It comes with its own .map()
function and other convenient functions like getOrNull()
.
I liked Result
so much that I had my own version before Kotlin officially introduced it. I even wrote a blog post on creating a custom Retrofit CallAdapter
to return Result
directly from Retrofit interfaces. I'm not the only one who finds this approach valuable.
For a long time, I felt good about this approach. With Result
, I didn't need to remember to try-catch API calls. If I saw a Result
, I knew the potential exception had already been caught. I could bubble that Result
up from my data layer to the Repository, then up to the ViewModel
, where I would reduce it to a state and conveniently render success or failure accordingly.
Drawbacks
Recently, a colleague asked me to explain why we use the Result
type for API calls in our current project. Why not just allow exceptions to throw? My initial thought was, "Because that's how we've always done it." But that's never satisfying. So, let's rethink this.
We found that orchestrating multiple API calls often led to situations where we had a Result<TypeA>
and a Result<TypeB>
, and mapping these together to some Result<TypeC>
felt clunky. For example:
class RewardsRepository(private val dataSource: RewardsDataSource) {
fun getRewards(user: User): Result<List<Reward>> = dataSource.getRewards(user.id)
.map { networkRewards ->
networkRewards.map { networkReward ->
networkReward.toDomainReward()
}
}
}
class GetUserRewardsUseCase(
private val userRepository: UserRepository,
private val rewardsRepository: RewardsRepository
) {
operator fun invoke(): Result<UserRewards> {
val user = userRepository.getUser() // Result<User>
val rewards = rewardsRepository.getRewards(user) // Result<List<Reward>>
val userRewards = ???
}
}
Trying to combine two results is painful. You might write a function to handle this, but it often ends up feeling like a workaround. I found myself reaching for this pattern:
class GetUserRewardsUseCase(
private val userRepository: UserRepository,
private val rewardsRepository: RewardsRepository
) {
operator fun invoke(): Result<UserRewards> {
return try {
val user = userRepository.getUser().getOrThrow()
val rewards = rewardsRepository.getRewards(user).getOrThrow()
Result.success(
UserRewards(
user = user,
rewards = rewards
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
}
This works, but let's think about it. We have a UseCase
that try-catches, and returns a Result
. We call getOrThrow()
on our repository Result
functions - essentially re-throwing their exceptions. Then we catch those exceptions, and map back into a Result. Gah! What have we gained from having userRepository.getUser()
return a Result
?
It's not the worst, and if you like this pattern or prefer to hide this logic behind an extension function, you might be content. But is this really the best approach?
So, What's the Problem?
Here are a few issues with propagating Result
everywhere:
- Propagating Result Everywhere: Using
Result
deep in your data source layer means you propagate theResult
type throughout your architecture. Consumers of your API have no choice but to handleResult
. Orchestrating multipleResult
types together can be cumbersome and adds unnecessary boilerplate. - Reinventing Checked Exceptions: In some ways,
Result
essentially reinvents Java's checked exceptions. Kotlin deliberately avoided checked exceptions to keep the language concise. By encapsulating success and failure into a single type, developers are forced to handle both scenarios, leading to verbosity and complexity. - Redundant Syntax:
Result
makes all existing try-catch syntax redundant. By wrapping everything in a try-catch at the lowest layers, we no longer throw exceptions. This can make constructs liketry-catch
,runCatching()
, andFlow.catch()
less useful. - False Sense of Security:
Result
can provide a false sense of security. Although it encourages handling errors, it doesn't guarantee that errors are managed correctly. It's still possible to forget to implement.onFailure()
, which might leave the app in an invalid state without any crash reports.
Write Exceptional Code
💡 The solution is simple: let your code throw exceptions naturally.
Consider refactoring the earlier example:
class UserDataSource(private val networkApi: NetworkApi) {
fun getUser(): Result<NetworkUser> {
return try {
Result.success(networkApi.getUser())
} catch (e: Exception) {
Result.failure(e)
}
}
}
class UserRepository(private val dataSource: UserDataSource) {
fun getUser(): Result<User> = dataSource.getUser()
.map { networkUser -> networkUser.toDomainUser() }
}
Becomes:
class UserDataSource(private val networkApi: NetworkApi) {
fun getUser(): NetworkUser = networkApi.getUser()
}
class UserRepository(private val dataSource: UserDataSource) {
fun getUser(): User = dataSource.getUser().toDomainUser()
}
Now, I know what you're thinking. Change is scary. What if someone forgets to try-catch this code? Won't that result in a crash in production? 😱
There are valid concerns, but let's think about this for a minute:
Where Should You Handle Exceptions Anyway?
In a traditional MVVM app, exceptions should be handled thoughtfully at appropriate layers:
- In the
Repository
. Often you’ll need to map from a specific network exception to a more generic domain exception. This sort of mapping is usually the responsibility of theRepository
. - In a
UseCase
. UseCases encapsulate units of business logic, orchestrating multiple repositories and exposing data for consumption by the ViewModel. They are a good place to handle exceptions if necessary. - In the
ViewModel
. Deciding how to surface an exception to the user is presentation logic, which usually belongs in the ViewModel.
💡 Prematurely catching exceptions, or mapping to Result at lower layers like the DataSource introduces unnecessary boilerplate.
If you like the Result pattern and the API it provides, you can still use it - but think about where you want to stop using traditional exception propagation and start introducing Result.
Writing Good Code Requires Discipline
Any external dependency that you call has the potential to throw an exception. Even a function that returns Result
could potentially throw if a particular exception isn’t caught. So whenever you're interacting with a Repository
or any other dependency from your ViewModel
, you should always consider possible exceptions. What if there's a server error? What if there's no space on disk? What if the user is not authenticated? Whether the return type is Result
or not, exception handling should always be front of mind.
Final Thoughts
Kotlin’s Result
type is a powerful way to handle exceptions. But, it’s important to consider how and where you use it throughout your app’s architecture. For lower layers like DataSources, traditional exception propagation might be more appropriate, preserving the clarity and simplicity of your code. Reserve Result
for higher-level orchestration where it can genuinely add value without introducing unnecessary boilerplate.
💡 How you handle exceptions is up to you, but don't be afraid to rely on plain old try-catch.