Handling Errors in Kotlin
There are two inevitable things in programming: the occurrence of errors and the need to handle them effectively. Errors are a natural part of software engineering, arising from unpredictable inputs, system limitations, or even human mistakes. However, the way we handle these errors not only impacts the quality and maintainability of our code but also shapes the user experience and system reliability. Kotlin, being a modern and expressive language, provides several options to handle errors, starting from traditional try-catch blocks to more sophisticated constructs such as Result, sealed classes, which enable the creation of custom error modeling solutions, and even well known functional paradigm constructs like Either. In this post, we will take a closer look at each method, analyzing its strengths and weaknesses, and try to advise which option will work best in a particular case.
For whom is this blog post?
We recommend it mainly to people who have already taken their first steps in Kotlin and are interested in learning good practices for writing maintainable and expandable code in the context of error processing. However, the approaches discussed can also inspire developers from other languages to adapt similar strategies in their projects.
Why does error handling matter?
Most people in the IT community have likely heard Murphy's law, which states: “Anything that can go wrong will go wrong”. And I believe nearly everyone in IT would agree with Murphy. This sentence highly resonates with software engineers in a world where almost impossible edge cases pop out, bulletproof systems fail mostly when we don’t expect it and our level of certainty dramatically decreases - sometimes feeding our impostor syndrome.
Every experienced developer knows that errors are not just possible - they are inevitable. Instead of ignoring them, the key is acknowledging and preparing for them proactively. While we may not predict every specific failure, building systems with resilience in mind helps mitigate the impact of unforeseen issues. As obvious as this may sound, it is a foundational mindset for writing maintainable and robust software.
How Kotlin Handles Exceptions: A Quick Introduction
In Kotlin, exception handling is simpler and more flexible compared to Java. Unlike Java, Kotlin treats all exceptions as unchecked, which means that you are not required to declare or catch them in method signatures. This approach reduces boilerplate and keeps method signatures cleaner, but it also places the responsibility for handling errors on the developer. Let’s consider a simple snippet of code that reads the configuration data from a file at a given file path:
fun readConfigFile(filePath: String): Map<String, String> =
FileReader(filePath).use { reader ->
reader.readLines()
.filter { it.contains("=") }
.associate {
val (key, value) = it.split("=", limit = 2)
key.trim() to value.trim()
}
}
At first glance, as a user of this method, you are not able to determine whether the implementation is safe in terms of handling potential exceptions or not. In fact, the method signature doesn’t reveal much information apart from the method name, required parameters, and the returned type. If you start examining the readConfigFile method more closely, you will notice a potential exception that might be thrown but is not explicitly handled. Specifically, the constructor of the FileReader class can throw a FileNotFoundException. The signature of the FileReader constructor indicates this explicitly:
public FileReader(String fileName) throws FileNotFoundException
In Java, you are required to either catch the exception explicitly or declare it in the method signature using throws FileNotFoundException, ensuring that the caller is aware of the potential exception. However, in Kotlin, there is no requirement to explicitly declare or catch such exceptions, as all exceptions are treated as unchecked. While this simplifies the method signature, it also means that users of this method must carefully handle potential exceptions themselves. With this fundamental knowledge, let’s try to dig into particular methods of handling errors using Kotlin.
Different Flavors of Error Handling in Kotlin
Try-Catch: Traditional Approach
This is the most basic way of handling errors in Kotlin – of course, it is well known from Kotlin's older brother – Java, and many other programming languages. It is a simple tool, familiar to most developers, that is the first line of defense in managing errors.
How it works?
The syntax is very simple, it consists of a try clause where the developer puts the code that may throw an exception. If an exception appears, then the control flow is moved to the catch block, where the catched exception waits to be handled appropriately. The last part of try-catch is optionally the finally block that can be used to execute code regardless of whether an exception was thrown or not - typically the use case for using the finally block is ensuring that all resources were cleaned up in a proper way. Basing on previous snippet code the usage of try-catch would like this way:
try {
val config = readConfigFile(configFilePath)
logger.info("Configuration loaded successfully: $config")
} catch (e: FileNotFoundException) {
// Specific handling of file not found error
logger.error("Configuration file not found at path: $configFilePath", e)
} catch (e: Exception) {
// General handling of other unexpected errors
logger.error("An unexpected error occurred while reading the configuration file", e)
}
This approach to exception handling is straightforward and easy to grasp, even for beginner Kotlin developers or those familiar with Java. It is a good starting point for managing errors, as it allows for granular handling of specific exceptions (e.g., FileNotFoundException) as well as more general ones belonging to the exception hierarchy, while providing a fallback for unexpected issues. However, it has its limitations. The readConfigFile does not explicitly declare the exceptions it might throw, requiring the caller to have implicit knowledge of its behavior. Looking at the method signature, it might seem like it always returns a Map<String, String>. In reality, this isn’t guaranteed - exceptions might be thrown during execution, disrupting the flow and reducing the predictability of the code. Although try-catch is intuitive and sufficient for many basic cases, it lacks explicit error tracking. Developers must rely on implicit knowledge or external documentation to understand which exceptions might be thrown, which can lead to missed or unhandled errors in more complex scenarios. To make the code more predictable and address these challenges, Kotlin offers the Result class, which provides a modern and concise way to handle both success and failure cases explicitly. Let’s explore how this approach simplifies error handling.
Result: A Better Way to Handle Exceptions
While try-catch is a traditional and familiar way of managing errors, Kotlin introduces the Result class as a more modern and idiomatic approach. The Result class allows developers to represent both success and failure as values, making error handling explicit and reducing the need for exceptions in many scenarios. Using Result, you can model operations that may fail, providing a cleaner and more functional way to handle errors. Instead of relying on exceptions to propagate failure, a method can return a Result that explicitly indicates whether the operation succeeded or failed.
How it works?
The Result class simplifies error handling by wrapping the outcome of an operation in a way that explicitly distinguishes success from failure. Instead of relying on exceptions to signal errors, Result allows you to model the result of an operation as either:
- Success: the operation completed successfully and provides the desired output.
- Failure: the operation failed, and the exception is encapsulated in the Result object.
To have better understanding of it let’s try to rewrite readConfigFile function to the form that uses Result class:
fun readConfigFile(filePath: String): Result<Map<String, String>> =
runCatching {
FileReader(filePath).use { reader ->
reader.readLines()
.filter { it.contains("=") }
.associate {
val (key, value) = it.split("=", limit = 2)
key.trim() to value.trim()
}
}
}
You may notice that not too much has been changed except the signature of the method and the usage of runCatching function within readConfigFile. The runCatching function takes a block of code that might throw exceptions. It captures any exception thrown during the execution and returns it as a Result object. If the operation succeeds, runCatching returns a Result object containing the resulting Map<String, String>. If the operation fails (e.g. file does not exist), it returns a Result object encapsulating the exception. Thanks to it, the signature of readConfigFile explicitly indicates that it returns Result<Map<String, String>> making the handling of success and failure explicit for the caller. Let’s have a look on it from the caller perspective:
readConfigFile(configFilePath).fold(
onSuccess = { config ->
logger.info("Configuration loaded successfully: $config")
},
onFailure = { exception ->
// Handle errors based on the type of exception
when (exception) {
is FileNotFoundException ->
// Specific handling for file not found errors
logger.error("Configuration file not found at path: $configFilePath", exception)
else ->
// General handling for all other unexpected exceptions
logger.error("An unexpected error occurred while reading the configuration file", exception)
}
}
)
The biggest change comes from the changed signature of readConfigFile that requires to handle the returned Result. The fold method demands handling both states, which ensures that neither case in the context of Success or Failure is missed by the programmer. This change not only makes the handling of success and failure explicit but also improves the clarity of the API. By requiring the caller to process both success and failure paths, fold minimizes the risk of overlooking edge cases or silently ignoring exceptions. Additionally, it aligns with Kotlin's idiomatic functional programming style, making the code more predictable and easier to maintain. Moreover, using Result can reduce the reliance on traditional try-catch blocks scattered across the codebase, leading to a more concise and declarative approach to error handling. This is particularly useful in complex systems where error propagation needs to be handled consistently.
Finally, the clear separation of success and failure states ensures better testing and debugging, as developers can focus on each specific scenario independently, leading to more robust and reliable software. It’s worthy to mention that the Result class provides many more handy methods to work with Result objects, such as:
- getOrNull: retrieves the value if the operation succeeded, or null if it failed.
- getOrElse: returns the value if successful, or a default value provided in case of failure.
- exceptionOrNull: extracts the exception if the operation failed, or null if it succeeded.
- onSuccess / onFailure: allows performing side effects based on success or failure.
- map / mapCatching: transforms the result’s value while preserving the success or failure state.
- recover / recoverCatching: provides a fallback value or transforms the failure into a success.
These are just a few examples of the useful methods provided by the Result class. For a complete overview, refer to the official Kotlin Result API documentation. Although Result is lightweight and perfect for simple operations, it has its limitations when dealing with more complex scenarios. Specifically, Result falls short when modeling domain-specific errors with rich data or when the application requires handling multiple nuanced error cases. In such situations, we need a more structured and exhaustive approach to ensure type safety and maintainability as the application grows. To address these needs, Kotlin’s sealed classes or sealed interfaces provide a robust framework for modeling complex, domain-specific scenarios.
Sealed Classes: A Structured Approach
Sealed classes provide a robust way to model domain-specific scenarios with exhaustive handling of all cases. In Kotlin, a sealed class is a special kind of class that restricts the set of subclasses that can inherit from it. This ensures that all possible cases are known at compile time, making the code more predictable and type-safe. Unlike generic approaches like Result, sealed classes allow you to model domain-specific scenarios in a way that the Kotlin compiler can enforce, ensuring exhaustive handling of all possible cases. As always, it’s easier to understand the concept with a simple example. Let’s imagine a simplified e-commerce order processing domain. Let's say we want to have a method that is responsible for processing an order. We have got the described specification from the domain expert that the processing of the order should contain the following checks before acknowledge the order:
- The user is allowed to order a selected product regarding its age.
- The product has to be in stock.
- The payment process finished with a success.
Once these checks are satisfied, the method can return a success result. Having this knowledge, we can start modeling our processOrder method.
Modelling Order Processing With Sealed Classes
First, we define a sealed class to represent the result of the order processing. This class will cover all possible outcomes, such as success and various error types:
sealed class OrderProcessingResult {
data object Success : OrderProcessingResult()
sealed class Error : OrderProcessingResult() {
data object AgeRestriction : Error()
data object PaymentFailed : Error()
data object OutOfStock : Error()
data class SystemError(val reason: String) : Error()
}
}
Next, we implement a method that processes the order and returns an OrderProcessingResult. This method will perform all the necessary checks, and the result will indicate the outcome of the operation. Such method could look like:
class OrderProcessor(
private val agePolicy: AgePolicy,
private val stockService: StockService,
private val paymentService: PaymentService
) {
fun processOrder(order: Order): OrderProcessingResult {
return Result.runCatching {
when {
!agePolicy.isUserAllowedToOrder(order.productId, order.orderingUser) ->
OrderProcessingResult.Error.AgeRestriction
stockService.isNotAvailableInStock(order.productId) ->
OrderProcessingResult.Error.OutOfStock
paymentService.processPayment(order.orderingUser) is PaymentResult.Failure ->
OrderProcessingResult.Error.PaymentFailed
// potentially more logic
else -> OrderProcessingResult.Success
}
}.getOrElse { exception ->
OrderProcessingResult.Error.SystemError("Unexpected error: ${exception.message}")
}
}
}
With such an implementation, the user of the method is required to handle the result themselves. From a practical point of view, the best option is to use Kotlin’s when construct, leveraging the compiler’s exhaustive checks. Let's jump into it and see what it looks like:
when (val processingOrderResult = orderProcessor.processOrder(order)) {
is OrderProcessingResult.Error.AgeRestriction -> {
logger.error("Order failed: User is not allowed to order the product due to age restrictions.")
// Additional logic for handling age restriction cases can be implemented here
}
is OrderProcessingResult.Error.OutOfStock -> {
logger.error("Order failed: The product is out of stock. Product ID: ${order.productId}.")
// Additional logic for handling out-of-stock cases can be implemented here
}
is OrderProcessingResult.Error.PaymentFailed -> {
logger.error("Order failed: Payment processing failed for User: ${order.orderingUser}.")
// Additional logic for handling payment failure cases can be implemented here
}
is OrderProcessingResult.Error.SystemError -> {
logger.error("Order failed: System error occurred - ${processingOrderResult.reason}")
// Additional logic for system error handling can be implemented here
}
is OrderProcessingResult.Success -> {
logger.info("Order processed successfully! Order ID: ${order.orderId}.")
// Additional logic for successful order processing can be implemented here
}
}
}
To conclude, using a sealed class to handle the outcomes of processOrder provides a type-safe and comprehensive mechanism that ensures all possible cases are explicitly addressed. Combined with the when statement, this approach makes error handling straightforward and maintainable. By avoiding an else branch, you allow the compiler to enforce handling of all potential outcomes, ensuring the code evolves safely as new cases are introduced.
This approach highlights:
- Readability and clarity: each branch in the when statement corresponds to a specific outcome, making the logic self-explanatory and easy to follow.
- Scalability: adding new result types is straightforward, as the Kotlin compiler enforces handling of all defined cases. This reduces the risk of missed scenarios and promotes safer code development.
- Maintainability: error-handling logic is centralized within a closed hierarchy, making it easier to modify, test, or extend specific cases without affecting the rest of the code.
Kotlin’s compiler checks ensure that all cases are handled, reducing the risk of runtime errors and aligning with Kotlin’s design philosophy. This makes your code clear, reliable, and adaptable to future requirements while maintaining robust error-handling strategies.
Modelling Order Processing With Either
While using sealed classes provides a robust and intuitive way to handle multiple outcomes, Kotlin developers often turn to functional programming constructs like Either, available for through external libraries such as arrow-kt. Designed for scenarios where the result can naturally be split into two distinct types: success and failure. Either originates from functional programming paradigms and allows us to represent these two states in a concise, type-safe manner. This makes it a popular choice in Kotlin for error handling and functional composition.
Let’s revisit the processOrder example. This time, we’ll use Either to model the result of order processing. By replacing the custom sealed class hierarchy with Either, we can simplify the representation of success and failure while leveraging functional-style operations such as map, flatMap, and fold. Here’s how the updated code could look:
sealed class OrderProcessingError {
data object AgeRestriction : OrderProcessingError()
data object PaymentFailed : OrderProcessingError()
data object OutOfStock : OrderProcessingError()
data class SystemError(val reason: String) : OrderProcessingError()
}
class OrderProcessor(
private val agePolicy: AgePolicy,
private val stockService: StockService,
private val paymentService: PaymentService
) {
fun processOrder(order: Order): Either<OrderProcessingError, Unit> =
Result.runCatching {
when {
!agePolicy.isUserAllowedToOrder(order.productId, order.orderingUser) ->
Either.Left(OrderProcessingError.AgeRestriction)
stockService.isNotAvailableInStock(order.productId) ->
Either.Left(OrderProcessingError.OutOfStock)
paymentService.processPayment(order.orderingUser) is PaymentResult.Failure ->
Either.Left(OrderProcessingError.PaymentFailed)
// potentially more logic
else -> Either.Right(Unit)
}
}.getOrElse { exception ->
Either.Left(OrderProcessingError.SystemError("Unexpected error: ${exception.message}"))
}
}
By transitioning from sealed classes to Either, the representation of success and failure in the processOrder method has been restructured. The most noticeable change is in the method’s return type, which now explicitly returns Either. This approach provides a structured and type-safe way to handle both success and failure states.
Conventionally, the failure state is represented using Left, and the success state using Right. In this example, processOrder returns Either where the Left type is OrderProcessingError. This sealed class encapsulates all error cases from the previous OrderProcessingResult.Error hierarchy, ensuring all potential errors are handled explicitly. The Right type is Unit, indicating that there is no special result to represent a successful operation. In the previous implementation, success was represented as OrderProcessingResult.Success, but here, using Unit simplifies the success handling while keeping the design meaningful and clear.
However, this design choice highlights a trade-off between simplicity and expressiveness. While representing success with Unit avoids introducing unnecessary complexity, it limits the ability to convey meaningful domain-specific information for successful outcomes. For instance, if the processOrder method needed to return an order tracking ID or a detailed status for success, a dedicated type for the Right side would be more appropriate.
This compromise illustrates how such decisions depend on the specific requirements of the domain. In scenarios where success involves additional context or data, using a descriptive type enhances clarity and future extensibility. On the other hand, for cases where success carries no significant information, Unit is a concise and practical choice.
After adapting code to Either usage let’s have a look how it can be used from the user perspective:
orderProcessor.processOrder(order).fold(
ifLeft = { orderProcessingError ->
when (orderProcessingError) {
OrderProcessingError.AgeRestriction -> {
logger.error("Order failed: User is not allowed to order the product due to age restrictions.")
// Additional logic for handling age restriction cases can be implemented here
}
OrderProcessingError.OutOfStock -> {
logger.error("Order failed: The product is out of stock. Product ID: ${order.productId}.")
// Additional logic for handling out-of-stock cases can be implemented here
}
OrderProcessingError.PaymentFailed -> {
logger.error("Order failed: Payment processing failed for User: ${order.orderingUser}.")
// Additional logic for handling payment failure cases can be implemented here
}
is OrderProcessingError.SystemError -> {
logger.error("Order failed: System error occurred - ${orderProcessingError.reason}")
// Additional logic for system error handling can be implemented here
}
}
},
ifRight = { logger.info("Order processed successfully! Order ID: ${order.orderId}.") }
)
With the updated implementation using Either, the processOrder result is now handled in a functional manner. Previously, when using sealed classes, the client processed results with Kotlin’s when statement, which explicitly handled every possible case. With Either, the fold method introduces an alternative approach that aligns with functional programming paradigms, enabling concise handling of success and failure scenarios in two branches. However, it’s worth noting that while Either provides a functional flavor, the error representation still relies on a sealed class hierarchy, maintaining the explicit modeling of error states. In addition to fold, Either provides a set of key methods that simplify processing and composing operations:
- map: transforms the Right value while leaving the Left untouched.
- flatMap: chains operations that return another Either, allowing for composable workflows.
- mapLeft: transforms the Left value, leaving the Right untouched.
- getOrElse: returns the Right value or a default value if Left.
- swap: swaps the Left and Right values.
- catch: captures exceptions and transforms them into an Either.
These are some key methods that illustrate the capabilities of the Either class. To explore its full API, check out the official Arrow Either API documentation.
As you can notice, the Either class offers a rich API that supports functional programming, making it ideal for chaining operations, transforming results, and handling success and failure declaratively. It is especially well-suited for projects that adopt a functional approach throughout their codebase, as using Either selectively in an otherwise imperative style may introduce inconsistency. For those curious about its full potential, exploring the arrow-kt library and functional programming can open up even more possibilities for clean and expressive Kotlin code.
Summary: Choosing The Right Error Handling Approach
When deciding how to handle errors in Kotlin, it’s important to choose an approach that fits your domain complexity, team preferences, and the style of your project. Here’s a quick comparison to help you decide:
Approach | Use case | Advantages | Disadvantages |
try-catch | Simple error handling | Easy to understand, native Kotlin | Lack of explicit error tracking, not type-safe |
Result | Basic success/failure handling | Built-in, lightweight | Limited flexibility |
sealed class / interface | Complex domain-specific error modeling | Type-safe, exhaustive | Requires more setup |
Either | Functional-style composition | Powerful, flexible, composable | Requires consistent use of the functional paradigm, the steeper learning curve |
When To Use Each Approach
- Use try-catch: for quick, straightforward error handling where minimal code is needed. Best for scripts or small tasks.
- Use Result: when working on simple success/failure operations that don’t require complex error modeling. Ideal for native Kotlin projects.
- Use sealed classes: for applications with rich domain models, where different error states carry meaningful data and exhaustive handling is critical.
- Use Either: when adopting a functional programming paradigm or dealing with workflows involving complex transformations or chaining operations.
Final Thoughts
Error handling is essential for building robust applications, and there’s no one-size-fits-all solution in Kotlin. The best approach depends on your domain complexity, team preferences, and project needs. For simpler scenarios, start with try-catch or Result. Sealed classes offer clarity and type safety for more complex domains, while Either and functional constructs are ideal for teams adopting a functional programming paradigm. To get started, analyze your domain, align with your team on a consistent approach, and prioritize readability. Begin with simpler tools and explore advanced libraries like arrow-kt as your needs grow. Balancing simplicity, expressiveness, and maintainability ensures your project stays clear and robust.
Reviewed by: Rafał Maciak, Łukasz Rola
Check: Why Should Your Company Consider Switching from Java to Kotlin