Best practices for error handling in Kotlin
Code confidently with Kotlin
Every programming language must provide error handling, which holds true for Kotlin in the context of Android development. Kotlin has powerful features that make handling errors easy and effective.
One of the most significant advantages of Kotlin’s error handling is its ability to handle exceptions concisely and straightforwardly. This feature helps developers identify and resolve errors quickly, reducing the time required to debug their code.
Kotlin has some error handling features such as Null Safety, let, Elvis operator, late initialization, and safe casting with the ‘as?’ operator. We will discuss other advanced techniques for handling errors in Kotlin as follows.
Exceptions in Coroutines
A coroutine will carry an exception up to its parent when it fails with one. Following that, the parent will:
- cancel itself,
- cancel the remaining children, and
- propagate the exception up to its parent.
All of the coroutines that the CoroutineScope launched will be canceled once the exception reaches the top of the hierarchy.
1) Cancel itself
import kotlinx.coroutines.*
fun main() = runBlocking {
val parentJob = GlobalScope.launch {
val childJob = launch {
throw RuntimeException("Exception occurred in child coroutine!")
}
try {
childJob.join()
println("Child job completed successfully")
} catch (e: Exception) {
println("Caught exception in parent: ${e.message}")
}
}
parentJob.join()
println("Parent job completed")
}
In this example, we have a parent coroutine (parentJob
) that launches a child coroutine (childJob
). The child coroutine intentionally throws a RuntimeException
to simulate a failure.
2) Cancel the remaining children
import kotlinx.coroutines.*
fun main() = runBlocking {
val parentJob = GlobalScope.launch {
val childJob1 = launch {
delay(1000)
throw RuntimeException("Exception occurred in child job 1!")
}
val childJob2 = launch {
delay(2000)
println("Child job 2 completed successfully")
}
val childJob3 = launch {
delay(3000)
println("Child job 3 completed successfully")
}
try {
childJob1.join()
} catch (e: Exception) {
println("Caught exception in parent: ${e.message}")
}
}
parentJob.join()
println("Parent job completed")
}
In this example, we have a parent coroutine (parentJob
) that launches three child coroutines (childJob1
, childJob2
, childJob3
). The first child job intentionally throws a RuntimeException
after a delay, simulating a failure.
3) Propagate the exception up to its parent
import kotlinx.coroutines.*
fun main() = runBlocking {
val parentJob = GlobalScope.launch {
val childJob = launch {
throw RuntimeException("Exception occurred in child coroutine!")
}
try {
childJob.join()
} catch (e: Exception) {
println("Caught exception in parent: ${e.message}")
throw e // Rethrow the exception
}
}
try {
parentJob.join()
} catch (e: Exception) {
println("Caught exception in top-level coroutine: ${e.message}")
}
println("Coroutine execution completed")
}
In this example, the parent coroutine launches a child coroutine that intentionally throws a RuntimeException
. When the exception occurs in the child coroutine, it carries the exception to its parent coroutine.
Uses of Sealed Classes for Error Handling
The sealed class provides a powerful way to model the error class in Kotlin.
By defining a sealed class hierarchy that represents all possible errors in your app, you can easily handle errors concisely and effectively.
sealed class AppState {
object Loading : AppState()
object Ready : AppState()
object Error : AppState()
}
fun handleAppState(state: AppState) {
when (state) {
is AppState.Loading -> {
// Do something when the app is loading
}
is AppState.Ready -> {
// Do something when the app is ready
}
is AppState.Error -> {
// Do something when the app has an error
}
}
}
The code includes a function handleAppState
that manages the various app states represented by AppState
. It responds to the loading, ready, and error states by carrying out the appropriate actions using a when expression.
Functional Error Handling
Functional error management is a significant method that applies higher-order functions. You can quickly develop error-handling logic and eliminate nested if-else statements by sending error-handling routines as inputs to other parts.
fun <T> Result<T>.onError(action: (Throwable) -> Unit): Result<T> {
if (isFailure) {
action(exceptionOrNull())
}
return this
}
fun loadData(): Result<Data> {
return Result.success(Data())
}
loadData().onError { e -> Log.e("TAG", e.message) }
The onError
function is defined in the code to handle Result
errors with a default action for failures. A successful load of data returns a Result
data object. When loading data encounters exceptions, the example logs error messages.
Uncaught Exception Handlers
You can configure an uncaught exception handler to handle any unhandled exceptions that arise in your application. Before the application crashes, This approach allows you to log errors or present user-friendly messages before the application crashes.
Here is an illustration of how to configure an uncaught exception handler:
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
// Handle the uncaught exception here
Log.e("AppCrash", "Uncaught exception occurred: $throwable")
// Perform any necessary cleanup or show an error dialog
// ...
}
Using Thread.setDefaultUncaughtExceptionHandler
, the code creates a default uncaught exception handler. Unhandled exceptions cause Log.e
to log the exception’s specifics. It enables the appropriate error presentation or cleanup.
Handling Network Errors with Retrofit
By creating a unique error converter, you may use Retrofit’s error-handling capabilities while utilizing it for network requests. This enables you to handle various HTTP error codes and network problems more systematically.
For illustration:
class NetworkException(message: String, cause: Throwable? = null) : Exception(message, cause)
interface MyApiService {
@GET("posts")
suspend fun getPosts(): List<Post>
}
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.build()
val apiService = retrofit.create(MyApiService::class.java)
try {
val posts = apiService.getPosts()
// Process the retrieved posts
} catch (e: HttpException) {
// Handle specific HTTP error codes
when (e.code()) {
404 -> {
// Handle resource not found error
}
// Handle other error codes
}
} catch (e: IOException) {
// Handle network-related errors
throw NetworkException("Network error occurred", e)
} catch (e: Exception) {
// Handle other generic exceptions
}
The NetworkException and MyApiService
interfaces are defined in the code for Retrofit network operations. It makes a network call to get posts, managing exceptions linked to HTTP
and the network via try-catch
blocks and the proper error-handling techniques.
Graceful Error Handling with Coroutines
When using coroutines, you can execute a suspended action and gracefully handle any exceptions using runCatching
function. This function streamlines code structure, making it easier to gather and handle exceptions within the same block. For instance:
suspend fun fetchData(): Result<Data> = coroutineScope {
runCatching {
// Perform asynchronous operations
// ...
// Return the result if successful
Result.Success(data)
}.getOrElse { exception ->
// Handle the exception and return an error result
Result.Error(exception.localizedMessage)
}
}
// Usage:
val result = fetchData()
when (result) {
is Result.Success -> {
// Handle the successful result
}
is Result.Error -> {
// Handle the error result
}
}
The program’s suspend
function, fetchData
, uses coroutines to perform asynchronous tasks. To handle exceptions, it uses runCatching
and returns a Result
that either indicates success with data or an error with an error description. The example shows how to use fetchData
and deal with success or error results.
Error Handling with RXJava
Operators are features of RxJava that allow you to work with the data that Observables emit. The RxJava operators used to handle errors are given below:
1) onExceptionResumeNext()
2) onErrorResumeNext()
3) doOnError()
4) onErrorReturnItem()
5) onErrorReturn()
Conclusion
Kotlin’s robust error-handling capabilities make it simpler and more efficient for developers. Its exceptional exception handling within coroutines is a standout advantage. Exceptions are seamlessly propagated up the coroutine hierarchy, facilitating accurate handling and cancellation of coroutines.
By following these best practices and leveraging Kotlin’s error-handling features, developers can write more robust and reliable code in their Kotlin applications.
Keep following the Simform Engineering publication to know more about such insights and the latest trends in the development ecosystem.