Result #

Error handling is one of the easiest aspects of programming to write badly. Deeply nested try-catch patterns, silently ignored exceptions, null returned as a failure signal without context — all of these produce code that’s hard to read and hard to debug. Kotlin provides Result<T> as a more expressive alternative: a type representing either success with a value T, or failure with a Throwable. Combined with runCatching, fold, map, recover, and a series of other transformation functions, Result enables clean, composable, explicit error handling — without having to discard exception handling entirely. This article covers the entire Result API, when to use it, and the idiomatic patterns that make error handling feel natural in Kotlin.

What Is Result #

Result<T> is a built-in Kotlin sealed class representing two possible outcomes of an operation: Success wrapping a value T, or Failure wrapping a Throwable.

// The conceptual declaration of Result (implemented as an inline class)
sealed class Result<out T> {
    class Success<T>(val value: T) : Result<T>()
    class Failure(val exception: Throwable) : Result<Nothing>()
}
flowchart LR
    A["An operation\nthat can fail"] --> B{"Succeeded?"}
    B -- Yes --> C["Result.Success(value)\nWraps a value T"]
    B -- No --> D["Result.Failure(exception)\nWraps a Throwable"]
    C --> E["getOrNull() → value\ngetOrThrow() → value\ngetOrElse { } → value\ngetOrDefault(x) → value"]
    D --> E2["exceptionOrNull() → exception\ngetOrElse { fallback }\nrecover { } → a new Result"]
// Creating a Result manually
val sukses: Result<Int> = Result.success(42)
val gagal: Result<Int> = Result.failure(IllegalArgumentException("Invalid input"))

// Checking status
println(sukses.isSuccess)    // true
println(sukses.isFailure)    // false
println(gagal.isSuccess)     // false
println(gagal.isFailure)     // true

// Accessing values
println(sukses.getOrNull())         // 42
println(gagal.getOrNull())          // null
println(sukses.exceptionOrNull())   // null
println(gagal.exceptionOrNull())    // IllegalArgumentException: Invalid input

runCatching — Wrapping Exceptions #

runCatching is the main way to create a Result — it executes a code block and catches all exceptions that occur, wrapping them in a Result.

// Without runCatching — verbose manual try-catch
fun ambilAngka(input: String): Int? {
    return try {
        input.toInt()
    } catch (e: NumberFormatException) {
        null
    }
}

// With runCatching — cleaner
fun ambilAngka(input: String): Result<Int> =
    runCatching { input.toInt() }

// runCatching catches all Exceptions (but not Errors like OutOfMemoryError)
val hasilOK = runCatching { "42".toInt() }    // Success(42)
val hasilGagal = runCatching { "abc".toInt() } // Failure(NumberFormatException)

// runCatching on more complex operations
fun bacaFile(path: String): Result<String> = runCatching {
    java.io.File(path).readText()
}

fun parseJson(json: String): Result<Map<String, Any>> = runCatching {
    // parsing JSON — can throw JsonSyntaxException
    gson.fromJson(json, Map::class.java) as Map<String, Any>
}

// runCatching as an extension — called on an object
val koneksi = buatKoneksi()
val hasilQuery = koneksi.runCatching {
    query("SELECT * FROM users")
}
runCatching catches all Exceptions but not Errors (like OutOfMemoryError, StackOverflowError). This is correct behavior — Error usually signals a critical JVM condition that shouldn’t be caught in normal business logic.

Accessing Values from Result #

Result provides several ways to access its value, each with different behavior on failure.

getOrNull and exceptionOrNull #

val sukses = Result.success(42)
val gagal = Result.failure<Int>(RuntimeException("Oops"))

// getOrNull — null on failure
val nilai: Int? = sukses.getOrNull()   // 42
val nilai2: Int? = gagal.getOrNull()   // null

// exceptionOrNull — null on success
val ex: Throwable? = sukses.exceptionOrNull()  // null
val ex2: Throwable? = gagal.exceptionOrNull()  // RuntimeException

