Error sebagai nilai
Go tidak punya exception. Error adalah nilai biasa yang dikembalikan, dibungkus, dan diperiksa seperti data lain — dengan akibat bahwa setiap jalur kegagalan terlihat di kode.
Intisari
errorcuma interface satu method:Error() string.- Bungkus dengan
%wdifmt.Errorfsupaya konteks bertambah tanpa identitas errornya hilang. errors.Ismembandingkan identitas (sentinel);errors.Asmengambil tipe konkretnya.- Bungkus sekali per lapisan, dengan konteks yang tidak diketahui pemanggil. Jangan bungkus lalu juga catat log di tempat yang sama.
- Error sentinel domain (
ErrTidakDitemukan) adalah yang dipetakan jadi status HTTP di lapisan terluar — bukan di dalam domain.
Bentuk dasarnya
type error interface {
Error() string
}
Itu saja. Tiga cara membuatnya, dari yang paling sederhana:
// 1. Sekali pakai
return errors.New("koneksi gagal")
return fmt.Errorf("port %d sudah dipakai", port)
// 2. Sentinel — nilai tetap yang bisa dibandingkan pemanggil
var ErrTidakDitemukan = errors.New("tidak ditemukan")
// 3. Tipe error — saat pemanggil butuh DATA, bukan cuma identitas
type ErrValidasi struct {
Field string
Pesan string
}
func (e *ErrValidasi) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Pesan)
}
Membungkus dengan %w
func (p *Postgres) AmbilProduk(ctx context.Context, id int64) (Produk, error) {
var pr Produk
err := p.db.QueryRow(ctx, kueriAmbil, id).Scan(&pr.ID, &pr.Nama, &pr.Harga)
if errors.Is(err, pgx.ErrNoRows) {
// Terjemahkan error infrastruktur jadi error DOMAIN.
return Produk{}, fmt.Errorf("produk %d: %w", id, ErrTidakDitemukan)
}
if err != nil {
return Produk{}, fmt.Errorf("ambil produk %d: %w", id, err)
}
return pr, nil
}
| Kata kerja | Efek | Kapan |
|---|---|---|
%w | Membungkus — errors.Is/As masih tembus | Hampir selalu |
%v | Menempel teksnya saja — rantainya terputus | Saat kamu sengaja tidak mau membocorkan error dalam ke pemanggil |
Isi pesan bungkus dengan konteks yang tidak diketahui lapisan dalam. Lapisan database tahu
kueri apa yang gagal; ia tidak tahu pesanan siapa yang sedang diproses. Karena itu bungkusnya
bertingkat: proses pesanan 991: ambil produk 42: connection refused. Rantai itulah yang
membuat log satu baris cukup untuk mendiagnosis.
Yang tidak boleh: menulis fmt.Errorf("gagal: %w", err). Kata "gagal" tidak
menambah informasi apa pun, dan setelah lima lapisan pesanmu jadi
gagal: gagal: gagal: gagal: EOF.
Memeriksa: Is versus As
// errors.Is — "apakah di rantai ini ada error yang IDENTIK dengan X?"
if errors.Is(err, ErrTidakDitemukan) {
http.Error(w, "tidak ditemukan", http.StatusNotFound)
return
}
// errors.As — "apakah di rantai ini ada error bertipe T? kalau ya, isi ke variabel"
var ve *ErrValidasi
if errors.As(err, &ve) {
http.Error(w, ve.Field+": "+ve.Pesan, http.StatusUnprocessableEntity)
return
}
// errors.Join — beberapa kegagalan sekaligus (validasi banyak field)
err := errors.Join(errNama, errEmail) // Is/As tetap bekerja untuk keduanya
Jangan pernah membandingkan pesan error dengan string.
if err.Error() == "tidak ditemukan" akan rusak diam-diam begitu ada yang menambah konteks
di lapisan mana pun. errors.Is ada justru untuk itu.
Perbandingan langsung err == ErrTidakDitemukan juga rapuh: ia gagal begitu errornya
dibungkus. errors.Is menembus seluruh rantai.
Memetakan error domain jadi status HTTP
Ini pola yang dipakai sepanjang roadmap: domain tidak tahu apa itu HTTP, dan lapisan HTTP punya satu tempat yang menerjemahkan.
// internal/http/errors.go
func tulisError(w http.ResponseWriter, r *http.Request, err error) {
var (
status = http.StatusInternalServerError
pesan = "terjadi kesalahan"
ve *ErrValidasi
)
switch {
case errors.Is(err, domain.ErrTidakDitemukan):
status, pesan = http.StatusNotFound, "tidak ditemukan"
case errors.Is(err, domain.ErrTidakBerhak):
status, pesan = http.StatusForbidden, "tidak berhak"
case errors.Is(err, domain.ErrKonflik):
status, pesan = http.StatusConflict, "sudah ada"
case errors.As(err, &ve):
status, pesan = http.StatusUnprocessableEntity, ve.Error()
case errors.Is(err, context.DeadlineExceeded):
status, pesan = http.StatusGatewayTimeout, "waktu habis"
}
// Detail lengkap ke log, pesan aman ke pengguna.
if status >= 500 {
slog.ErrorContext(r.Context(), "permintaan gagal",
"err", err, "path", r.URL.Path)
}
tulisJSON(w, status, map[string]string{"error": pesan})
}
Ini juga keputusan keamanan. Pesan error internal sering berisi nama tabel, kueri, alamat host, bahkan potongan data. Yang keluar ke pengguna harus pesan tetap; yang lengkap masuk log. Dibahas lagi di Fase 6.
Bungkus atau catat — pilih satu
| Lapisan | Yang dilakukan |
|---|---|
| Repository / klien luar | Bungkus dengan konteks teknis, kembalikan |
| Service / domain | Bungkus dengan konteks bisnis, atau terjemahkan jadi error domain |
| Handler HTTP / worker | Catat sekali, lalu ubah jadi respons |
Gejala "log berulang": satu kegagalan muncul lima kali di CloudWatch dengan pesan yang mirip. Penyebabnya selalu sama — setiap lapisan mencatat dan mengembalikan error yang sama. Aturannya: yang mengembalikan tidak mencatat; yang mencatat tidak mengembalikan.
Kapan boleh mengabaikan error
defer f.Close() // baca-saja: tidak ada yang bisa dilakukan
_ = json.NewEncoder(w).Encode(v) // klien sudah putus; sengaja diabaikan
// TAPI: pada berkas yang DITULIS, Close() bisa mengembalikan error
// dari flush terakhir — di sana error itu penting.
if err := f.Close(); err != nil {
return fmt.Errorf("tutup %s: %w", nama, err)
}
Linter errcheck (bagian dari golangci-lint) memaksa setiap pengabaian ditulis
eksplisit dengan _ =. Itu bukan formalitas — ia mengubah "lupa" jadi "keputusan yang terlihat
di review".
Latihan: buat sentinel ErrTidakDitemukan di paket domain, kembalikan dari fungsi
repository palsu, bungkus dua kali di lapisan berbeda dengan %w, lalu buktikan
errors.Is masih mengenalinya. Setelah itu ganti satu %w jadi %v dan
lihat errors.Is berhenti bekerja — itu selisih satu huruf yang mematikan seluruh penanganan
errormu.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.