ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Follow publication

Kotlin Tips and Tricks You May Not Know: #7 — Goodbye try-catch, Hello runCatching!

Elena van Engelen - Maslova
ProAndroidDev
Published in
9 min readFeb 27, 2025

Introduction

Exception handling is a key aspect of building robust applications. However, there is no one-size-fits-all solution when it comes to handling errors. Different paradigms and use cases require different approaches.

Roman Elizarov, a key figure in Kotlin’s design, has emphasized that we should not catch exceptions unnecessarily, especially in structured applications, and instead let the framework handle them where possible [1].

However, in JVM-based applications that integrate with Java libraries, avoiding exception catching is often impractical. Unlike Kotlin, Java uses checked exceptions, which do not always indicate programming errors but rather recoverable conditions. In Kotlin, it is generally preferred to handle recoverable failures explicitly by returning a value, such as using Result<T>, rather than throwing exceptions. This makes failure handling clearer and avoids unexpected disruptions in execution flow. A popular functional programming library, Arrow [2], provides Either, which similarly encapsulates success and failure. Arrow offers more functional constructs for working with errors in a type-safe way.

In addition, even when an exception should be propagated, the platform you are running on may not handle it exactly as you want. For example, some platforms may fail to log the error with the necessary context, such as custom dimensions for monitoring and alerting. In such cases, catching exceptions explicitly and handling them in a structured way can provide better control over logging and monitoring.

To simplify exception handling, Kotlin’s standard library offers the runCatching function, which automatically catches exceptions and wraps them in a Result.

In this article, we will explore how runCatching integrates with Result by automatically catching exceptions and wrapping them in a Failure state. We will also look at some of the side effects of using runCatching.

Note: For clarity, the examples provided in this article are simplified representations of common scenarios.

Returning a Result for Safer Code

Since all exceptions in Kotlin are unchecked, when you throw exceptions from a function instead of returning a result, the caller may not know what to expect and is not forced to handle exceptions. This can lead to unpredictable behavior and uncaught failures at runtime. By always returning a Result<T>, you can ensure that error handling is explicit and enforced, leading to safer and more maintainable code.

Instead of throwing an exception, we wrap the operation in runCatching:

fun parseNumber(input: String): Result<Int> {
return runCatching { input.toInt() }
}

Now, the caller can handle the result explicitly:

val result = parseNumber("123")
result.onSuccess { println("Parsed number: $it") }
.onFailure { println("Failed to parse number: ${it.message}") }

This ensures that callers are always aware of possible failures and handle them accordingly.

runCatching as a Scope Function

Kotlin provides various scope functions (run, let, apply, etc.) that allow concise and readable operations on objects. runCatching is a specialized version of run that wraps the execution result in a Result object, making error handling more structured.

Unlike a standard run block, which returns the result directly, runCatching ensures that if an exception occurs, it is captured inside a Result.Failure instead of propagating as an unhandled exception.

Example:

data class Order(val id: String, val quantity: Int)
val order = Order(id = "123", quantity = 0)

val result = order.runCatching {
100 / quantity // This will cause a division by zero
}.onFailure { e ->
println("An error occurred: ${e.message}")
}.onSuccess { value ->
println("Computation successful: $value")
}
println("Result object: $result")

Using runCatching as a scope function encapsulates both computation and error handling within a single block, reducing boilerplate. Instead of throwing exceptions, failures can be handled safely using onFailure, recover, or getOrElse, making error management more predictable. This approach also improves readability by eliminating explicit try-catch blocks, allowing the focus to remain on the actual logic. Since runCatching integrates seamlessly with other scope functions, it fits well into a functional-style error-handling approach while maintaining idiomatic Kotlin syntax.

Replacing Try-Catch with an Empty Catch Block

Beyond just returning results, runCatching can replace try-catch in many cases, even when you want the behaviour to remain unchanged. It allows handling failures cleanly while keeping code more readable and concise.

Consider an example where we would like to ignore an exception whereby the try-catch contains an empty catch block, which suppresses exceptions:

fun fireAndForget() {
try {
riskyFunction()
} catch (t: Throwable) {
// Ignore
}
}

Using runCatching, we can achieve the same result more concisely:

fun fireAndForget() {
runCatching { riskyFunction() }
}

Falling Back to a Default Value

A common pattern in traditional try-catch blocks is providing a default value when an exception occurs:

fun parseNumberWithDefault(input: String): Int {
return try {
input.toInt()
} catch (t: Throwable) {
0 // Default value for invalid numbers
}
}