// Idiomatic usage
val input = "123"
runCatching { input.toInt() }
    .getOrNull()
    ?.let { println("Success: $it") }
    ?: println("Failed to parse input")

getOrThrow #

// getOrThrow — get the value or throw the original exception
val sukses = Result.success(42)
val nilai = sukses.getOrThrow()   // 42

val gagal = Result.failure<Int>(IllegalStateException("Invalid state"))
val nilaiGagal = gagal.getOrThrow()   // throws IllegalStateException!

// When to use getOrThrow: when you're certain of success, or intentionally propagating
fun prosesYangSudahValidasi(result: Result<Int>): Int {
    // Assumes the result was already validated earlier
    return result.getOrThrow()
}

getOrDefault and getOrElse #

val gagal = Result.failure<Int>(RuntimeException("Failed"))

// getOrDefault — a static default value on failure
val nilai = gagal.getOrDefault(0)   // 0

// getOrElse — a dynamic default value via a lambda
// Receives the exception as a parameter — useful for logging or different fallbacks
val nilaiElse = gagal.getOrElse { exception ->
    println("Error: ${exception.message}")
    -1
}
// prints "Error: Failed"
// returns -1

// ANTI-PATTERN: getOrDefault for all cases — loses the error context
fun ambilKonfigurasi(kunci: String): String {
    return runCatching { bacaKonfigurasi(kunci) }
        .getOrDefault("")   // error silently ignored!
}

// CORRECT: getOrElse with logging
fun ambilKonfigurasi(kunci: String): String {
    return runCatching { bacaKonfigurasi(kunci) }
        .getOrElse { e ->
            log.warn("Config '$kunci' not found: ${e.message}, using default")
            ""
        }
}

Transforming Results #

Result supports functional transformation — you can change the contents of a Result without leaving the Result context.

map and mapCatching #

// map — transform the value on success, skip on failure
val hasilParse: Result<Int> = runCatching { "42".toInt() }
val hasilKuadrat: Result<Int> = hasilParse.map { it * it }   // Success(1764)

val hasilGagal: Result<Int> = runCatching { "abc".toInt() }
val hasilGagalMap: Result<Int> = hasilGagal.map { it * it }  // Failure(stays the same)

// map doesn't catch exceptions — if the transformation throws, it crashes!
val berbahaya = hasilParse.map {
    if (it > 100) throw IllegalStateException("Too big!")
    it
}   // can crash if the value > 100

// mapCatching — a map that catches exceptions from the transformation
val aman = hasilParse.mapCatching {
    if (it > 100) throw IllegalStateException("Too big!")
    it
}   // Failure(IllegalStateException) if the value > 100

// A transformation pipeline
fun prosesInput(input: String): Result<String> =
    runCatching { input.trim() }
        .map { it.toInt() }
        .map { it * 2 }
        .map { "Result: $it" }

prosesInput("  21  ")   // Success("Result: 42")
prosesInput(" abc ")    // Failure(NumberFormatException)

recover and recoverCatching #

recover allows recovery from failure — turning a Failure into a Success with a fallback value.

// recover — on failure, try to recover to another value
val hasilGagal: Result<Int> = Result.failure(RuntimeException("Failed"))

val dipulihkan: Result<Int> = hasilGagal.recover { exception ->
    when (exception) {
        is NumberFormatException -> 0    // recover with 0 for format errors
        is IllegalArgumentException -> -1
        else -> throw exception           // rethrow if unknown
    }
}
// Success(0) on NumberFormatException

// recover for data fallbacks
fun ambilDataDariCache(kunci: String): Result<String> = runCatching {
    cache.get(kunci) ?: throw NoSuchElementException("Not in the cache")
}

fun ambilDataDariDB(kunci: String): Result<String> = runCatching {
    database.query("SELECT nilai FROM data WHERE kunci = ?", kunci)
}

// Try the cache, fall back to the DB
fun ambilData(kunci: String): Result<String> =
    ambilDataDariCache(kunci)
        .recover { ambilDataDariDB(kunci).getOrThrow() }

