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.
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.
// 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:
=== 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..150Mari kita amati bagian-bagian penting dari kode di atas:
ValidationError: TurunanRuntimeException. Konstruktor menggabungkanfielddanmessagemenjadi satu string, sehingga pesan yang tercetak sudah mencantumkan konteks. Field tidak disimpan sebagai property — bila perlu diakses dicatch, deklarasikan denganval.PembayaranGagal: TurunanException. Konstruktor menyimpankodeTransaksidanjumlahsebagai propertyval, sehinggacatchdapat membaca konteks bisnis untuk logging atau strategi pemulihan.KonfigurasiError: Menerimacause(Throwable) di konstruktor dan meneruskannya kesuper(pesan, cause). Pola ini penting saat membungkus exception teknis (misalIOException) ke exception domain; stack trace asli tetap tersimpan.FormErrorsebagai sealed class: Mendefinisikan tiga subkasus untuk validasi form. Pemanggil dapat menuliswhenyang exhaustive terhadap subkasus tersebut, sehingga compiler memperingatkan bila ada cabang baru yang belum ditangani.bacaKonfigurasi: Pola re-throw dengan wrapping.IOExceptionditangkap, dibungkus keKonfigurasiErrorbersamacause, lalu dilempar ke pemanggil. Pemanggil hanya melihat exception domain, namun stack trace asli tetap dapat diakses viae.cause.
Memilih Induk yang Tepat #
Pemilihan induk menentukan semantik exception. Tabel berikut memandu pilihan:
| Induk | Cocok untuk | Catatan |
|---|---|---|
RuntimeException | Validasi input, kondisi tak terduga, kesalahan logika | Unchecked di Java; idiomatik di Kotlin |
Exception | Kegagalan yang “wajar” ditangani di layer atas | Bisa dianggap checked bila dipakai dari Java |
IllegalArgumentException | Argumen dari pemanggil tidak valid | Sudah dikenal stdlib; gunakan bila pesan standar cukup |
IllegalStateException | Object dalam state tidak konsisten | Misalnya memanggil method sebelum init selesai |
| Sealed class custom | Hierarki kesalahan domain yang dipakai di banyak tempat | when 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:
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 #
- Exceptions — panduan resmi mendeklarasikan custom exception
- Throwable API reference
— API reference
Throwablesebagai superclass exception custom
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.