The Result Type
Many Kotlin operations can fail: parsing text, opening a file, calling a web service. Java-style code handles this with exceptions that jump out of normal control flow, or with nullable types that drop the reason for failure. Kotlin’s Result<T> offers a third option: a standard library type that wraps either a successful value or the Throwable that caused a failure, so “this might fail” becomes an ordinary value you can store, return, transform, and chain instead of an exceptional jump. This lesson covers how Result is represented internally, its complete API, and when to reach for it instead of exceptions or a nullable return type.
Overview: How Result Works
A function that can fail has three common designs in Kotlin. It can throw an exception, which is invisible in the function’s signature and forces every caller into a try/catch. It can return a nullable type like Int?, which tells you an operation failed but discards why. Or it can return a Result<T>, declared directly in the signature (fun parse(s: String): Result<Int>) and carrying the original exception along with the failure, so callers can inspect it, log it, or convert it into a fallback value.
kotlin.Result<T> is declared as a value class (an inline class) with a single internal field of type Any?. On success, that field holds the value itself directly. On failure, it holds a private Failure object wrapping the Throwable. Because a value class can often avoid a separate heap allocation, wrapping a value in Result is typically cheaper than it looks for the common success case, though boxing does happen when a Result is stored in a generic collection, passed as Any?, or handed to println as in the examples below.
The isSuccess and isFailure properties are computed by checking whether that internal field is an instance of the private Failure wrapper – there is no separate tag or sealed hierarchy to switch on. That has one important consequence: Result is not a sealed class, so you cannot exhaustively when over “the success case” and “the failure case” the way you would with a sealed class hierarchy. Instead, you consume a Result with its dedicated functions – isSuccess/isFailure, getOrNull()/exceptionOrNull(), or best of all fold, which forces you to supply a handler for both branches and requires both to return the same type.
Because Result‘s constructor is internal, you can’t write Result(5) yourself. You build one of two ways: the factory functions Result.success(value) and Result.failure(exception), or – far more common in practice – the top-level function runCatching { ... }. It runs a block of code and automatically wraps a normal return value in Result.success, or, if the block throws, wraps the caught Throwable in Result.failure. runCatching catches essentially anything extending Throwable, including Error subclasses like OutOfMemoryError, not just ordinary Exceptions – a broader net than a typical catch (e: Exception), worth remembering before wrapping large blocks of unrelated code in a single runCatching.
Using Result as a function’s return type is fully stable in modern Kotlin (2.x). Older Kotlin 1.x releases required an experimental opt-in annotation before Result stabilized in version 1.5, but today you can declare fun parse(s: String): Result<Int> with no annotation at all.
Syntax
The core factory functions and the shape of a Result-returning function look like this:
fun readConfigValue(key: String): Result<String> {
return if (key.isNotBlank()) Result.success("value-for-$key")
else Result.failure(IllegalArgumentException("Key must not be blank"))
}
The table below summarizes the API you will use most often:
| Member | What it does |
|---|---|
Result.success(value) |
Builds a successful Result wrapping value. |
Result.failure(exception) |
Builds a failed Result wrapping exception. |
runCatching { block } |
Runs block; returns success on a normal return or failure if it throws. |
.isSuccess / .isFailure |
Boolean checks for which case this Result holds. |
.getOrNull() |
The success value, or null on failure. |
.exceptionOrNull() |
The Throwable, or null on success. |
.getOrDefault(default) |
The success value, or a fixed fallback on failure. |
.getOrElse { e -> ... } |
The success value, or a fallback computed from the exception. |
.getOrThrow() |
The success value, or rethrows the original exception. |
.map { ... } |
Transforms the success value; a failure passes through unchanged. |
.mapCatching { ... } |
Like map, but also catches exceptions the transform itself throws. |
.recover { e -> ... } |
Turns a failure into a success by computing a fallback value. |
.onSuccess { ... } / .onFailure { ... } |
Runs a side effect; both return the original Result unchanged so calls can be chained. |
.fold(onSuccess = { ... }, onFailure = { ... }) |
Consumes both branches at once and returns a single value. |
Examples
Example 1: Building and inspecting a Result with runCatching
fun main() {
val success = runCatching { "42".toInt() }
val failure = runCatching { "abc".toInt() }
println(success)
println(success.isSuccess)
println(success.getOrNull())
println(failure)
println(failure.isFailure)
println(failure.exceptionOrNull()?.message)
}
Output:
Success(42)
true
42
Failure(java.lang.NumberFormatException: For input string: "abc")
true
For input string: "abc"
runCatching turns each block into a Result: parsing "42" succeeds, so success becomes Result.success(42), and printing it invokes Result‘s own toString(), which renders as Success(42). Parsing "abc" throws a NumberFormatException, which runCatching catches and wraps, so failure prints as Failure(...) with the exception’s own message included. getOrNull() and exceptionOrNull() give you a safe, null-checked way to reach into whichever side actually applies.
Example 2: Chaining with mapCatching, onSuccess, and onFailure
fun main() {
fun parseAge(input: String): Result<Int> =
runCatching { input.trim().toInt() }
.mapCatching { age ->
require(age in 0..150) { "Age out of range: $age" }
age
}
val inputs = listOf("30", "abc", "200")
for (input in inputs) {
parseAge(input)
.onSuccess { println("Valid age: $it") }
.onFailure { println("Invalid input '$input': ${it.message}") }
}
}
Output:
Valid age: 30
Invalid input 'abc': For input string: "abc"
Invalid input '200': Age out of range: 200
This shows Result composed like a pipeline. runCatching turns a possible parse failure into a Result<Int>, and mapCatching layers a range check on top, catching the IllegalArgumentException from require if the age is out of bounds. onSuccess and onFailure each fire only for their matching case and both return the same Result, which is why they can be chained one after another.
Example 3: A function that returns Result directly, consumed with fold and getOrElse
fun safeDivide(a: Int, b: Int): Result<Int> =
if (b == 0) Result.failure(ArithmeticException("Division by zero"))
else Result.success(a / b)
fun main() {
val results = listOf(safeDivide(10, 2), safeDivide(5, 0))
for (result in results) {
val message = result.fold(
onSuccess = { value -> "Result: $value" },
onFailure = { error -> "Error: ${error.message}" }
)
println(message)
}
val fallback = safeDivide(5, 0).getOrElse { -1 }
println("Fallback: $fallback")
}
Output:
Result: 5
Error: Division by zero
Fallback: -1
safeDivide never throws – it builds the Result explicitly, checking for division by zero itself instead of relying on runCatching. fold consumes both branches into a single String in one expression, and getOrElse shows the shorthand for “give me the value, or compute a fallback from the exception” when you only care about one final value.
How It Works Step by Step
Walk through what Example 2 does for the input "abc":
runCatching { input.trim().toInt() }evaluates"abc".trim().toInt(), which throwsNumberFormatException;runCatchingcatches it and producesResult.failure(NumberFormatException(...))..mapCatching { ... }is called on that failedResult. It checksisFailurefirst, sees it is already a failure, and returns the same failure without ever invoking the transform lambda – so therequirecheck never runs for bad parses.parseAge("abc")returns that unchangedResult<Int>to the caller..onSuccess { ... }checksisSuccess, finds it false, skips its lambda, and passes the sameResultthrough..onFailure { ... }checksisFailure, finds it true, runs its lambda (printing the message), and again returns the sameResultso further chaining would still be possible.
Contrast that with the input "200": runCatching succeeds with Result.success(200), so mapCatching actually invokes its transform this time. Inside that transform, require(age in 0..150) throws IllegalArgumentException – but because mapCatching wraps its own transform in a try/catch, that exception becomes a brand-new Result.failure rather than crashing the program. That catching behavior is exactly what separates mapCatching from plain map, which does not guard its transform at all.
Common Mistakes
Mistake 1: Throwing away the exception with getOrNull
Wrong:
val result = runCatching { "abc".toInt() }
val value = result.getOrNull()
println("Value is $value")
This compiles and runs fine, but it silently converts every failure into null with no trace of what went wrong. The entire point of Result over a plain nullable type is that it carries the original exception – discarding it with getOrNull() and never checking exceptionOrNull() throws away exactly the information Result exists to preserve.
Corrected:
val result = runCatching { "abc".toInt() }
result.onFailure { println("Parsing failed: ${it.message}") }
.onSuccess { println("Value is $it") }
Mistake 2: Comparing a Result to null instead of checking isFailure
Wrong:
val result: Result<Int> = runCatching { "abc".toInt() }
if (result == null) {
println("Failed")
} else {
println("This branch always runs, even though parsing failed")
}
A Result<Int> is never itself null – even a failed operation produces a real Result object wrapping a Failure. Checking result == null is always false regardless of success or failure, so this code silently takes the wrong branch on every failure. The mistake comes from treating Result like a nullable return type instead of a wrapper with its own isSuccess/isFailure state.
Corrected:
val result: Result<Int> = runCatching { "abc".toInt() }
if (result.isFailure) {
println("Failed: ${result.exceptionOrNull()?.message}")
} else {
println("Succeeded: ${result.getOrNull()}")
}
Mistake 3: Calling getOrThrow and defeating the purpose of Result
Wrong:
fun main() {
val result = runCatching { "abc".toInt() }
val value = result.getOrThrow()
println(value)
}
Output:
(no output is printed - the program crashes with an uncaught java.lang.NumberFormatException: For input string: "abc")
getOrThrow() rethrows the original exception on failure. Calling it immediately after runCatching with no other handling just turns the Result back into a thrown exception, which is the exact behavior runCatching was meant to avoid. If you always intend to crash on failure, skip Result entirely and let the original call throw.
Corrected:
fun main() {
val result = runCatching { "abc".toInt() }
val value = result.getOrElse { error ->
println("Falling back to 0 because: ${error.message}")
0
}
println(value)
}
Output:
Falling back to 0 because: For input string: "abc"
0
Best Practices
- Use
Resultfor expected, recoverable failures (parsing, I/O attempts, validation) – keep programming errors like invalid arguments as immediate exceptions viarequire/check, since those signal bugs, not routine failures. - Prefer functional composition (
map,mapCatching,fold,getOrElse) over manually unwrapping withgetOrNull()and re-checking for null everywhere. - Always eventually consume both branches, through
fold,onFailure, orgetOrElse– callinggetOrNull()and moving on throws away the reason for failure. - Wrap only the specific call that can fail in
runCatching, not a large block of unrelated code, so a failure’s cause stays precise. - Remember
runCatchingcatches everyThrowable, includingErrorsubclasses – check the exception type inonFailureif you need to let truly fatal errors propagate instead of silently swallowing them. - Reserve
getOrThrow()(and!!on a nullable result) for cases where a failure genuinely means a bug worth crashing on, not for routine error handling a caller could act on. - Avoid exposing
Result-returning functions on public APIs meant to be called from Java – becauseResultis an inline value class, the compiler mangles its signature on the JVM specifically to discourage awkward Java interop, so Java callers see an ugly, hard-to-use method.
Practice Exercises
- Write
fun parseEmail(input: String): Result<String>that succeeds with the trimmed input if it contains"@", and otherwise fails with anIllegalArgumentExceptiondescribing the problem. Test it against"a@b.com"and"not-an-email", printing the outcome of each withfold. - Using
mapCatching, chain aResult<String>holding a numeric string into aResult<Int>and then into aResult<Int>holding double that value. Try it with both a valid number string and a non-numeric string, and print the final result withgetOrElsefalling back to0. - Given a
List<Result<Int>>built from several calls to a function likesafeDivide, count how many entries are successes and how many are failures usingisSuccess/isFailure, then print both counts. Expected shape of the output: two lines likeSuccesses: 2andFailures: 1.
Summary
Result<T>wraps either a success value or theThrowablefrom a failure, letting “this might fail” live in the return type instead of jumping out via an exception.- It is a value class storing one internal field;
isSuccess/isFailurejust check whether that field is a privateFailurewrapper – there is no sealed hierarchy towhenover. - Build one with
Result.success(value),Result.failure(exception), or – most commonly –runCatching { ... }, which catches any thrownThrowable. - Consume a
ResultwithgetOrNull()/exceptionOrNull(),getOrElse,getOrDefault,map/mapCatching,onSuccess/onFailure, orfoldfor both branches at once. getOrThrow()rethrows the original exception – use it only when a failure should genuinely crash the program.- Never compare a
Resulttonull; it is always a real object, even when it wraps a failure. - Since Kotlin 1.5,
Resultis fully stable as a function return type with no opt-in annotation required.