// recoverCatching — a recover that also catches exceptions from the recovery block
val amanDipulihkan = hasilGagal.recoverCatching { exception ->
    ambilDataFallback()   // if this also throws, it's wrapped as a new Failure
}

fold — Handling Success and Failure Together #

fold is the most expressive way to handle both cases — success and failure — in a single expression.

val result: Result<Int> = runCatching { "42".toInt() }

// fold takes two lambdas: onSuccess and onFailure
val pesan: String = result.fold(
    onSuccess = { nilai -> "Got the value: $nilai" },
    onFailure = { exception -> "Failed: ${exception.message}" }
)

// fold with the same return type — very useful for API responses
fun formatRespons(result: Result<List<Produk>>): String = result.fold(
    onSuccess = { produk ->
        if (produk.isEmpty()) "No products"
        else "${produk.size} products found"
    },
    onFailure = { e ->
        when (e) {
            is java.net.ConnectException -> "Failed to connect to the server"
            is java.net.SocketTimeoutException -> "Connection timeout"
            else -> "An error occurred: ${e.message}"
        }
    }
)

// fold vs when — both idiomatic, choose the clearer one
val result2: Result<User> = ambilUser()

// with fold
val tampilan = result2.fold(
    onSuccess = { user -> "Hello, ${user.nama}!" },
    onFailure = { "User not found" }
)

// with when + isSuccess/isFailure
val tampilan2 = when {
    result2.isSuccess -> "Hello, ${result2.getOrThrow().nama}!"
    else -> "User not found"
}

onSuccess and onFailure — Side Effects #

onSuccess and onFailure are the side-effect versions that return the same Result — useful for logging, analytics, or other side effects without breaking the chain.

val result = runCatching { ambilDataDariApi() }

// onSuccess: only executed on success, returns the same Result
result
    .onSuccess { data ->
        log.info("Data fetched successfully: ${data.size} items")
        analytics.track("data_fetch_success")
    }
    .onFailure { exception ->
        log.error("Failed to fetch data", exception)
        analytics.track("data_fetch_failure", exception.javaClass.simpleName)
    }

// Useful in pipelines — doesn't break the chain
fun prosesData(input: String): Result<ProcessedData> =
    runCatching { parse(input) }
        .onFailure { log.warn("Parse failed for input: $input") }
        .map { parsed -> validasi(parsed) }
        .onSuccess { log.debug("Validation succeeded") }
        .mapCatching { valid -> proses(valid) }
        .onSuccess { log.info("Processing complete") }
        .onFailure { log.error("Processing failed", it) }

Result Composition #

Result can be composed — combining several operations that can each fail.

Sequential Composition #

// Several operations that can each fail
fun validasiEmail(email: String): Result<String> = runCatching {
    require(email.contains("@")) { "Invalid email" }
    email.trim().lowercase()
}

fun validasiPassword(password: String): Result<String> = runCatching {
    require(password.length >= 8) { "Password at least 8 characters" }
    require(password.any { it.isDigit() }) { "Password must contain a digit" }
    password
}

fun buatAkun(email: String, password: String): Result<User> {
    val emailValid = validasiEmail(email)
        .getOrElse { return Result.failure(it) }

    val passwordValid = validasiPassword(password)
        .getOrElse { return Result.failure(it) }

    return runCatching {
        userRepository.buat(emailValid, passwordValid)
    }
}

// A more concise way with andThen (a custom extension function)
infix fun <T, R> Result<T>.andThen(transform: (T) -> Result<R>): Result<R> =
    fold(
        onSuccess = { transform(it) },
        onFailure = { Result.failure(it) }
    )

fun buatAkunRingkas(email: String, password: String): Result<User> =
    validasiEmail(email)
        .andThen { emailValid -> validasiPassword(password).map { emailValid to it } }
        .andThen { (emailValid, passValid) ->
            runCatching { userRepository.buat(emailValid, passValid) }
        }

Combining Multiple Results #

