Pendahuluan

Perlakuan Go terhadap error sebagai nilai telah melayani kita dengan baik selama dekade terakhir ini. Walaupun dukungan pustaka standar untuk error masih minimal—hanya fungsi errors.New dan fmt.Errorf, yang menghasilkan error yang berisi hanya sebuah pesan—interface error bawaan membolehkan pemrogram Go untuk menambahkan informasi apa pun yang mereka butuhkan. Yang dibutuhkan hanyalah supaya sebuah tipe yang mengimplementasikan method Error:

type QueryError struct {
	Query string
	Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }

Tipe-tipe error seperti di atas sering kita temui, dan informasi yang disimpan bisa beragam, dari timestamp sampai nama berkas sampai alamat server. Terkadang, informasi tersebut mengikutkan error tingkat rendah lainnya untuk menyediakan konteks tambahan.

Pola-pola dari sebuah error yang berisi informasi tambahan begitu banyak digunakan dalam kode Go, setelah diskusi yang mendalam, Go 1.13 menambahkan dukungan eksplisit untuk hal tersebut. Artikel ini menjelaskan penambahan-penambahan ke pustaka bawaan tersebut: tiga fungsi baru dalam paket errors, dan sebuah format baru pada fmt.Errorf.

Sebelum menjelaskan perubahan ini lebih detail, mari kita lihat bagaimana error diperiksa dan dibuat dalam versi bahasa sebelumnya.

Error sebelum Go 1.13

Pemeriksaan error

Error pada Go adalah nilai. Program membuat keputusan berdasarkan nilai-nilai tersebut dalam beberapa cara. Hal yang paling umum yaitu dengan membandingkan sebuah error dengan nil untuk melihat apakah sebuah operasi gagal.

if err != nil {
	// kesalahan terjadi
}

Terkadang kita membandingkan error dengan sebuah nilai sentinel, untuk melihat apakah error tertentu terjadi.

var ErrNotFound = errors.New("not found")

if err == ErrNotFound {
	// sesuatu tidak ditemukan
}

Sebuah nilai error bisa saja bertipe apa pun selama memenuhi interface error. Sebuah program dapat menggunakan konversi tipe atau switch bertipe untuk mengubah atau mendapatkan nilai error menjadi tipe yang diinginkan.

type NotFoundError struct {
	Name string
}

func (e *NotFoundError) Error() string { return e.Name + ": not found" }

if e, ok := err.(*NotFoundError); ok {
	// e.Name tidak ditemukan
}

Menambahkan informasi

Sering kali sebuah fungsi mengembalikan sebuah error ke si pemanggil fungsi dengan menambahkan informasi tambahan, seperti deskripsi singkat dari apa yang terjadi saat error muncul. Salah satu cara sederhana melakukan hal ini yaitu membentuk sebuah error yang baru yang mengikutkan teks dari error sebelumnya:

if err != nil {
	return fmt.Errorf("decompress %v: %v", name, err)
}

Membuat sebuah nilai error baru dengan fmt.Errorf mengindahkan semua informasi yang ada dalam error yang asli kecuali representasi teks dari error asli. Seperti yang kita lihat sebelumnya dengan QueryError, kita mungkin ingin mendefinisikan sebuah tipe error yang baru yang berisi error sebelumnya, menjaganya supaya dapat diinspeksi oleh kode. Sekali lagi berikut QueryError,

type QueryError struct {
	Query string
	Err   error
}

Program dapat melihat nilai di dalam *QueryError untuk membuat keputusan berdasarkan error di dalamnya. Hal seperti ini sering disebut sebagai "membuka" error.

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
	// kueri gagal karena permasalahan akses
}

Tipe os.PathError dalam pustaka standar adalah contoh lain dari sebuah error yang berisi error lainnya.

Error pada Go 1.13

Method Unwrap