With runCatching, this can be simplified using getOrElse, which provides a default value in case of failure:

fun parseNumberWithDefault(input: String): Int {
return runCatching { input.toInt() }.getOrElse { 0 }
}

This removes the need for explicit try-catch blocks while ensuring a fallback value is returned in case of failure.

Rethrow Original Exceptions with a getOrThrow

In some scenarios, you still may want to throw exceptions. For example, within some libraries, frameworks, or serverless cloud functionality, exceptions result in automated error handling mechanisms, such as retries and dead lettering. Another example is when refactoring an application from try-catch to runCatching, but you want to keep the existing error handling unchanged. In these cases you can use getOrThrow.

Before:

fun parseNumberWithExceptions(input: String): Int {
return try {
input.toInt()
} catch (e: NumberFormatException) {
logger.error(e){"Failed parsing integer"} // Log error
throw e // Unexpected exceptions are rethrown
}
}

After:

fun parseNumberWithExceptions(input: String): Int {
return runCatching { input.toInt() }
.onFailure { e -> logger.error(e){"Failed parsing integer"}} // Log error
.getOrThrow()
}

Handling Nested Exceptions with runCatching

In many applications, multiple dependent operations can fail in different ways. Reading a file may fail if the file is missing, parsing its content may fail if the format is invalid, and processing the parsed data may fail if required values are missing. Handling these errors with nested try-catch blocks often leads to unreadable and hard-to-maintain code.

Before: Nested try-catch

fun processFile(path: String): ProcessedData {
return try {
val content = File(path).readText()
try {
val json = parseJson(content)
try {
processData(json)
} catch (e: Exception) {
logger.error(e) { "Failed to process data" }
throw e
}
} catch (e: Exception) {
logger.error(e) { "Failed to parse JSON" }
throw e
}
} catch (e: Exception) {
logger.error(e) { "Failed to read file: $path" }
throw e
}
}

Before: Refactored nested to verbose try-catch

fun processFile(path: String): ProcessedData {
val content = try {
File(path).readText()
} catch (e: Exception) {
logger.error(e) { "Failed to read file: $path" }
throw e
}

val json = try {
parseJson(content)
} catch (e: Exception) {
logger.error(e) { "Failed to parse JSON" }
throw e
}

return try {
processData(json)
} catch (e: Exception) {
logger.error(e) { "Failed to process data" }
throw e
}
}

After: Concise with runCatching

fun processFile(path: String): ProcessedData {
return runCatching { File(path).readText() }
.onFailure { e -> logger.error(e) { "Failed to read file: $path" } }
.mapCatching { content -> parseJson(content) }
.onFailure { e -> logger.error(e) { "Failed to parse JSON" } }
.mapCatching { json -> processData(json) }
.onFailure { e -> logger.error(e) { "Failed to process data" } }
.getOrThrow()
}

With runCatchingin combination with mapCatching error handling is more structured and concise.

Handling Multiple Exception Types

In some cases, different exceptions require different handling, which is typically done using multiple catch blocks in a try-catch structure. runCatching does not provide built-in syntax for multiple exceptions, but you can achieve the same logic combining runCatching with when .

Before:

fun readFile(path: String): String {
return try {
File(path).readText()
} catch (e: FileNotFoundException) {
logger.error(e) { "File not found: $path" }
throw e
} catch (e: IOException) {
logger.error(e) { "Failed to read file: $path" }
throw e
}
}

After:


fun readFile(path: String): String {
return runCatching { File(path).readText() }
.onFailure { e ->
when (e) {
is FileNotFoundException -> logger.error(e) { "File not found: $path" }
is IOException -> logger.error(e) { "Failed to read file: $path" }
}
}.getOrThrow()
}

Replacing Try-Catch-Finally Blocks

If we need to execute cleanup code regardless of success or failure, a traditional try-catch-finally block will execute code in finally block. Just like handling multiple exceptions, runCatching does not provide built-in support for this. However, we can use Kotlin’s built-in use function for cleaning up closable resources.

For resources like files, streams, or database connections, use is preferred as it automatically closes the resource. Traditional try-catch-finally looks like this:

fun readFileWithTryCatch(path: String): String {
val reader = File(path).bufferedReader()
return try {
reader.readText()
} catch (e: Exception) {
logger.error(e){"Failed to read file: ${e.message}"}
throw e
} finally {
reader.close() // Must be manually closed
}
}

Utilize runCatching with use instead to eliminate manual resource management:

fun readFileWithUse(path: String): String {
return runCatching {
File(path).bufferedReader().use { it.readText() } // Auto-closes reader
}.onFailure { logger.error(it){"Failed to read file: ${it.message}" }}
.getOrThrow()
}

