Stop Throwing Exceptions: Embrace Safer Kotlin Practices
Written on
Now that I've captured your attention, let me clarify the message of this article: STOP THROWING EXCEPTIONS!
In Kotlin, there are excellent strategies for managing exceptions, the most straightforward being the use of try/catch blocks. Here’s a basic example:
try {
something()} catch (exception: Exception) {
println(exception)}
One of the advantages of Kotlin's try/catch is that it functions as an expression, enabling you to return meaningful data from both the try and catch clauses. Additionally, Kotlin offers robust support for handling exceptions within flows:
flowOf("Some", "values").catch { println(it) }
Lastly, you can encapsulate calls within a Result object:
runCatching {
something()}
However, Kotlin has its limitations, particularly when it comes to identifying which code might throw an exception. This is largely due to Kotlin's lack of checked exceptions. You might think, "I know exactly which part of my code can throw an exception!" But consider this exercise: Can you determine which of the following two functions throws an exception?
fun function1() {
println(getSomeInformation())}
fun function2() {
println(getSomeOtherInformation())}
Take your guesses! Unfortunately, we may never know which one can throw an exception, and, more importantly, we shouldn’t worry about it. The absence of checked exceptions in Kotlin isn't a flaw; it's a feature that I encourage you to leverage, regardless of the programming language you are currently using. Users of Rust may not see the relevance of this discussion since they already enjoy a programming environment devoid of runtime exceptions.
But why should we avoid throwing exceptions?
It's simple: exception handling can be cumbersome. It can lead to neglecting issues that actually require attention. Even in an older language like Java, relying on try/catch blocks is not advisable. Unlike Kotlin, Java's try/catch does not return values, which can complicate code readability. Moreover, it forces developers into a hidden context of error handling that only the IDE and compiler might discern. It’s not pleasant to rely on error indicators in IntelliJ to determine if something might fail during execution. For those using VIM: IntelliJ is an IDE designed to assist you in writing code quickly and efficiently, unlike what you may believe is efficient in your terminal setup.
So, what is the alternative?
Let me reiterate: we should stop throwing exceptions! Of course, there are rare instances when crashing the program is necessary, but those are few and far between. Much time is spent preventing crashes.
Let’s step back and view the situation from a broader perspective: every application essentially acts as a sophisticated interface for a database. Ultimately, we aim to return a result from this database: either we receive the desired value, or we fail to retrieve it. The route taken to acquire this data—be it from across the ocean to a phone’s cache and then onto a screen—doesn't need to be shrouded in complexity.
So, enough with the buildup; let’s get to the code! Here’s one approach, though certainly not the only or best one. As countless others have tackled similar problems, I’ll share my way as an Android developer with a view model. Initially, we'll handle exceptions in a conventional manner, but we’ll refine it later:
class MyViewModel(
private val fetchSomeStuffUseCase: FetchSomeStuffUseCase,
private val someMapper: SomeMapper
) : ViewModel() {
val viewState = MutableStateFlow<ViewState>(LoadingState)
init {
viewModelScope.launch {
try {
val newState = someMapper.map(fetchSomeStuffUseCase.run())
viewState.update { newState }
} catch (e: Exception) {
println(e)}
}
}
}
As responsible mobile developers, we often overlook anything outside the happy path. Let’s rectify this:
class MyViewModel(
private val fetchSomeStuffUseCase: FetchSomeStuffUseCase,
private val someMapper: SomeMapper
) : ViewModel() {
val viewState = MutableStateFlow<ViewState>(LoadingState)
init {
viewModelScope.launch {
val newState = try {
someMapper.map(fetchSomeStuffUseCase.run())} catch (e: Exception) {
println(e)
ErrorState
}
viewState.update { newState }
}
}
}
While the code here is clear, it will operate properly even without the try/catch (until it doesn’t). Consider the following example:
class FetchSomeSafeStuffUseCase(
private val fetchSomeDangerousStuffUseCase: FetchSomeDangerousStuffUseCase,) {
suspend fun run(): Stuff? = try {
fetchSomeDangerousStuffUseCase.run()} catch (e: Exception) {
println(e)
println("Failed to fetch stuff, returning null!")
null
}
}
If you’re feeling fine, great! If not, you might be missing the CancellationException, which is triggered when a coroutine is canceled. So, how can we improve this situation?
Kotlin provides a built-in Result class. You might think, “Oh, it’s just a sealed class with Success and Failure subclasses.” But that’s a misunderstanding! It’s actually a value class (previously known as an inline class), meaning it often avoids the overhead of creating a wrapper class around your values—an attractive benefit when dealing with numerous objects.
The Kotlin standard library also offers useful extension functions for the Result class. You can use getOrNull(), getOrElse {}, map {}, onSuccess {}, onFailure {}, recover {}, and even fold {}. Ironically, I manage to fold Results just as infrequently as I do my laundry. However, I believe a flatMap {} method is missing. Let me clarify for those who might be confused: when mapping a result to another result, you end up with a nested result structure:
fun example(
initialResult: Result<Something>,): Result<Result<Other>> = initialResult.map { something ->
getAnotherResultBasedOnSomething(something)}
This is undesirable, especially when we want to chain actions. Instead, we want to flatten it to achieve a simpler result object. Let’s create a straightforward flatMap {} extension function for the Result object:
inline fun <T, R> Result<T>.flatMap(
transform: (T) -> Result<R>,): Result<R> {
contract {
callsInPlace(transform, InvocationKind.AT_MOST_ONCE)}
val value = getOrElse {
return failure(it)}
return transform(value)
}
Now that we have the basics, let’s rewrite our view model using Result objects. Prepare for mild disappointment!
class MyViewModel(
private val fetchSomeStuff: FetchSomeStuffUseCase,
private val mapper: SomeMapper
) : ViewModel() {
val viewState = MutableStateFlow<ViewState>(LoadingState)
init {
viewModelScope.launch {
val newState = fetchSomeStuff.run().map(mapper::map).getOrElse {
println(it)
ErrorState
}
viewState.update { newState }
}
}
}
The crucial aspect is that we can easily map results, and getOrElse {} will retrieve the value if successful or return the result from the provided block. While you may feel underwhelmed, consider that we could also pass the entire Result object to the mapper to handle failures:
class MyViewModel(
private val fetchSomeStuff: FetchSomeStuffUseCase,
private val mapper: SomeMapper
) : ViewModel() {
val viewState = MutableStateFlow<ViewState>(LoadingState)
init {
viewModelScope.launch {
val newState = fetchSomeStuff.run().map(mapper::map)
viewState.update { newState }
}
}
}
Now our view model is clean and easy to test! The Result mapping is abstracted to the mapper, enhancing testability. Let’s explore our use case further:
class FetchSomeStuffUseCase(
private val fetchLoggedInUserUseCase: FetchLoggedInUserUseCase,
private val fetchSomeSecretUseCase: FetchSomeSecretUseCase,
private val someRepository: SomeRepository,
) {
suspend fun run(): Result<Something> = fetchLoggedInUserUseCase.run()
.flatMap { user -> fetchSomeSecretUseCase.run(user) }
.flatMap { secret -> someRepository.fetchSomethingWithSecret(secret) }
}
In my humble opinion, this is highly readable. No hidden complexity here—we simply fetch a user, and upon success, use that to fetch a secret, which allows us to fetch something else.
Moreover, it’s worth noting that within our view model and use cases, we’re using the same logic! Triggering something and then mapping it is a pattern everyone can appreciate. But wait, there’s more! Let’s examine how we can manage data retrieval from memory, database, or network. The standard library provides us with an extension function called Result.recover {}, which enables recovery from failures:
fun recoverExample(initialResult: Result<String>): Result<String>
= initialResult.recover { "Will be returned if initialResult is failure" }
When initialResult fails, it will recover and yield a successful result, all while remaining a Result object. However, I prefer to return another Result object, similar to the earlier flatMap {}. Thus, I created a flatRecover {} function to attempt recovery using another result object:
class SomeRepository(
private val memoryCache: MemoryCache,
private val database: Database,
private val network: FancyShmancyNetwork,
) {
suspend fun fetchSomethingWithSecret(secret: String): Result<Something> = memoryCache.get(secret).flatRecover {
database.get(secret).flatRecover {
network.get(secret).onSuccess { something ->
database.set(secret, something)}
}.onSuccess { stuff ->
memoryCache.set(secret, something)}
}
}
This approach combines functional programming with elegance. For those needing a refresher, the steps are straightforward: first, check memory; if successful, great! If not, query the database, and if that fails, reach out to the network. On success, update the database, and if either the database or network fetch is successful, ensure the memory cache is updated for future access.
The map {}, flatMap {}, and flatRecover {} functions can form the foundation of any data retrieval process. A singular pattern can govern various data flows. Need to obtain user location? Let’s chain it up:
suspend fun run(): Result<Location>
= checkLocationPermissionUseCase.run().flatMap {
checkLocationServicesEnabledUseCase.run()}.flatMap {
fetchUserLocation.run()}
So, is Result all I need?
Yes, and no. You can choose your path, but please stop throwing exceptions! We’ve established this. Now, let’s discuss potential drawbacks to this approach:
Since the Result object is a value class with its own mechanisms for holding either an exception or a value, pattern matching is not feasible:
when (result) {
is Success -> doStuff() // Does not exist
is Failure -> doFailureStuff() // Does not exist
}
Additionally, you can't specify the type of exception. I want to mention Arrow here, as it’s a library many Kotlin developers praise for its Either type, allowing you to define whether it’s this or a specific type of exception. I encourage you to explore it!
Personally, a lot of data in mobile applications is fetched through the network, which can fail for various reasons, such as IOException or even issues caused by a malfunctioning VPN. More often than not, my error handling will include an 'else' clause, regardless of my ability to anticipate potential failures.
Another potential drawback is if you are accustomed to throwing and catching exceptions; it can be a hard habit to break. During my transition from Java to Kotlin, I attempted (and failed) to be the ultimate exception catcher. I’m grateful to have shifted to using Result objects. You’ll still need to catch exceptions, but only at the boundaries of your code where your application interfaces with external libraries that may throw exceptions, depending on the author's design. My recommendation: for any code that might throw an exception, wrap it in a runCatching {} block at the source. This will return a Result object that you can propagate safely throughout your application.
I’m confident your language has a means to manage results appropriately instead of throwing exceptions around like a frustrated toddler.
In summary, I enjoyed articulating my thoughts. And by “articulating,” I mean typing them out in my notepad. Wishing you all a wonderful day ahead!