Introduction: Why is standard try/catch not enough?
\n\nDevelopment in Kotlin, especially in the context of server applications or Android, often faces the need for reliable error handling. The standard try/catch block is the foundation, but in practice it leads to "scattering" logic and complicating testing. At an advanced level, we want not just to catch exceptions, but to design the system so that errors are part of the data type, rather than an unexpected control flow.
In this article, we will explore how to go beyond try/catch and use functional approaches: Result, Either (from libraries), and sealed class. You will learn how to make code predictable, type-safe, and easy to maintain.
1. Classic try/catch: when is it still useful?
\n\nYou shouldn't completely abandon try/catch. It is indispensable for working with legacy code, external libraries (for example, when parsing JSON), and system calls (input/output). However, there are important nuances.
Proper structure and resources
\nIn Kotlin, unlike Java, there are no checked exceptions, but there is an elegant use construct for automatically closing resources:
fun readFile(path: String): String? = try {\n BufferedReader(FileReader(path)).use { it.readText() }\n} catch (e: IOException) {\n println("Error reading file: ${e.message}")\n null\n}\n\nHere, use guarantees the file is closed, even if an exception occurs inside. This is an elegant replacement for try-with-resources.
The problem: loss of context
\nThe main drawback of try/catch is that it does not inform the calling code that the function might "fail". An exception is a hidden side effect. A call to readFile() might return null or throw an IOException, but the function signature does not reflect this. This leads to runtime errors.
The solution is to move to an explicit representation of errors.
\n\n2. Result: built-in monad for success or failure
\n\nKotlin provides a standard Result<T> class. It is a simple algebraic structure: either success (value T) or failure (Throwable). This makes error handling part of the type system.
Basic usage
\nfun riskyDivision(a: Int, b: Int): Result<Int> = runCatching {\n a / b\n}\n\nfun main() {\n val result = riskyDivision(10, 0)\n result.onSuccess { println("Result: $it") }\n .onFailure { println("Error: ${it.message}") }\n}\n\nrunCatching is a wrapper around try/catch that returns a Result. The onSuccess and onFailure methods allow you to react to the outcome without breaking the call chain.
Transformation and chains
\nYou can transform the result without worrying about exceptions inside map:
\n\nval finalResult = riskyDivision(10, 2)\n .map { it * 2 } // if success — multiply\n .recover { error -> // if error — return a fallback value\n println("Using fallback value due to: ${error.message}")\n 0\n }\nprintln(finalResult.getOrDefault(-1)) // 10\n\nImportant: Result is not intended for storage in collections or passing between threads without additional processing. For these purposes, sealed class or libraries like Arrow are better suited.
3. Either: distinguishing the error type
\n\nThe Result class only captures Throwable. But often we want to distinguish between error types: "not found", "insufficient permissions", "validation error". This is where Either<L, R> from functional libraries (e.g., Arrow or custom