Go 1.13 memperkenalkan beberapa fitur baru pada pustaka standar errors dan fmt untuk memudahkan bekerja dengan error yang berisi error lainnya. Hal yang paling signifikan yaitu adanya sebuah konvensi bukan sebuah perubahan: sebuah error yang berisi error lainnya bisa mengimplementasikan sebuah method Unwrap yang mengembalikan error di dalamnya. Jika e1.Unwrap() mengembalikan e2, maka kita bisa mengatakan e1 membungkus e2, dan kita bisa membuka e1 untuk mendapatkan e2.

Dengan mengikuti konvensi ini, kita dapat menambahkan method Unwrap pada tipe QueryError yang mengembalikan error di dalamnya:

func (e *QueryError) Unwrap() error { return e.Err }

Nilai error dari hasil pembukaan sebuah error bisa jadi memiliki method Unwrap juga; kita menyebut urutan error-error yang dihasilkan oleh pembukaan yang berulang ini disebut dengan error chain (rangkaian error).

Memeriksa error dengan Is dan As

Paket errors pada Go 1.13 mengikutkan dua fungsi baru untuk memeriksa error: Is dan As.

Fungsi errors.Is membandingkan sebuah error dengan sebuah nilai.

// Sama dengan:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
	// error karena sesuatu tidak ditemukan.
}

Fungsi As memeriksa apakah sebuah error adalah tipe tertentu, dan mengonversinya ke tipe tersebut jika berhasil.

// Sama dengan:
//   if e, ok := err.(*QueryError); ok { … }
var e *QueryError
if errors.As(err, &e) {
	// err adalah *QueryError, dan e di set menjadi nilai dari error.
}

Pada kasus sederhana, fungsi errors.Is berlaku seperti sebuah pembandingan ke error sentinel, dan fungsi errors.As berlaku seperti konversi tipe. Saat beroperasi pada error yang dibungkus, fungsi-fungsi tersebut melihat semua error di dalam rangkaian error. Mari kita lihat kembali contoh sebelumnya saat membuka sebuah QueryError untuk memeriksa error di dalamnya:

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
	// kueri gagal karena permasalahan akses.
}

Dengan menggunakan fungsi errors.Is, kita dapat menulisnya dengan:

if errors.Is(err, ErrPermission) {
	// err, atau error yang dibungkusnya, adalah kesalahan akses.
}

Paket error juga mengikutkan fungsi baru Unwrap yang mengembalikan hasil dari pemanggilan method Unwrap pada error, atau nil bila error tidak memiliki method Unwrap. Pada umumnya lebih baik menggunakan errors.Is atau errors.As saja, secara fungsi-fungsi tersebut akan memeriksa semua rangkaian error dalam sekali pemanggilan.

Membungkus error dengan %w

Seperti yang telah disebutkan sebelumnya, sangat umum menggunakan fungsi fmt.Errorf untuk menambahkan informasi tambahkan ke dalam sebuah error.

if err != nil {
	return fmt.Errorf("decompress %v: %v", name, err)
}

Pada Go 1.13, fungsi fmt.Errorf mendukung sebuah format baru %w. Bila format ini digunakan, maka error yang dikembalikan oleh fungsi fmt.Errorf akan memiliki method Unwrap yang mengembalikan argumen yang diberikan pada %w, yang haruslah berupa sebuah tipe error. Pada kasus selain itu, %w sama saja dengan %v (misalnya, apabila argumen yang diberikan pada %w tidak mengimplementasikan interface error.)

if err != nil {
	// Mengembalikan sebuah error yang membungkus err.
	return fmt.Errorf("decompress %v: %w", name, err)
}

Membungkus sebuah error dengan %w membuatnya dapat diakses dengan errors.Is dan errors.As.

err := fmt.Errorf("access denied: %w", ErrPermission)
...
if errors.Is(err, ErrPermission) ...

Membungkus error atau tidak?