// Run several independent operations and collect the results
fun <T> List<Result<T>>.allOrFailure(): Result<List<T>> {
    val values = mutableListOf<T>()
    for (result in this) {
        result.fold(
            onSuccess = { values.add(it) },
            onFailure = { return Result.failure(it) }
        )
    }
    return Result.success(values)
}

val hasil = listOf(
    runCatching { "1".toInt() },
    runCatching { "2".toInt() },
    runCatching { "abc".toInt() }   // this one fails
).allOrFailure()
// Failure(NumberFormatException)

// Collect all results (both successes and failures)
fun <T> List<Result<T>>.partisi(): Pair<List<T>, List<Throwable>> {
    val sukses = mutableListOf<T>()
    val gagal = mutableListOf<Throwable>()
    forEach { result ->
        result.fold(
            onSuccess = { sukses.add(it) },
            onFailure = { gagal.add(it) }
        )
    }
    return sukses to gagal
}

val inputs = listOf("1", "dua", "3", "empat", "5")
val (berhasil, errors) = inputs
    .map { runCatching { it.toInt() } }
    .partisi()
// berhasil: [1, 3, 5]
// errors: [NumberFormatException, NumberFormatException]

Result vs Exception vs Nullable #

Three approaches to error handling in Kotlin — each suited to different situations.

flowchart TD
    A{Error type?} --> B["Expected errors\n(invalid input, missing resource)"]
    A --> C["Unexpected errors\n(bugs, unpredictable conditions)"]
    A --> D["No value\n(not an error condition)"]

    B --> E{Need error\ncontext?}
    E -- Yes --> F["Result<T>\nCarries the exception with information"]
    E -- No --> G["T?\n(nullable) is enough"]

    C --> H["Regular exceptions\n(throw/catch)\nLet them propagate"]

    D --> I["T?\n(nullable)\nNull = no value"]
// Nullable — for "maybe there's no value", not errors
fun cariUser(id: Int): User? = database.findById(id)  // null = not found, not an error

// Exceptions — for conditions that shouldn't happen (bugs)
fun ambilItemKeranjang(index: Int): Item {
    require(index >= 0) { "Index must not be negative" }  // a programming error
    return keranjang[index]
}

// Result — for operations that can fail due to external factors
fun unduhGambar(url: String): Result<ByteArray> = runCatching {
    URL(url).readBytes()  // can fail: network error, invalid URL, timeout
}

// ANTI-PATTERN: Result for everything
fun tambah(a: Int, b: Int): Result<Int> = Result.success(a + b)  // no Result needed!

// ANTI-PATTERN: null to hide errors
fun parseUser(json: String): User? {
    return try {
        gson.fromJson(json, User::class.java)
    } catch (e: Exception) {
        null  // error information is lost!
    }
}

// CORRECT: Result for errors that need to be reported
fun parseUser(json: String): Result<User> = runCatching {
    gson.fromJson(json, User::class.java)
}

Idiomatic Patterns in Production Code #

The Repository Pattern with Result #

interface UserRepository {
    fun cariById(id: Long): Result<User>
    fun simpan(user: User): Result<User>
    fun hapus(id: Long): Result<Unit>
}

class UserRepositoryImpl(private val db: Database) : UserRepository {

    override fun cariById(id: Long): Result<User> = runCatching {
        db.query("SELECT * FROM users WHERE id = ?", id)
            ?.let { parseUser(it) }
            ?: throw NoSuchElementException("User with id $id not found")
    }

    override fun simpan(user: User): Result<User> = runCatching {
        validasiUser(user)
        db.insert(user.toMap())
        user.copy(id = db.lastInsertId())
    }

    override fun hapus(id: Long): Result<Unit> = runCatching {
        val dihapus = db.delete("DELETE FROM users WHERE id = ?", id)
        if (dihapus == 0) throw NoSuchElementException("User not found")
    }
}

Use Cases / Services with Result #

