Kotlin Error Handling with Result and runCatching: A Practical Guide
Why I Stopped Throwing Exceptions for Expected Failures
Early in my Kotlin career I treated every error as an exception. Network timeouts, malformed JSON, missing configuration — all ended up in a try / catch block that bubbled up the call stack. The code worked, but the control flow became hard to follow, and callers had to remember which exceptions each function could throw.
Then I discovered Result<T> and the runCatching helper. They let me model fallible operations as values, keeping the happy path linear and the error path explicit. The result is code that reads like a specification rather than a series of defensive checks.
Real‑World Scenario: Parsing a Config File
Imagine a service that reads a JSON configuration at startup. The file might be missing, contain invalid JSON, or miss required fields. Throwing exceptions forces the caller to wrap the whole initialization in a try block, and the compiler offers no hint about what can go wrong.
With Result the function signature tells the whole story:
fun loadConfig(path: String): Result<AppConfig> = runCatching {
val json = File(path).readText()
Json.decodeFromString<AppConfig>(json)
}
The caller can now decide how to react — log, provide defaults, or abort — without any catch clauses:
val configResult = loadConfig("config.json")
configResult
.onSuccess { cfg -> initializeApp(cfg) }
.onFailure { err -> logger.error("Config load failed", err) }
.fold(
onSuccess = { it },
onFailure = { DefaultConfig }
)
Under the Hood: What runCatching Does
runCatching { … } is a tiny wrapper that executes a lambda and captures any Throwable into a Result.Failure. If the lambda completes normally, the value is wrapped in Result.Success. The implementation is essentially:
inline fun <T> runCatching(block: () -> T): Result<T> {
try {
Result.success(block())
} catch (e: Throwable) {
Result.failure(e)
}
}
Because it’s inline, the compiler can eliminate the lambda overhead, making it as fast as a hand‑rolled try / catch.
Chaining Transformations Without Nesting
One of the biggest wins is the ability to chain map, flatMap, and recover calls. Suppose after loading the config we need to validate a URL and then create an HTTP client:
fun buildClient(config: AppConfig): Result<HttpClient> = runCatching {
validateUrl(config.apiEndpoint)
}.flatMap { url ->
runCatching { HttpClientBuilder().baseUrl(url).build() }
}
The caller sees a single Result<HttpClient> and can handle success or failure uniformly. No pyramid of if (result.isSuccess) … else … blocks.
When to Keep Exceptions
Rule of thumb: Use
Resultfor *expected* failures — missing files, bad input, service‑level errors. Reserve exceptions for *programming errors* (null pointer, illegal state) that indicate a bug.
This distinction keeps the type system honest. A function that returns Result<T> promises “I’ll tell you if something predictable went wrong.” A function that throws signals “I assume the preconditions hold; if they don’t, it’s a bug.”
Testing Becomes Trivial
Because the error path is a value, unit tests can assert on the exact failure type without mocking exception throwing:
@Test
fun `loadConfig returns failure when file missing`() {
val result = loadConfig("nonexistent.json")
assertTrue(result.isFailure)
assertInstanceOf<FileNotFoundException>(result.exceptionOrNull())
}
No need for assertThrows or custom test rules.
Performance Note
The Result class is a value class (inline class) since Kotlin 1.5, so there’s virtually no allocation overhead for the success case. The failure case allocates a thin wrapper around the throwable, which is comparable to the cost of throwing and catching an exception — but only when an error actually occurs.
Putting It All Together
Adopting Result and runCatching across a codebase yields a consistent error‑handling vocabulary. New team members can read a function signature and instantly know which failures are part of the contract. The happy path stays uncluttered, and the error path is handled where it makes sense — often at a higher architectural boundary.
Next time you reach for try / catch around a predictable I/O call, ask yourself: “Can I return a Result instead?” The answer is usually yes, and the resulting code will be easier to read, test, and maintain.