Saat menambahkan konteks tambahan ke dalam sebuah error, baik dengan fmt.Errorf atau dengan mengimplementasikan tipe kostum, kita harus memutuskan apakah error yang baru membungkus error yang asli. Tidak ada jawaban yang tunggal untuk pertanyaan ini; ia bergantung pada konteks di mana error yang baru dibuat. Bungkus lah sebuah error untuk mengekspose ke pemanggilnya. Jangan membungkus sebuah error bila melakukan hal tersebut akan mengekspose detail implementasi internal.

Sebagai salah satu contoh, bayangkan sebuah fungsi Parse yang membaca sebuah struktur data yang kompleks dari io.Reader. Jika sebuah error terjadi, kita ingin melaporkan nomor baris dan kolom tempat ia terjadi. Jika error terjadi saat membaca dari io.Reader, kita akan membungkus error tersebut supaya permasalahan di dalamnya dapat diperiksa. Secara yang memanggil Parse yang memberikan io.Reader ke fungsi, maka masuk akal untuk mengekspose error yang dihasilkan ke yang memanggil.

Kebalikannya, sebuah fungsi yang melakukan beberapa kali pemanggilan ke database seharusnya tidak mengembalikan error yang membungkus kesalahan pada database. Jika database yang digunakan oleh fungsi adalah detail dari implementasi, maka mengekspose error-error tersebut adalah sebuah pelanggaran dari abstraksi. Misalnya, jika fungsi LookupUser dari sebuah paket pkg menggunakan paket database/sql, maka ia mungkin akan mengembalikan error sql.ErrNoRows. Jika kita mengembalikan error tersebut dengan fmt.Errorf("akses DB: %v", err) maka si pemanggil tidak bisa melihat ke dalam untuk menemukan sql.ErrNoRows. Namun bila fungsi mengembalikan fmt.Errorf("akses DB: %w", err), maka si pemanggil dapat menulis

err := pkg.LookupUser(...)
if errors.Is(err, sql.ErrNoRows) …

Pada titik ini, fungsi tersebut harus selalu mengembalikan sql.ErrNoRows jika kita tidak ingin merusak kode dari program yang menggunakan paket kita, bahkan bila kita mengganti ke paket database yang berbeda. Dengan kata lain, membungkus sebuah error membuat error tersebut menjadi bagian dari API kita. Jika kita tidak ingin membuat error tersebut sebagai bagian dari API, kita seharusnya tidak membungkus error tersebut.

Hal yang penting untuk diingat, baik untuk error yang dibungkus atau tidak, teks yang dihasilkan dari error seharusnya tetap sama. Seseorang yang mencoba memahami error akan memiliki informasi yang sama; pilihan untuk membungkus error atau tidak bergantung pada apakah kita ingin memberikan program suatu informasi tambahan supaya mereka dapat melakukan keputusan berdasarkan informasi yang tersedia, atau menyembunyikan informasi tersebut untuk menjaga lapisan dari abstraksi.

Kustomisasi pengujian error dengan method Is dan As

Fungsi errors.Is memeriksa setiap error dalam sebuah rangkaian untuk kecocokan dengan nilai target. Secara bawaan, sebuah error cocok dengan target jika keduanya sama. Sebagai tambahan, sebuah error dalam rangkaian error bisa mendeklarasikan bahwa ia cocok dengan sebuah target dengan mengimplementasikan method Is.

Sebagai contoh, pertimbangkan error berikut yang terinspirasi oleh paket error pada Upspin yang membandingkan sebuah error dengan sebuah templat, dengan mempertimbangkan hanya field-field yang tidak nol di dalam templat:

type Error struct {
	Path string
	User string
}

func (e *Error) Is(target error) bool {
	t, ok := target.(*Error)
	if !ok {
		return false
	}
	return (e.Path == t.Path || t.Path == "") &&
		(e.User == t.User || t.User == "")
}

if errors.Is(err, &Error{User: "someuser"}) {
	// field User pada err adalah "someuser".
}

Fungsi errors.As dengan cara yang sama memeriksa method As bila ada.

Error dan paket API