Handling Coroutines and Errors with runCatching

While runCatching simplifies error handling, it is important to be aware that it catches all Throwable, including both exceptions (Exception) and errors (Error). This includes CancellationException, which is used by coroutines to signal cancellations. Catching it unintentionally can prevent proper coroutine cancellation, leading to side effects such as unresponsive UI, or resource leaks.

Avoiding Cancellation Issues in Coroutines

Valerii Popov [3] has pointed out that runCatching catches CancellationException, which may unintentionally suppress coroutine cancellations. Since CancellationException is not a typical failure but a control mechanism, it should be rethrown to allow proper coroutine behavior.

To ensure CancellationException is correctly propagated while still handling other exceptions, you can use a custom extension function:

inline fun <reified E : Throwable, T> Result<T>.onFailureOrRethrow(action: (Throwable) -> Unit): Result<T> {
return onFailure { if (it is E) throw it else action(it) }
}

inline fun <T> Result<T>.onFailureIgnoreCancellation(action: (Throwable) -> Unit): Result<T> {
return onFailureOrRethrow<CancellationException, T>(action)
}

You can then apply it to runCatching as follows:

val result = runCatching {
apiService.fetchDataFromServer()
}.onFailureIgnoreCancellation {
println("Handled non-cancellation error: ${it.message}")
}

This ensures that CancellationException is properly propagated, allowing coroutines to cancel as expected while still handling other failures. For more details on handling coroutines correctly with runCatching, see [3].

Avoiding System-Level Errors

In most applications, you only need to catch recoverable exceptions (Exception) rather than system-level errors (Error). If you are catching and not propogating, then you want to ensure that runCatching does not suppress critical failures, you can filter them out in the same way as CancellationException, for example:

inline fun <T> Result<T>.onFailureIgnoreErrors(action: (Throwable) -> Unit): Result<T> {
return onFailureOrRethrow<Error, T>(action)
}

Usage:

val result = runCatching {
riskyOperation()
}.onFailureIgnoreErrors {
println("Handled exception: ${it.message}")
}

Static Analysis for Better Exception Handling

To ensure best practices in exception handling, static analysis tools like Detekt [4] can help catch problematic patterns. The coroutines ruleset can flag incorrect handling of CancellationException, and the exceptions ruleset can warn against swallowing exceptions. Configuring Detekt to suit your project needs helps you avoid common pitfalls.

By keeping these considerations in mind, you can use runCatching effectively while maintaining proper coroutine behavior and avoiding unintended suppression of system-level errors.

Conclusion

runCatching is a powerful tool when you want to always return a result—whether success or failure—without unexpected exceptions disrupting execution. However, its benefits go beyond just returning results. In many cases, it can also replace traditional try-catch and try-catch-finally blocks, making error handling more structured, concise, and easier to follow.

By using runCatching, we maintain a consistent error-handling pattern across our codebase, whether we need to always return a result, handle multiple exceptions, or still propagate exceptions when necessary. This approach leads to cleaner, more readable, and maintainable code.

⚠️ Caution: When using runCatching, be aware that it catches Throwable, including Error and CancellationException. Consider using techniques such as rethrowing exceptions where relevant, as suggested by Valerii Popov [3].

Tip Recap:

By replacing traditional try-catch-finally blocks with runCatching, you can make your code more structured and predictable:

  • Use runCatching when you want your functions to always return a meaningful result instead of throwing exceptions.
  • Replace empty catch blocks with a more concise runCatching.
  • Use getOrElse to provide default values in case of failure.
  • Use getOrThrow when you need to rethrow exceptions while still keeping structured error handling.
  • Handle multiple exception types using when inside recover, recoverCatching or onFailure.
  • Utilize use to properly handle resource cleanup, replacing finally blocks effectively.
  • If used within coroutines, ensure proper handling to avoid unintended suppression of cancellations.

For more Kotlin tips and tricks, visit my Medium blog homepage.

References

[1] Kotlin and Exceptions, Roman Elizarov https://elizarov.medium.com/kotlin-and-exceptions-8062f589d07

[2] Arrow, https://arrow-kt.io/

[3] Mastering runCatching in Kotlin: How to Avoid Coroutine Cancellation Issues, Valerii Popov, https://dev.to/1noshishi/mastering-runcatching-in-kotlin-how-to-avoid-coroutine-cancellation-issues-5go2

[4] Detekt, https://detekt.dev/docs/intro

Responses (4)

Write a response