Exception standar seperti IllegalArgumentException atau IOException sering kali terlalu umum untuk merepresentasikan kegagalan domain bisnis. Saat pengguna mengisi formulir dengan email tanpa @, pesan “format salah” jauh lebih bermakna daripada sekadar IllegalArgumentException. Kotlin memungkinkan kita membuat class exception sendiri yang mewarisi Exception atau RuntimeException, menambahkan field konteks, dan menyusun hierarki dengan sealed class. Pada chapter ini kita akan membangun empat pola custom exception yang lazim dipakai di aplikasi nyata.

Dasar Custom Exception #

Custom exception adalah class biasa yang mewarisi Throwable atau subkelasnya. Konstruktor menerima pesan dan opsional cause. Karena Kotlin tidak membedakan checked dan unchecked, pilihan induk hanya menentukan semantik: RuntimeException untuk kegagalan yang umumnya tidak perlu dideklarasikan, Exception bila kita ingin mengikuti konvensi Java yang lebih hati-hati.

kotlin Bentuk paling sederhana
class ValidationError(field: String, message: String) :
    RuntimeException("[$field] $message")

ValidationError mewarisi RuntimeException. Konstruktor menerima field dan message, lalu menyusun pesan gabungan dengan format [field] message. Cara pemanggilan sama dengan exception standar: throw ValidationError("email", "format tidak valid").

Empat Pola Custom Exception #

Contoh berikut menunjukkan empat pola yang sering muncul di aplikasi nyata: exception sederhana, exception dengan field tambahan, exception dengan cause, dan hierarki sealed class untuk validasi formulir.

kotlin
// Custom exception: mewarisi Exception/RuntimeException, menambahkan field konteks, dan membungkus cause.

// 1. Exception domain sederhana — turunan RuntimeException (unchecked).
class ValidationError(field: String, message: String) : RuntimeException("[$field] $message")

// 2. Exception dengan field tambahan untuk konteks bisnis.
class PembayaranGagal(val kodeTransaksi: String, val jumlah: Int, pesan: String) :
    Exception(pesan)

// 3. Exception dengan cause, untuk membungkus exception teknis.
class KonfigurasiError(val sumber: String, pesan: String, cause: Throwable) :
    Exception(pesan, cause)

// 4. Sealed class hierarki error untuk validasi form.
sealed class FormError(message: String) : Exception(message) {
    class FieldKosong(val namaField: String) : FormError("field '$namaField' wajib diisi")
    class FormatSalah(val namaField: String, val nilai: String) :
        FormError("field '$namaField' memiliki format salah: '$nilai'")
    class TerlaluPendek(val namaField: String, val panjang: Int, val minimal: Int) :
        FormError("field '$namaField' hanya $panjang karakter, minimal $minimal")
}

fun validasiForm(nama: String, email: String, umur: String) {
    if (nama.isBlank()) throw FormError.FieldKosong("nama")
    if (nama.length < 3) throw FormError.TerlaluPendek("nama", nama.length, 3)
    if (!email.contains("@")) throw FormError.FormatSalah("email", email)

    val umurInt = umur.toIntOrNull()
        ?: throw FormError.FormatSalah("umur", umur)
    if (umurInt < 0 || umurInt > 150) throw ValidationError("umur", "di luar rentang 0..150")
}

fun bacaKonfigurasi(sumber: String): String {
    return try {
        // simulasi: file tidak ditemukan
        throw java.io.FileNotFoundException(sumber)
    } catch (io: java.io.IOException) {
        throw KonfigurasiError(sumber, "gagal membaca konfigurasi", io)
    }
}

fun main() {
    println("=== ValidationError sederhana ===")
    try {
        throw ValidationError("email", "format tidak valid")
    } catch (e: ValidationError) {
        println("tangkap ${e::class.simpleName}: ${e.message}")
    }

    println()
    println("=== PembayaranGagal dengan field tambahan ===")
    try {
        throw PembayaranGagal("TRX-001", 50000, "saldo tidak cukup")
    } catch (e: PembayaranGagal) {
        println("tangkap ${e::class.simpleName}")
        println("  kodeTransaksi = ${e.kodeTransaksi}")
        println("  jumlah        = ${e.jumlah}")
        println("  message       = ${e.message}")
    }

    println()
    println("=== KonfigurasiError dengan cause ===")
    try {
        bacaKonfigurasi("/etc/app.conf")
    } catch (e: KonfigurasiError) {
        println("tangkap ${e::class.simpleName}: ${e.message}")
        println("  sumber = ${e.sumber}")
        println("  cause  = ${e.cause?.javaClass?.simpleName}: ${e.cause?.message}")
    }

    println()
    println("=== FormError sebagai sealed class ===")
    val kasus = listOf(
        Triple("", "[email protected]", "25"),
        Triple("Budi", "tanpa-atas", "25"),
        Triple("Siti", "[email protected]", "abc"),
        Triple("Ani", "[email protected]", "200"),
    )
    for ((nama, email, umur) in kasus) {
        try {
            validasiForm(nama, email, umur)
            println("validasi OK untuk ($nama, $email, $umur)")
        } catch (e: FormError) {
            val jenis = when (e) {
                is FormError.FieldKosong -> "FieldKosong"
                is FormError.FormatSalah -> "FormatSalah"
                is FormError.TerlaluPendek -> "TerlaluPendek"
            }
            println("[$jenis] $e")
        } catch (e: ValidationError) {
            println("[ValidationError] ${e.message}")
        }
    }
}

