Menangani kegagalan secara terstruktur di Haskell: error yang recoverable lewat Maybe dan Either dengan ExceptT dan MonadError, exception I/O dengan Control.Exception dan bracket untuk resource safety, serta mengganti fungsi partial seperti head dan fromJust dengan fungsi total yang aman.

Tidak ada program yang selalu berhasil. Di episode 16 ini kita merapikan cara Haskell menangani kegagalan — dan pentingnya bab ini lebih besar dari kelihatannya, karena pilihan arsitektur error menentukan kualitas API yang kalian bangun. Di bahasa lain, hampir semua kegagalan diperlakukan sebagai exception yang bisa "meledak" di mana saja; di Haskell, kegagalan adalah nilai yang bisa diperiksa, dikomposisikan, dan dipetakan ke response HTTP.
Mengapa bab ini penting? Karena perbedaan antara "aplikasi yang tidak pernah crash tak terduga" dan "aplikasi yang sering 500" sering kali hanya soal disiplin ini: error recoverable ditangani sebagai data, exception hanya untuk hal langka dan tak terduga.
Haskell membedakan dua jenis kegagalan secara eksplisit:
| Jenis | Contoh | Alat |
|---|---|---|
| Error recoverable | input invalid, data tidak ditemukan | Maybe, Either, ExceptT |
| Exception | file tidak ada, koneksi putus | Control.Exception |
Aturannya: gunakan nilai error (Maybe/Either) untuk kegagalan yang diharapkan dan harus diproses kode; gunakan exception untuk kegagalan tak terduga dari lingkungan (I/O, resource).
Dasar-dasarnya sudah kita bahas di episode 11. Yang perlu ditambahkan di sini: Either dengan tipe error yang terstruktur. Alih-alih Either String, gunakan ADT:
data AppError
= ValidationError String
| NotFoundError String
| DatabaseError String
deriving (Show)
simpanUser :: String -> Either AppError User
simpanUser nama
| null nama = Left (ValidationError "nama tidak boleh kosong")
| otherwise = Right (User 1 nama)Dengan ADT, AppError bisa dicocokkan dengan pola, di-map ke status HTTP, dan di-log secara konsisten:
httpStatus :: AppError -> Int
httpStatus (ValidationError _) = 400
httpStatus (NotFoundError _) = 404
httpStatus (DatabaseError _) = 500Sekarang seluruh aplikasi punya satu bahasa error — bukan string acak yang tersebar.
Saat handler berurutan memakai IO (database, HTTP), error perlu berjalan di dalam stack monad. ExceptT e IO a menyatukan keduanya:
import Control.Monad.Except
type AppM a = ExceptT AppError IO a
cariUser :: Int -> AppM User
cariUser id = do
hasil <- liftIO (queryDB id) -- IO murni diangkat ke stack
case hasil of
Nothing -> throwError (NotFoundError ("user " ++ show id))
Just u -> pure u
getUserHandler :: Int -> AppM User
getUserHandler id = do
u <- cariUser id
pure uthrowError membawa error ke Left; liftIO membungkus aksi IO ke dalam stack. Di puncak stack, jalankan dan tangkap hasilnya:
runApp :: AppM a -> IO (Either AppError a)
runApp = runExceptTMonadError adalah typeclass yang mengabstraksi kemampuan "lempar error" ini — kode yang memakai MonadError e m bisa dijalankan di atas ExceptT, atau diganti implementasinya untuk test.
Tip
Jika kalian pernah memakai try/catch secara manual di bahasa lain, pola Haskell adalah kebalikannya: jangan lempar exception untuk error yang bisa diprediksi. Kembalikan Left/Nothing sebagai data, dan serahkan "penerjemahan" (ke status HTTP, ke log) ke lapisan terluar.
Untuk kegagalan lingkungan, Control.Exception menyediakan mekanisme seperti try/catch. Contoh membaca file yang mungkin tidak ada:
import Control.Exception (try, IOException)
bacaAman :: FilePath -> IO (Either IOException String)
bacaAman fp = try (readFile fp)try mengembalikan Left exception atau Right nilai. Untuk resource safety — memastikan resource ditutup apa pun yang terjadi — gunakan bracket:
import Control.Exception (bracket)
import qualified Data.Text.IO as T
prosesFile :: FilePath -> IO ()
prosesFile fp = bracket
(openFile fp ReadMode) -- akuisisi resource
hClose -- release: dijalankan SELALU
(\h -> T.hGetContents h >>= T.putStrLn)bracket dijamin memanggil hClose baik aksi sukses maupun melempar exception — inilah idiom withResource/try-with-resources ala Haskell, dan implementasi internal dari withResource di episode 15.
Fungsi partial adalah fungsi yang crash untuk sebagian input: head [], fromJust Nothing, tail [], read "abc". Ini adalah sumber crash runtime yang paling mudah dihindari.
-- PARTIAL (berbahaya): crash saat list kosong
pertama :: [a] -> a
pertama (x:xs) = x
-- pertami [] -> error
-- TOTAL (aman): selalu menghasilkan nilai
pertamaSafely :: [a] -> Maybe a
pertamaSafely [] = Nothing
pertamaSafely (x:_) = Just xCara menghindarinya:
head → safeHead :: [a] -> Maybe a atau pakai Data.Maybe.listToMaybe.fromJust → pattern matching Just x <- ... atau maybe def f:!! (index) → lookup atau Data.List.findIndex dengan Maybe.import Data.Maybe (maybe)
sapaPengguna :: Maybe String -> String
sapaPengguna = maybe "tamu" (\n -> "Halo " ++ n)maybe menerapkan fungsi ke nilai dalam Just, atau mengembalikan default untuk Nothing — tanpa pernah crash.
Warning
Menggunakan head/fromJust adalah bau kode di Haskell modern. GHC bahkan punya warning -Wx-partial yang menandai fungsi-fungsi partial dari Prelude. Aktifkan warning ini (ghc-options: -Wall -Wx-partial) dan jadikan kegagalan tak terduga mustahil — itulah esensi fungsi total (dibahas lanjut di episode 17).
Pola Just x <- ... (episode 11) memakai MonadFail — kegagalan terstruktur di dalam monad (misal Nothing untuk Maybe). Exception dipakai untuk hal yang tak terduga. Jangan campur keduanya: jika kode bisa menghasilkan Nothing/Left sebagai bagian dari domain, selalu tangani sebagai data.
Left/Nothing.head/fromJust/tail tanpa jaminan — ganti dengan versi total; aktifkan -Wx-partial.bracket — resource bocor saat exception; selalu wrapping resource dengan bracket.throwError di kode yang seharusnya pure — ExceptT punya versi pure (Except), gunakan sesuai konteks.SomeException — menangkap semua exception menyembunyikan bug; tangkap tipe spesifik (IOException, dll).Inti yang harus dibawa pulang:
Maybe/Either); exception = kegagalan lingkungan tak terduga.ExceptT + MonadError membawa error ke dalam stack monad yang melibatkan IO.bracket menjamin resource ditutup apa pun yang terjadi.head, fromJust) adalah musuh; tulis fungsi total yang selalu menghasilkan nilai.Di episode 17 selanjutnya kita akan membahas keamanan & best practice — total functions dan type-driven design untuk membuat illegal states tidak representable, menghindari unsafe*, validasi input dengan Megaparsec, serta mengaudit dependensi dengan cabal-audit dan CVE. Sampai jumpa di episode 17!