class DaftarUserUseCase(
    private val userRepo: UserRepository,
    private val emailService: EmailService,
    private val analytics: Analytics
) {
    fun eksekusi(request: DaftarRequest): Result<User> =
        validasiRequest(request)
            .andThen { userRepo.cariByEmail(request.email)
                .fold(
                    onSuccess = { Result.failure(IllegalStateException("Email is already registered")) },
                    onFailure = { Result.success(request) }   // not found = OK
                )
            }
            .andThen { userRepo.simpan(User.dari(request)) }
            .onSuccess { user ->
                emailService.kirimVerifikasi(user.email)
                    .onFailure { log.warn("Failed to send verification email", it) }
                analytics.track("user_registered", user.id)
            }
            .onFailure { log.error("Registration failed: ${it.message}") }

    private fun validasiRequest(request: DaftarRequest): Result<DaftarRequest> =
        runCatching {
            require(request.email.contains("@")) { "Invalid email" }
            require(request.password.length >= 8) { "Password at least 8 characters" }
            require(request.nama.isNotBlank()) { "Name must not be empty" }
            request
        }
}

API Response Handlers #

// Mapping from Result to HTTP responses in Ktor
suspend fun ApplicationCall.respondResult(result: Result<Any>) {
    result.fold(
        onSuccess = { data ->
            respond(HttpStatusCode.OK, mapOf("data" to data, "success" to true))
        },
        onFailure = { exception ->
            val (status, pesan) = when (exception) {
                is NoSuchElementException -> HttpStatusCode.NotFound to exception.message
                is IllegalArgumentException -> HttpStatusCode.BadRequest to exception.message
                is IllegalStateException -> HttpStatusCode.Conflict to exception.message
                is SecurityException -> HttpStatusCode.Forbidden to "Access denied"
                else -> {
                    log.error("Unhandled error", exception)
                    HttpStatusCode.InternalServerError to "An internal error occurred"
                }
            }
            respond(status, mapOf("error" to pesan, "success" to false))
        }
    )
}

// Usage in a route handler
get("/users/{id}") {
    val id = call.parameters["id"]?.toLongOrNull()
        ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID")
    call.respondResult(userService.cariById(id))
}

When to Use Result #

Use Result<T> if:
  ✓ The operation involves external resources (network, files, databases)
  ✓ Failure is a valid condition that needs handling
  ✓ You need to carry information about the failure type (exception type)
  ✓ Pipelines of operations that can each fail
  ✓ Public APIs that need to communicate errors without throwing exceptions

Use nullable (T?) if:
  ✓ The absence of a value is a normal condition, not an error
  ✓ You don't need to know why there's no value (find, firstOrNull)
  ✓ Simple transformations: null on failure

Keep using try-catch if:
  ✗ Errors are exceptional (shouldn't happen)
  ✗ You need to catch exceptions from libraries that don't return Result
  ✗ Handling errors at boundary layers (UI, API handlers)

Summary #

  • Result<T> represents two possibilities: Success(value) or Failure(exception) — making the possibility of failure explicit in the type, not hidden.
  • runCatching { } is the main way to create a Result — wraps all Exceptions from the code block. Doesn’t catch Errors (OutOfMemoryError, etc.).
  • getOrElse { } is preferred over getOrDefault() because it receives the exception as a parameter — usable for logging or determining fallbacks based on the error type.
  • fold(onSuccess, onFailure) is the most expressive way to handle both cases in one expression — use it instead of when (result.isSuccess).
  • map transforms the success value without leaving the Result context. mapCatching catches exceptions from the transformation. recover turns a failure into a success with a fallback value.
  • onSuccess and onFailure for side effects (logging, analytics) without breaking the pipeline — both return the same Result.
  • andThen (a custom extension) for sequential composition — run the next operation only if the previous one succeeded.
  • Don’t use Result for everything — nullable T? is enough when the absence of a value is a normal condition. Direct exceptions are more appropriate for bugs and conditions that shouldn’t happen.
  • Result isn’t a replacement for exceptions — it complements exceptions for cases where failure is part of the normal program flow, not an exceptional condition.
  • The runCatching → map → recover → onSuccess → onFailure → fold pipeline is a complete pattern that makes error handling feel as natural as ordinary data transformation.

← Previous: Sequences   Next: Enum →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact