Result

Result #

Error handling adalah salah satu aspek pemrograman yang paling mudah ditulis dengan buruk. Pola try-catch bersarang yang dalam, exception yang diabaikan diam-diam, null yang dikembalikan sebagai sinyal kegagalan tanpa konteks — semuanya menghasilkan kode yang sulit dibaca dan sulit di-debug. Kotlin menyediakan Result<T> sebagai alternatif yang lebih ekspresif: sebuah tipe yang merepresentasikan baik keberhasilan dengan nilai T, maupun kegagalan dengan Throwable. Dikombinasikan dengan runCatching, fold, map, recover, dan serangkaian fungsi transformasi lainnya, Result memungkinkan error handling yang bersih, composable, dan eksplisit — tanpa harus membuang exception handling sepenuhnya. Artikel ini membahas seluruh API Result, kapan menggunakannya, dan pola idiomatik yang membuat error handling terasa alami di Kotlin.

Apa Itu Result #

Result<T> adalah sealed class bawaan Kotlin yang merepresentasikan dua kemungkinan hasil sebuah operasi: Success yang membungkus nilai T, atau Failure yang membungkus Throwable.

// Deklarasi konseptual Result (diimplementasikan sebagai 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["Operasi yang\nbisa gagal"] --> B{"Berhasil?"}
    B -- Ya --> C["Result.Success(nilai)\nMembungkus nilai T"]
    B -- Tidak --> D["Result.Failure(exception)\nMembungkus Throwable"]
    C --> E["getOrNull() → nilai\ngetOrThrow() → nilai\ngetOrElse { } → nilai\ngetOrDefault(x) → nilai"]
    D --> E2["exceptionOrNull() → exception\ngetOrElse { fallback }\nrecover { } → Result baru"]
// Membuat Result secara manual
val sukses: Result<Int> = Result.success(42)
val gagal: Result<Int> = Result.failure(IllegalArgumentException("Input tidak valid"))

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

// Mengakses nilai
println(sukses.getOrNull())         // 42
println(gagal.getOrNull())          // null
println(sukses.exceptionOrNull())   // null
println(gagal.exceptionOrNull())    // IllegalArgumentException: Input tidak valid

runCatching — Membungkus Exception #

runCatching adalah cara utama membuat Result — ia mengeksekusi blok kode dan menangkap semua exception yang terjadi, membungkusnya dalam Result.

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

// Dengan runCatching — lebih bersih
fun ambilAngka(input: String): Result<Int> =
    runCatching { input.toInt() }

// runCatching menangkap semua Exception (tapi bukan Error seperti OutOfMemoryError)
val hasilOK = runCatching { "42".toInt() }    // Success(42)
val hasilGagal = runCatching { "abc".toInt() } // Failure(NumberFormatException)

// runCatching pada operasi yang lebih kompleks
fun bacaFile(path: String): Result<String> = runCatching {
    java.io.File(path).readText()
}

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

// runCatching sebagai extension — dipanggil pada objek
val koneksi = buatKoneksi()
val hasilQuery = koneksi.runCatching {
    query("SELECT * FROM users")
}
runCatching menangkap semua Exception tapi tidak menangkap Error (seperti OutOfMemoryError, StackOverflowError). Ini perilaku yang benar — Error biasanya menandakan kondisi JVM yang kritis dan tidak seharusnya ditangkap dalam logika bisnis normal.

Mengakses Nilai dari Result #

Result menyediakan beberapa cara untuk mengakses nilainya, masing-masing dengan perilaku berbeda saat gagal.

getOrNull dan exceptionOrNull #

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

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

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

// Penggunaan idiomatik
val input = "123"
runCatching { input.toInt() }
    .getOrNull()
    ?.let { println("Berhasil: $it") }
    ?: println("Gagal parse input")

getOrThrow #

// getOrThrow — ambil nilai atau lempar exception aslinya
val sukses = Result.success(42)
val nilai = sukses.getOrThrow()   // 42

val gagal = Result.failure<Int>(IllegalStateException("State tidak valid"))
val nilaiGagal = gagal.getOrThrow()   // melempar IllegalStateException!

// Kapan pakai getOrThrow: saat kamu yakin sukses, atau sengaja propagate exception
fun prosesYangSudahValidasi(result: Result<Int>): Int {
    // Asumsi result sudah divalidasi sebelumnya
    return result.getOrThrow()
}

getOrDefault dan getOrElse #

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

// getOrDefault — nilai default statis jika gagal
val nilai = gagal.getOrDefault(0)   // 0

// getOrElse — nilai default dinamis via lambda
// Menerima exception sebagai parameter — bisa digunakan untuk logging atau fallback berbeda
val nilaiElse = gagal.getOrElse { exception ->
    println("Error: ${exception.message}")
    -1
}
// prints "Error: Gagal"
// returns -1

// ANTI-PATTERN: getOrDefault untuk semua kasus — kehilangan konteks error
fun ambilKonfigurasi(kunci: String): String {
    return runCatching { bacaKonfigurasi(kunci) }
        .getOrDefault("")   // error diabaikan diam-diam!
}

// BENAR: getOrElse dengan logging
fun ambilKonfigurasi(kunci: String): String {
    return runCatching { bacaKonfigurasi(kunci) }
        .getOrElse { e ->
            log.warn("Konfigurasi '$kunci' tidak ditemukan: ${e.message}, pakai default")
            ""
        }
}

Transformasi Result #

Result mendukung transformasi fungsional — kamu bisa mengubah isi Result tanpa keluar dari konteks Result.

map dan mapCatching #

// map — transformasi nilai jika sukses, lewati jika gagal
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(tetap sama)

// map tidak menangkap exception — jika transformasi melempar, crash!
val berbahaya = hasilParse.map {
    if (it > 100) throw IllegalStateException("Terlalu besar!")
    it
}   // bisa crash jika nilai > 100

// mapCatching — map yang menangkap exception dari transformasi
val aman = hasilParse.mapCatching {
    if (it > 100) throw IllegalStateException("Terlalu besar!")
    it
}   // Failure(IllegalStateException) jika nilai > 100

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

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

recover dan recoverCatching #

recover memungkinkan pemulihan dari kegagalan — mengubah Failure menjadi Success dengan nilai fallback.

// recover — jika gagal, coba pulihkan ke nilai lain
val hasilGagal: Result<Int> = Result.failure(RuntimeException("Gagal"))

val dipulihkan: Result<Int> = hasilGagal.recover { exception ->
    when (exception) {
        is NumberFormatException -> 0    // pulihkan dengan 0 untuk format error
        is IllegalArgumentException -> -1
        else -> throw exception           // lempar ulang jika tidak diketahui
    }
}
// Success(0) jika NumberFormatException

// recover untuk fallback data
fun ambilDataDariCache(kunci: String): Result<String> = runCatching {
    cache.get(kunci) ?: throw NoSuchElementException("Tidak ada di cache")
}

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

// Coba cache, fallback ke DB
fun ambilData(kunci: String): Result<String> =
    ambilDataDariCache(kunci)
        .recover { ambilDataDariDB(kunci).getOrThrow() }

// recoverCatching — recover yang juga menangkap exception dari blok recovery
val amanDipulihkan = hasilGagal.recoverCatching { exception ->
    ambilDataFallback()   // jika ini juga melempar, dibungkus sebagai Failure baru
}

fold — Tangani Sukses dan Gagal Sekaligus #

fold adalah cara paling ekspresif untuk menangani kedua kasus — sukses dan gagal — dalam satu ekspresi.

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

// fold menerima dua lambda: onSuccess dan onFailure
val pesan: String = result.fold(
    onSuccess = { nilai -> "Berhasil mendapat nilai: $nilai" },
    onFailure = { exception -> "Gagal: ${exception.message}" }
)

// fold dengan tipe return yang sama — sangat berguna untuk response API
fun formatRespons(result: Result<List<Produk>>): String = result.fold(
    onSuccess = { produk ->
        if (produk.isEmpty()) "Tidak ada produk"
        else "${produk.size} produk ditemukan"
    },
    onFailure = { e ->
        when (e) {
            is java.net.ConnectException -> "Gagal terhubung ke server"
            is java.net.SocketTimeoutException -> "Koneksi timeout"
            else -> "Terjadi kesalahan: ${e.message}"
        }
    }
)

// fold vs when — keduanya idiomatik, pilih yang lebih jelas
val result2: Result<User> = ambilUser()

// dengan fold
val tampilan = result2.fold(
    onSuccess = { user -> "Halo, ${user.nama}!" },
    onFailure = { "Pengguna tidak ditemukan" }
)

// dengan when + isSuccess/isFailure
val tampilan2 = when {
    result2.isSuccess -> "Halo, ${result2.getOrThrow().nama}!"
    else -> "Pengguna tidak ditemukan"
}

onSuccess dan onFailure — Efek Samping #

onSuccess dan onFailure adalah versi efek samping yang mengembalikan Result yang sama — berguna untuk logging, analytics, atau side effects lain tanpa memutus chain.

val result = runCatching { ambilDataDariApi() }

// onSuccess: dieksekusi hanya jika sukses, mengembalikan Result yang sama
result
    .onSuccess { data ->
        log.info("Data berhasil diambil: ${data.size} item")
        analytics.track("data_fetch_success")
    }
    .onFailure { exception ->
        log.error("Gagal ambil data", exception)
        analytics.track("data_fetch_failure", exception.javaClass.simpleName)
    }

// Berguna dalam pipeline — tidak memutus chain
fun prosesData(input: String): Result<ProcessedData> =
    runCatching { parse(input) }
        .onFailure { log.warn("Parse gagal untuk input: $input") }
        .map { parsed -> validasi(parsed) }
        .onSuccess { log.debug("Validasi berhasil") }
        .mapCatching { valid -> proses(valid) }
        .onSuccess { log.info("Proses selesai") }
        .onFailure { log.error("Proses gagal", it) }

Komposisi Result #

Result bisa dikomposisikan — menggabungkan beberapa operasi yang masing-masing bisa gagal.

Komposisi Berurutan #

// Beberapa operasi yang masing-masing bisa gagal
fun validasiEmail(email: String): Result<String> = runCatching {
    require(email.contains("@")) { "Email tidak valid" }
    email.trim().lowercase()
}

fun validasiPassword(password: String): Result<String> = runCatching {
    require(password.length >= 8) { "Password minimal 8 karakter" }
    require(password.any { it.isDigit() }) { "Password harus mengandung angka" }
    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)
    }
}

// Cara lebih ringkas dengan andThen (extension function kustom)
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) }
        }

Menggabungkan Beberapa Result #

// Jalankan beberapa operasi independen dan kumpulkan hasilnya
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() }   // ini gagal
).allOrFailure()
// Failure(NumberFormatException)

// Kumpulkan semua hasil (sukses dan gagal)
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 #

Tiga pendekatan untuk error handling di Kotlin — masing-masing cocok untuk situasi berbeda.

flowchart TD
    A{Jenis error?} --> B["Error yang diharapkan\n(input tidak valid, resource tidak ada)"]
    A --> C["Error yang tidak terduga\n(bug, kondisi tak terprediksi)"]
    A --> D["Tidak ada nilai\n(bukan kondisi error)"]

    B --> E{Butuh konteks\nerror?}
    E -- Ya --> F["Result<T>\nMembawa exception dengan informasi"]
    E -- Tidak --> G["T?\n(nullable) cukup"]

    C --> H["Exception biasa\n(throw/catch)\nBiarkan propagate"]

    D --> I["T?\n(nullable)\nNull = tidak ada nilai"]
// Nullable — untuk "mungkin tidak ada nilai", bukan error
fun cariUser(id: Int): User? = database.findById(id)  // null = tidak ditemukan, bukan error

// Exception — untuk kondisi yang tidak seharusnya terjadi (bug)
fun ambilItemKeranjang(index: Int): Item {
    require(index >= 0) { "Index tidak boleh negatif" }  // programming error
    return keranjang[index]
}

// Result — untuk operasi yang bisa gagal karena faktor eksternal
fun unduhGambar(url: String): Result<ByteArray> = runCatching {
    URL(url).readBytes()  // bisa gagal: network error, URL tidak valid, timeout
}

// ANTI-PATTERN: Result untuk semua hal
fun tambah(a: Int, b: Int): Result<Int> = Result.success(a + b)  // tidak perlu Result!

// ANTI-PATTERN: null untuk menyembunyikan error
fun parseUser(json: String): User? {
    return try {
        gson.fromJson(json, User::class.java)
    } catch (e: Exception) {
        null  // informasi error hilang!
    }
}

// BENAR: Result untuk error yang perlu dilaporkan
fun parseUser(json: String): Result<User> = runCatching {
    gson.fromJson(json, User::class.java)
}

Pola Idiomatik dalam Kode Produksi #

Repository Pattern dengan 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 dengan id $id tidak ditemukan")
    }

    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 tidak ditemukan")
    }
}

Use Case / Service dengan 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 sudah terdaftar")) },
                    onFailure = { Result.success(request) }   // tidak ditemukan = OK
                )
            }
            .andThen { userRepo.simpan(User.dari(request)) }
            .onSuccess { user ->
                emailService.kirimVerifikasi(user.email)
                    .onFailure { log.warn("Gagal kirim email verifikasi", it) }
                analytics.track("user_registered", user.id)
            }
            .onFailure { log.error("Pendaftaran gagal: ${it.message}") }

    private fun validasiRequest(request: DaftarRequest): Result<DaftarRequest> =
        runCatching {
            require(request.email.contains("@")) { "Email tidak valid" }
            require(request.password.length >= 8) { "Password minimal 8 karakter" }
            require(request.nama.isNotBlank()) { "Nama tidak boleh kosong" }
            request
        }
}

API Response Handler #

// Mapping dari Result ke HTTP response di 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 "Akses ditolak"
                else -> {
                    log.error("Unhandled error", exception)
                    HttpStatusCode.InternalServerError to "Terjadi kesalahan internal"
                }
            }
            respond(status, mapOf("error" to pesan, "success" to false))
        }
    )
}

// Penggunaan di route handler
get("/users/{id}") {
    val id = call.parameters["id"]?.toLongOrNull()
        ?: return@get call.respond(HttpStatusCode.BadRequest, "ID tidak valid")
    call.respondResult(userService.cariById(id))
}

Kapan Menggunakan Result #

Gunakan Result<T> jika:
  ✓ Operasi melibatkan resource eksternal (network, file, database)
  ✓ Kegagalan adalah kondisi yang valid dan perlu ditangani
  ✓ Butuh membawa informasi tentang jenis kegagalan (exception type)
  ✓ Pipeline operasi yang masing-masing bisa gagal
  ✓ API publik yang perlu mengkomunikasikan error tanpa melempar exception

Gunakan nullable (T?) jika:
  ✓ Tidak adanya nilai adalah kondisi normal, bukan error
  ✓ Tidak butuh tahu alasan tidak ada nilai (find, firstOrNull)
  ✓ Transformasi sederhana: null jika gagal

Tetap gunakan try-catch jika:
  ✓ Error bersifat exceptional (seharusnya tidak terjadi)
  ✓ Perlu menangkap exception dari library yang tidak mengembalikan Result
  ✓ Menangani error di boundary layer (UI, API handler)

Ringkasan #

  • Result<T> merepresentasikan dua kemungkinan: Success(nilai) atau Failure(exception) — membuat kemungkinan kegagalan eksplisit di tipe, bukan tersembunyi.
  • runCatching { } adalah cara utama membuat Result — membungkus semua Exception dari blok kode. Tidak menangkap Error (OutOfMemoryError, dll).
  • getOrElse { } lebih disukai dari getOrDefault() karena menerima exception sebagai parameter — bisa digunakan untuk logging atau menentukan fallback berdasarkan jenis error.
  • fold(onSuccess, onFailure) adalah cara paling ekspresif menangani kedua kasus dalam satu ekspresi — gunakan ini sebagai pengganti when (result.isSuccess).
  • map mentransformasi nilai sukses tanpa keluar dari konteks Result. mapCatching menangkap exception dari transformasi. recover mengubah kegagalan menjadi sukses dengan nilai fallback.
  • onSuccess dan onFailure untuk efek samping (logging, analytics) tanpa memutus pipeline — keduanya mengembalikan Result yang sama.
  • andThen (extension kustom) untuk komposisi berurutan — jalankan operasi berikutnya hanya jika operasi sebelumnya sukses.
  • Jangan gunakan Result untuk semua hal — nullable T? cukup saat tidak adanya nilai adalah kondisi normal. Exception langsung lebih tepat untuk bug dan kondisi yang tidak seharusnya terjadi.
  • Result bukan pengganti exception — ia melengkapi exception untuk kasus di mana kegagalan adalah bagian dari flow normal program, bukan kondisi luar biasa.
  • Pipeline runCatching → map → recover → onSuccess → onFailure → fold adalah pola lengkap yang membuat error handling terasa senatural transformasi data biasa.

← Sebelumnya: Sequences   Berikutnya: Enum →

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