โ† Semua pembelajaran / Go Nol โ†’ Enterprise
Fase 1 ยท Tipe, Interface & Error

encoding/json & struct tag

Di Go, kontrak JSON API ditulis sebagai tag di sebelah field struct. Materi ini tentang aturan yang berlaku, dan tentang memisahkan struct DTO dari struct domain sebelum keduanya terlanjur menyatu.

Sumber asli pkg.go.dev Resmi Rangkuman ~8 menit baca

Intisari

  • Hanya field yang diekspor (huruf besar) yang ikut ter-encode. Field huruf kecil diam-diam hilang.
  • Tanpa tag, nama field dipakai apa adanya. Pencocokan saat decode tidak peka huruf besar-kecil.
  • omitempty membuang nilai nol โ€” termasuk 0 dan false yang mungkin bermakna. Sejak Go 1.24 ada omitzero yang lebih tepat.
  • Field yang tidak dikenal diabaikan diam-diam saat decode; nyalakan DisallowUnknownFields untuk API internal.
  • Jangan decode langsung ke struct domain. Itu adalah mass assignment versi Go.

Tag menentukan bentuk keluaran

type Produk struct {
	ID        int64     `json:"id"`
	Nama      string    `json:"nama"`
	Harga     int64     `json:"harga"`
	Deskripsi string    `json:"deskripsi,omitempty"`  // hilang kalau ""
	Rahasia   string    `json:"-"`                    // TIDAK PERNAH ikut
	Dibuat    time.Time `json:"dibuat"`               // RFC 3339 otomatis
	internal  string                                   // huruf kecil: tidak ikut
}
TagEfek
json:"nama"Ganti nama kunci
json:"-"Selalu dikecualikan โ€” untuk hash password, token, catatan internal
json:"nama,omitempty"Buang kalau bernilai nol (0, "", false, nil, slice/map kosong)
json:"nama,omitzero"Go 1.24+: buang hanya kalau benar-benar nilai nol tipenya โ€” bekerja benar untuk time.Time
json:"nama,string"Encode angka sebagai string โ€” untuk ID 64-bit yang dibaca JavaScript

omitempty tidak bekerja untuk time.Time maupun struct โ€” keduanya tidak pernah dianggap "empty", jadi tanggal nol tetap ikut sebagai "0001-01-01T00:00:00Z". Itu persis masalah yang diselesaikan omitzero di Go 1.24. Kalau kamu masih di versi lama, pakai pointer (*time.Time).

Tiga perilaku default yang mengejutkan

  1. []byte menjadi base64. Bukan array angka. Ini sering mengejutkan saat menyandingkan API Go dengan API bahasa lain.
  2. Slice nil menjadi null, bukan []. Klien yang langsung melakukan .map() akan gagal. Perbaikannya: inisialisasi dengan make([]T, 0) sebelum encode.
  3. Angka masuk ke any sebagai float64. ID 64-bit besar kehilangan presisi. Pakai struct bertipe, atau json.Number.
// Slice kosong yang benar-benar mengeluarkan []
type Daftar struct {
	Item []Produk `json:"item"`
}

d := Daftar{Item: []Produk{}}   // -> {"item":[]}
d := Daftar{}                    // -> {"item":null}

Decode: apa yang terjadi pada field asing

// Diam-diam mengabaikan field yang tidak dikenal โ€” default.
var p Produk
if err := json.Unmarshal(body, &p); err != nil { ... }

// Untuk API internal: tolak field asing supaya salah ketik ketahuan.
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
	return fmt.Errorf("body tidak valid: %w", err)
}

Pilihannya adalah keputusan kompatibilitas. API publik sebaiknya mengabaikan field asing โ€” itu yang membuat klien lama tetap jalan saat kamu menambah field. API internal antar layanan timmu sebaiknya menolak, karena di sana field asing hampir selalu berarti salah ketik atau versi yang tidak sinkron.

Risiko keamanan: jangan decode ke struct domain

// โŒ BAHAYA โ€” pengguna bisa mengirim {"peran":"admin","saldo":999999999}
var u User
json.NewDecoder(r.Body).Decode(&u)
db.Simpan(ctx, u)

Ini mass assignment dalam bentuk Go. Struct domainmu punya field yang tidak boleh datang dari input pengguna โ€” peran, saldo, status verifikasi, ID pemilik. Kalau decoder diarahkan langsung ke sana, semuanya bisa diisi siapa pun.

// โœ… DTO terpisah: hanya berisi apa yang boleh dikirim pengguna
type buatUserReq struct {
	Nama  string `json:"nama"`
	Email string `json:"email"`
}

func (h *Handler) BuatUser(w http.ResponseWriter, r *http.Request) {
	var req buatUserReq
	dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) // batasi 1 MB
	dec.DisallowUnknownFields()
	if err := dec.Decode(&req); err != nil {
		tulisError(w, r, &ErrValidasi{Field: "body", Pesan: "JSON tidak valid"})
		return
	}

	// Field sensitif diisi server, bukan dari body.
	u := domain.User{Nama: req.Nama, Email: req.Email, Peran: domain.PeranBiasa}
	...
}

http.MaxBytesReader bukan opsional. Tanpa itu, satu permintaan dengan body 2 GB akan dibaca sampai habis ke memori โ€” dan container-mu mati kehabisan RAM tanpa satu pun log yang menjelaskan. Ini pertahanan satu baris terhadap kelas serangan yang nyata; muncul lagi di Fase 6.

Struct terpisah untuk masuk dan keluar

StructTinggal diTugasnya
buatProdukReqinternal/httpPersis apa yang boleh dikirim klien
produkRespinternal/httpPersis apa yang dilihat klien
produk.Produkinternal/produkModel domain โ€” bebas berubah

Ongkosnya beberapa baris konversi per endpoint. Yang dibeli: menambah kolom database tidak diam-diam mengubah API publik, dan menghapus field dari API tidak memaksa mengubah domain. Untuk aplikasi yang hidup bertahun-tahun, pemisahan ini terbayar sangat cepat.

Kontrol penuh dengan Marshaler

type Rupiah int64

func (r Rupiah) MarshalJSON() ([]byte, error) {
	return json.Marshal(struct {
		Jumlah int64  `json:"jumlah"`
		Format string `json:"format"`
	}{int64(r), fmt.Sprintf("Rp%d", int64(r))})
}

Di depan: Go 1.25 memperkenalkan encoding/json/v2 sebagai eksperimen (aktif lewat GOEXPERIMENT=jsonv2) โ€” lebih cepat, dengan perilaku yang lebih konsisten. Belum saatnya dipakai di produksi, tapi kalau kamu membaca kode yang mengimpor encoding/json/v2, itu sebabnya.

Latihan: buat struct User berisi Nama, Email, HashPassword, dan Peran. Encode ke JSON dan pastikan hash-nya tidak ikut keluar. Lalu decode body {"nama":"x","peran":"admin"} ke struct itu langsung dan cetak hasilnya โ€” lihat peran yang berubah. Terakhir, perbaiki dengan DTO terpisah dan buktikan peran tidak lagi bisa disetel dari luar.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.