Decode yang berhasil belum berarti data bisa dipakai: json.Unmarshal hanya memeriksa bentuk, bukan makna. Nilai "umur": -5 lolos decode karena bertipe angka yang benar, meskipun mustahil sebagai umur manusia. Aplikasi yang sehat selalu menyisipkan satu lapisan validasi di antara decode dan pemakaian data. Pada chapter ini kita akan membangun lapisan tersebut dengan method Validate(), mengumpulkan semua galat sekaligus memakai errors.Join, dan memakai pointer field untuk membedakan field yang tidak dikirim dari field yang dikirim kosong.

Method Validate dan errors.Join #

Pola paling sederhana: beri tipe data satu method Validate() error yang memeriksa seluruh aturan bisnis. Kebiasaan yang baik adalah mengumpulkan semua pelanggaran, bukan berhenti pada yang pertama, agar pemakai cukup memperbaiki sekali. Package errors menyediakan errors.Join untuk menggabungkan beberapa galat menjadi satu nilai error yang pesannya dirangkai per baris — idiom standar sejak Go 1.20.

Pointer Field untuk Deteksi Kehadiran #

Field string biasa tidak bisa membedakan dua keadaan setelah decode: field tidak ada di JSON (tetap "") dan field dikirim kosong (juga ""). Field pointer menyelesaikan masalah ini: pointer nil berarti field tidak dikirim, pointer ke string kosong berarti dikirim kosong. Aturan “wajib diisi” cukup memeriksa nil; aturan “minimal tiga huruf” memeriksa isi di balik pointer.

Program Lengkap #

Program validasi-demo.go berikut mendecode empat dokumen dengan kualitas berbeda, memvalidasi isinya, lalu mencetak hasil per dokumen:

go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"strings"
)

type Pendaftaran struct {
	NamaLengkap *string      `json:"nama_lengkap"`
	Email       *string      `json:"email"`
	Umur        *int         `json:"umur"`
	Skor        *json.Number `json:"skor"`
	Alamat      string       `json:"alamat"`
}

func (p Pendaftaran) Validate() error {
	var galat []error
	if p.NamaLengkap == nil {
		galat = append(galat, errors.New("nama_lengkap wajib diisi"))
	} else if len(*p.NamaLengkap) < 3 {
		galat = append(galat, fmt.Errorf("nama_lengkap minimal 3 huruf, dapat %d", len(*p.NamaLengkap)))
	}
	if p.Email == nil {
		galat = append(galat, errors.New("email wajib diisi"))
	} else if !strings.Contains(*p.Email, "@") {
		galat = append(galat, fmt.Errorf("email %q tidak memuat tanda @", *p.Email))
	}
	if p.Umur == nil {
		galat = append(galat, errors.New("umur wajib diisi"))
	} else if *p.Umur < 17 || *p.Umur > 90 {
		galat = append(galat, fmt.Errorf("umur harus di rentang 17-90, dapat %d", *p.Umur))
	}
	if p.Skor != nil {
		nilai, err := p.Skor.Float64()
		if err != nil || nilai < 0 || nilai > 100 {
			galat = append(galat, fmt.Errorf("skor harus angka 0-100, dapat %s", *p.Skor))
		}
	}
	return errors.Join(galat...)
}

func prosesDokumen(dokumen string) {
	var p Pendaftaran
	if err := json.Unmarshal([]byte(dokumen), &p); err != nil {
		fmt.Println("decode gagal:", err)
		return
	}
	if err := p.Validate(); err != nil {
		fmt.Println("validasi ditolak:")
		for _, baris := range strings.Split(err.Error(), "\n") {
			fmt.Println("  -", baris)
		}
		return
	}
	fmt.Printf("validasi lolos: nama=%s umur=%d skor=%s\n",
		*p.NamaLengkap, *p.Umur, p.Skor.String())
}

func main() {
	gagal := `{
		"nama_lengkap": "Bo",
		"email": "budi.example.com",
		"umur": 15,
		"skor": 120,
		"kota": "Bandung"
	}`
	prosesDokumen(gagal)

	lolos := `{
		"nama_lengkap": "Budi Santoso",
		"email": "[email protected]",
		"umur": 25,
		"skor": 87.5,
		"alamat": "Jl. Merdeka No. 1"
	}`
	prosesDokumen(lolos)

	tanpaUmur := `{"nama_lengkap":"Siti Aminah","email":"[email protected]"}`
	prosesDokumen(tanpaUmur)

	kosong := `{}`
	prosesDokumen(kosong)
}

Output yang Dihasilkan:

text
validasi ditolak:
  - nama_lengkap minimal 3 huruf, dapat 2
  - email "budi.example.com" tidak memuat tanda @
  - umur harus di rentang 17-90, dapat 15
  - skor harus angka 0-100, dapat 120
validasi lolos: nama=Budi Santoso umur=25 skor=87.5
validasi ditolak:
  - umur wajib diisi
validasi ditolak:
  - nama_lengkap wajib diisi
  - email wajib diisi
  - umur wajib diisi

Mari kita telaah baris demi baris program di atas:

  1. Baris 10-16 (type Pendaftaran): field NamaLengkap, Email, dan Umur bertipe pointer karena wajib diisi; Skor bertipe *json.Number agar angka tetap utuh tanpa pembulatan float64; Alamat biasa karena opsional.
  2. Baris 18-19: Validate memulai dengan slice galat kosong; setiap pelanggaran ditambahkan, bukan langsung di-return.
  3. Baris 20-34: tiga blok pemeriksaan bercabang — cek nil dulu, baru cek isi; cabang kedua hanya berjalan ketika field terbukti terkirim.
  4. Baris 35-40: p.Skor.Float64() mengonversi json.Number; galat konversi atau nilai di luar rentang 0-100 ditolak dengan pesan yang menampilkan nilai mentahnya.
  5. Baris 41: errors.Join(galat...) menggabungkan seluruh pelanggaran menjadi satu galat; bila slice kosong, hasilnya nil — data lolos.
  6. Baris 44-59 (prosesDokumen): alur tetap dua langkah: decode, lalu validasi. Ketika validasi gagal, pesan galat dipecah per baris supaya setiap pelanggaran tampil sebagai butir terpisah.
  7. Baris 61-84: empat dokumen uji — banyak pelanggaran, lolos penuh, satu field hilang, dan dokumen kosong — memperlihatkan semua jalur yang mungkin.

json.Number untuk Angka Presisi #

Dokumen pertama mengirim "skor": 120 dan validasi menolaknya dengan pesan skor harus angka 0-100, dapat 120. Nilai tersebut tersimpan sebagai json.Number, tipe string yang menyimpan angka persis seperti tertulis di JSON, tanpa melewati float64. Validasi cukup memanggil .Float64() ketika angka benar-benar perlu dihitung. Pola ini menghindarkan kita dari jebakan presisi yang dibahas pada chapter Encoding JSON .

Pesan Galat yang Ramah Pemakai #

Setiap pesan pada program menyebut nama field, aturan yang dilanggar, dan nilai yang diterima — misalnya nama_lengkap minimal 3 huruf, dapat 2. Format seperti ini membuat pemakai API bisa memperbaiki input tanpa membaca kode. Ketika validasi berjalan di HTTP handler, pola yang sama menjadi tulang punggung respons galat 422 yang terstruktur; materi lengkapnya ada di chapter Request Validation .

Referensi #

Validasi menjaga kualitas data yang masuk hari ini. Lapisan berikutnya menjaga format data tetap hidup lintas waktu: bagaimana menambah field tanpa merusak pembaca lama — materi chapter Format dan Compatibility.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

validasi-demo.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"strings"
)

type Pendaftaran struct {
	NamaLengkap *string      `json:"nama_lengkap"`
	Email       *string      `json:"email"`
	Umur        *int         `json:"umur"`
	Skor        *json.Number `json:"skor"`
	Alamat      string       `json:"alamat"`
}

func (p Pendaftaran) Validate() error {
	var galat []error
	if p.NamaLengkap == nil {
		galat = append(galat, errors.New("nama_lengkap wajib diisi"))
	} else if len(*p.NamaLengkap) < 3 {
		galat = append(galat, fmt.Errorf("nama_lengkap minimal 3 huruf, dapat %d", len(*p.NamaLengkap)))
	}
	if p.Email == nil {
		galat = append(galat, errors.New("email wajib diisi"))
	} else if !strings.Contains(*p.Email, "@") {
		galat = append(galat, fmt.Errorf("email %q tidak memuat tanda @", *p.Email))
	}
	if p.Umur == nil {
		galat = append(galat, errors.New("umur wajib diisi"))
	} else if *p.Umur < 17 || *p.Umur > 90 {
		galat = append(galat, fmt.Errorf("umur harus di rentang 17-90, dapat %d", *p.Umur))
	}
	if p.Skor != nil {
		nilai, err := p.Skor.Float64()
		if err != nil || nilai < 0 || nilai > 100 {
			galat = append(galat, fmt.Errorf("skor harus angka 0-100, dapat %s", *p.Skor))
		}
	}
	return errors.Join(galat...)
}

func prosesDokumen(dokumen string) {
	var p Pendaftaran
	if err := json.Unmarshal([]byte(dokumen), &p); err != nil {
		fmt.Println("decode gagal:", err)
		return
	}
	if err := p.Validate(); err != nil {
		fmt.Println("validasi ditolak:")
		for _, baris := range strings.Split(err.Error(), "\n") {
			fmt.Println("  -", baris)
		}
		return
	}
	fmt.Printf("validasi lolos: nama=%s umur=%d skor=%s\n",
		*p.NamaLengkap, *p.Umur, p.Skor.String())
}

func main() {
	gagal := `{
		"nama_lengkap": "Bo",
		"email": "budi.example.com",
		"umur": 15,
		"skor": 120,
		"kota": "Bandung"
	}`
	prosesDokumen(gagal)

	lolos := `{
		"nama_lengkap": "Budi Santoso",
		"email": "[email protected]",
		"umur": 25,
		"skor": 87.5,
		"alamat": "Jl. Merdeka No. 1"
	}`
	prosesDokumen(lolos)

	tanpaUmur := `{"nama_lengkap":"Siti Aminah","email":"[email protected]"}`
	prosesDokumen(tanpaUmur)

	kosong := `{}`
	prosesDokumen(kosong)
}