Merancang penanganan error yang eksplisit dan aman: Option, Either, dan Try untuk operasi yang bisa gagal, error effect ala ZIO Cause, lalu validation yang mengumpulkan semua kesalahan dengan cats Validated dan ADT domain error.

Semua program bisa gagal — file tidak ada, koneksi terputus, input tidak valid. Perbedaan antara aplikasi yang rapuh dan yang tangguh bukan pada apakah ia gagal, melainkan bagaimana kegagalan direpresentasikan dan ditangani. Inilah topik episode ini: error handling dan validation di Scala.
Mengapa penting? Karena Scala memberi kalian alat untuk membuat error eksplisit di tipe — tidak mengintai di null atau exception yang tak terduga. Setelah episode ini, kalian akan merancang API yang "tidak mungkin" salah pakai: tipe itu sendiri yang memandu penanganan semua kemungkinan kegagalan.
Kita sudah mengenal Option (episode 5) dan Either (episode 7). Sekarang lengkap dengan Try:
| Container | Makna | Kapan dipakai |
|---|---|---|
Option[A] | Ada (Some) atau tidak ada (None) | Nilai yang bisa absen — misal lookup |
Either[L, R] | Gagal (Left) atau sukses (Right) | Hasil dengan dua kemungkinan berlabel |
Try[A] | Sukses (Success) atau exception (Failure) | Membungkus kode yang melempar exception |
import scala.util.Try
def parseAmount(s: String): Try[Double] =
Try(s.toDouble)
parseAmount("250000") // Success(250000.0)
parseAmount("bukan-angka") // Failure(NumberFormatException)Try adalah jembatan antara dunia Java (yang suka melempar) dan dunia Scala (yang suka tipe). Ketiga container bisa di-map, di-flatMap, dan diproses dengan for:
val result: Try[Double] =
for
a <- parseAmount("100")
b <- parseAmount("50")
yield a + bContainer saja tidak cukup; error-nya harus bisa dipahami. Jangan melempar String mentah — definisikan domain error sebagai ADT (pelajaran episode 7):
enum OrderError:
case InvalidAmount(msg: String)
case InsufficientStock(productId: Int)
case CustomerNotFound(id: Int)
type Result[A] = Either[OrderError, A]Dengan tipe ini, pemanggil tahu semua kegagalan yang mungkin terjadi — dan compiler memaksa mereka menanganinya:
def processOrder(amount: Double): Result[String] =
if amount <= 0 then Left(OrderError.InvalidAmount("jumlah harus positif"))
else Right(s"pesanan diproses: Rp $amount")
processOrder(1000) match
case Right(msg) => println(msg)
case Left(err) => err match
case OrderError.InvalidAmount(m) => println(s"input salah: $m")
case _ => println("kegagalan lain")Di effect system (episode 11), error bisa dimodelkan di level tipe maupun sebagai failure efek.
import cats.effect.IO
import cats.syntax.all.*
val program: IO[String] =
IO.raiseError(new RuntimeException("gagal")).recover {
case _: RuntimeException => "berhasil pulih"
}recover menangkap kegagalan dan memberikan nilai pengganti; attempt mengubah IO[A] menjadi IO[Either[Throwable, A]]:
val safe: IO[Either[Throwable, Int]] =
IO(1 / 0).attemptZIO membawa error di tipe (ZIO[R, E, A]) sehingga failure adalah bagian dari kontrak:
import zio.*
enum AppError:
case DbDown(msg: String)
case NotFound
def loadUser(id: Int): ZIO[Any, AppError, String] =
if id == 1 then ZIO.succeed("Sari")
else ZIO.fail(AppError.NotFound)
val handled: ZIO[Any, Nothing, String] =
loadUser(99).catchAll {
case AppError.NotFound => ZIO.succeed("fallback: tamu")
case AppError.DbDown(msg) => ZIO.fail(new RuntimeException(msg))
}catchAll menangani semua AppError; yang tidak tertangani naik sebagai error bertipe. ZIO Cause adalah representasi internal kegagalan yang bisa memuat multiple failures dan interruptions — konsep lanjutan yang berguna saat debugging fiber.
Masalah dengan Either untuk validasi form: ia berhenti di error pertama. Saat user mengirim form dengan 5 field salah, kalian ingin menampilkan semua 5 kesalahan sekaligus. Solusinya adalah accumulating validation.
Validated dari cats mengumpulkan semua error (accumulation), bukan fail-fast:
libraryDependencies += "org.typelevel" %% "cats-core" % "2.13.0"import cats.data.Validated
import cats.syntax.all.*
case class UserInput(name: String, email: String, age: Int)
def checkName(name: String): Validated[String, String] =
if name.nonEmpty then Validated.valid(name)
else Validated.invalid("nama wajib diisi")
def checkAge(age: Int): Validated[String, Int] =
if age >= 17 then Validated.valid(age)
else Validated.invalid("harus 17 tahun atau lebih")
def validate(input: UserInput): Validated[String, UserInput] =
(checkName(input.name), checkAge(input.age))
.mapN((n, a) => input.copy(name = n, age = a))
val bad = validate(UserInput("", "a@b.c", 10))
// Invalid(nama wajib diisi, harus 17 tahun atau lebih) - dua error!
val ok = validate(UserInput("Sari", "a@b.c", 25))
// Valid(UserInput(Sari, a@b.c, 25))mapN menggabungkan beberapa Validated — jika semua valid, jalankan fungsi; jika ada yang invalid, kumpulkan semuanya. .combine di List[Validated] melakukan hal yang sama untuk banyak field.
Note
Pilihannya penting dan sadar: fail-fast (Either/IO.raiseError) untuk alur yang menghentikan — misal otorisasi gagal — dan accumulating (Validated) untuk input batch — misal validasi form. Keduanya punya tempat; mengenali kapan memakai yang mana adalah keterampilan desain.
Untuk memakai Validated di dalam program effect, cukup konversi di ujung:
import cats.data.Validated
import cats.effect.IO
val program: IO[Either[List[String], UserInput]] =
IO.pure(validate(UserInput("", "a@b.c", 10)))
.map(_.toEither) // Validated -> Either
.map(_.left.map(List(_)))Either/Option; exception untuk hal yang benar-benar luar biasa.String — tanpa ADT, pemanggil tidak bisa membedakan kegagalan dan compiler tidak bisa membantu. Selalu definisikan domain error.Option untuk error yang butuh pesan — None tidak membawa informasi; pakai Either[Error, A] bila butuh detail.Validated untuk form/batch.Inti yang harus dibawa pulang:
Option, Either, Try merepresentasikan operasi yang bisa gagal secara eksplisit di tipe.String mentah.recover/attempt (cats-effect) dan error di level tipe (ZIO), dengan Cause untuk analisis mendalam.Validated mengumpulkan semua kesalahan (accumulating) — lawan dari fail-fast.Di episode 17 selanjutnya, kita mengamankan aplikasi: keamanan dan best practice — immutability, menghindari null, input validation, secret handling, dan dependency scanning. Sampai jumpa di episode 17!