Output yang Dihasilkan:

text
=== ValidationError sederhana ===
tangkap ValidationError: [email] format tidak valid

=== PembayaranGagal dengan field tambahan ===
tangkap PembayaranGagal
  kodeTransaksi = TRX-001
  jumlah        = 50000
  message       = saldo tidak cukup

=== KonfigurasiError dengan cause ===
tangkap KonfigurasiError: gagal membaca konfigurasi
  sumber = /etc/app.conf
  cause  = FileNotFoundException: /etc/app.conf

=== FormError sebagai sealed class ===
[FieldKosong] FormError$FieldKosong: field 'nama' wajib diisi
[FormatSalah] FormError$FormatSalah: field 'email' memiliki format salah: 'tanpa-atas'
[FormatSalah] FormError$FormatSalah: field 'umur' memiliki format salah: 'abc'
[ValidationError] [umur] di luar rentang 0..150

Mari kita amati bagian-bagian penting dari kode di atas:

  1. ValidationError: Turunan RuntimeException. Konstruktor menggabungkan field dan message menjadi satu string, sehingga pesan yang tercetak sudah mencantumkan konteks. Field tidak disimpan sebagai property — bila perlu diakses di catch, deklarasikan dengan val.
  2. PembayaranGagal: Turunan Exception. Konstruktor menyimpan kodeTransaksi dan jumlah sebagai property val, sehingga catch dapat membaca konteks bisnis untuk logging atau strategi pemulihan.
  3. KonfigurasiError: Menerima cause (Throwable) di konstruktor dan meneruskannya ke super(pesan, cause). Pola ini penting saat membungkus exception teknis (misal IOException) ke exception domain; stack trace asli tetap tersimpan.
  4. FormError sebagai sealed class: Mendefinisikan tiga subkasus untuk validasi form. Pemanggil dapat menulis when yang exhaustive terhadap subkasus tersebut, sehingga compiler memperingatkan bila ada cabang baru yang belum ditangani.
  5. bacaKonfigurasi: Pola re-throw dengan wrapping. IOException ditangkap, dibungkus ke KonfigurasiError bersama cause, lalu dilempar ke pemanggil. Pemanggil hanya melihat exception domain, namun stack trace asli tetap dapat diakses via e.cause.

Memilih Induk yang Tepat #

Pemilihan induk menentukan semantik exception. Tabel berikut memandu pilihan:

IndukCocok untukCatatan
RuntimeExceptionValidasi input, kondisi tak terduga, kesalahan logikaUnchecked di Java; idiomatik di Kotlin
ExceptionKegagalan yang “wajar” ditangani di layer atasBisa dianggap checked bila dipakai dari Java
IllegalArgumentExceptionArgumen dari pemanggil tidak validSudah dikenal stdlib; gunakan bila pesan standar cukup
IllegalStateExceptionObject dalam state tidak konsistenMisalnya memanggil method sebelum init selesai
Sealed class customHierarki kesalahan domain yang dipakai di banyak tempatwhen exhaustive memberikan keamanan saat refactor

Menambah Field Konteks #

Bila exception hanya membawa pesan string, pemanggil harus memparse string untuk mendapatkan konteks. Cara ini rapuh dan tidak idiomatik. Sebagai gantinya, deklarasikan field val di konstruktor agar dapat diakses langsung:

kotlin Field konteks yang dapat diakses pemanggil
class StokHabis(val sku: String, val diminta: Int, val tersedia: Int) :
    RuntimeException("stok SKU $sku tidak cukup: butuh $diminta, tersedia $tersedia")

fun prosesPesanan(sku: String, qty: Int) {
    val tersedia = 5
    if (qty > tersedia) throw StokHabis(sku, qty, tersedia)
}

Dengan field sku, diminta, dan tersedia, pemanggil dapat membangun ulang kebutuhan pengguna tanpa memparse pesan string, misalnya menyarankan jumlah pesanan yang lebih kecil.

Referensi Lanjutan #

Penutup #

Custom exception memungkinkan kita merepresentasikan kegagalan domain dengan tipe yang bermakna, lengkap dengan field konteks dan cause. Sealed class memberikan keamanan ekstra lewat when exhaustive. Dengan fondasi ini, kita siap mempelajari bagaimana exception merambat naik dari function dalam ke pemanggil terluar — topik yang akan kita bahas pada chapter Exception Propagation.