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.
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.
omitemptymembuang nilai nol โ termasuk0danfalseyang mungkin bermakna. Sejak Go 1.24 adaomitzeroyang lebih tepat.- Field yang tidak dikenal diabaikan diam-diam saat decode; nyalakan
DisallowUnknownFieldsuntuk 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
}
| Tag | Efek |
|---|---|
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
[]bytemenjadi base64. Bukan array angka. Ini sering mengejutkan saat menyandingkan API Go dengan API bahasa lain.- Slice
nilmenjadinull, bukan[]. Klien yang langsung melakukan.map()akan gagal. Perbaikannya: inisialisasi denganmake([]T, 0)sebelum encode. - Angka masuk ke
anysebagaifloat64. ID 64-bit besar kehilangan presisi. Pakai struct bertipe, ataujson.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
| Struct | Tinggal di | Tugasnya |
|---|---|---|
buatProdukReq | internal/http | Persis apa yang boleh dikirim klien |
produkResp | internal/http | Persis apa yang dilihat klien |
produk.Produk | internal/produk | Model 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.