Sebuah paket yang mengembalikan error (dan pada kebanyakan memang begitu) seharusnya menjelaskan properti-properti dari error-error tersebut yang mana para pemrogram dapat bergantung kepadanya. Paket yang dirancang dengan baik akan menghindari mengembalikan error dengan properti-properti yang tidak bisa diandalkan.

Spesifikasi yang paling sederhana menyatakan bahwa sebuah operasi bisa sukses atau gagal, mengembalikan nilai error yang nil atau tidak nil. Pada kebanyakan kasus, tidak ada lagi informasi yang diperlukan selain itu.

Jika kita menginginkan sebuah fungsi mengembalikan sebuah kondisi error yang dapat diidentifikasi, seperti "item tidak ditemukan", kita bisa mengembalikan sebuah error yang membungkus sebuah sentinel.

var ErrNotFound = errors.New("not found")

// FetchItem mengembalikan item yang bernama.
//
// Jika nama item tidak ditemukan, FetchItem mengembalikan sebuah error.
// yang membungkus ErrNotFound.
func FetchItem(name string) (*Item, error) {
	if itemNotFound(name) {
		return nil, fmt.Errorf("%q: %w", name, ErrNotFound)
	}
	// ...
}

Ada pola-pola lain yang menyediakan error yang secara semantik dapat diperiksa oleh si pemanggil, seperti dengan mengembalikan nilai sentinel secara langsung, mengembalikan tipe tertentu, atau sebuah nilai yang dapat diperiksa dengan sebuah fungsi.

Pada semua kasus tersebut, haruslah diperhatikan supaya tidak mengekspose detail internal kepada pengguna. Seperti yang telah kita bahas pada bagian "Membungkus error atau tidak?" di atas, saat kita mengembalikan sebuah error dari paket lain kita seharusnya mengonversi error ke bentuk yang tidak mengekpose error di belakangnya, kecuali kalau kita ingin mengembalikan error spesifik tersebut nantinya.

f, err := os.Open(filename)
if err != nil {
	// *os.PathError yang dikembalikan oleh os.Open adalah detail internal.
	// Untuk menghindari pengeksposan keluar, bungkus lah ia sebagai
	// sebuah error yang baru dengan teks yang sama.
	// Kita menggunakan format %v, secara %w akan membolehkan pemanggil
	// membuka *os.PathError yang asli.
	return fmt.Errorf("%v", err)
}

Jika sebuah fungsi didefinisikan mengembalikan sebuah error yang membungkus sentinel atau tipe, jangan kembalikan error di belakangnya secara langsung.

var ErrPermission = errors.New("permission denied")

// DoSomething mengembalikan sebuah error yang membungkus ErrPermission jika
// user tidak memiliki akses.
func DoSomething() error {
    if !userHasPermission() {
        // Jika kita langsung mengembalikan ErrPermission, si pemanggil bisa
	// jadi bergantung pada nilai error, menulis kode seperti berikut:
        //
        //	if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
        //
        // Hal ini akan menimbulkan masalah jika kita ingin menambah konteks
	// terhadap error nantinya.
	// Untuk menghindari ini, kita kembalikan error yang membungkus
	// sentinel supaya user selalu dapat membukanya:
        //
        //	if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
        return fmt.Errorf("%w", ErrPermission)
    }
    // ...
}

Kesimpulan

Meskipun jumlah perubahan yang kita diskusikan hanya tiga fungsi dan sebuah format, kami berharap mereka dapat meningkatkan penanganan error dalam program Go. Kami mengharapkan pembungkusan error yang menyediakan konteks tambahan menjadi hal yang umum, membantu program membuat keputusan yang lebih baik dan membantu pemrogram menemukan bug lebih cepat.

Seperti yang Russ Cox katakan dalam GopherCon 2019, untuk mencapai Go 2 kita bereksperimen, menyederhanakan dan merilis (yang baru). Sekarang karena kita telah merilis perubahan ini, kita menantikan eksperimen-eksperimen yang menggunakannya.