Jangan Percaya omitempty Saat Ekspos Kolom JSON-TEXT Publik
Kolom JSON-TEXT yang nullable tidak cukup ditutup omitempty: omitempty hanya kenal kekosongan Go, bukan NULL versi SQL. Ini cerita empat lapisan eksposnya.
Ringkasan
Kirain tinggal tambah field plus omitempty beres, ternyata data Info Tambahan tetep nggak muncul karena definisi kosong di SQL dan Go beda. Kuncinya ada di mapper rawJSONObject yang ubah NULL, string kosong, sama 'null' jadi nil biar omitempty beneran ngapus fieldnya. Di frontend tinggal pakai Object entries dengan fallback ?? {} jadi aman walau fieldnya hilang.
Laporan QA masuk pagi itu terkait modul detail entitas. Entitas yang sudah saya simpan dengan bagian "Info Tambahan" tidak pernah muncul di halaman detail publik. Padahal di database, datanya jelas ada. Saya langsung buka kode, mikirnya simpel. Tinggal tambah field di DTO, kasih tag omitempty, beres.
Ternyata tidak sesimpel itu. Sebuah kolom JSON-TEXT yang nullable harus melewati empat lapisan berbeda sebelum bisa dikonsumsi dengan aman oleh klien. Dan di salah satu lapisan itu, asumsi saya tentang cara Go menangani data kosong ternyata salah total.
Tebakan awal yang meleset
Langkah pertama yang saya ambil waktu itu sangat standar. Saya menambahkan field baru di model database, lalu meneruskannya ke DTO.
type EntityDTO struct {
// field lain...
Attributes json.RawMessage `json:"attributes,omitempty"`
}Logikanya terlihat sempurna. Kalau datanya ada, dia muncul. Kalau tidak ada, tag omitempty akan mengurus sisanya dan field itu tidak akan muncul di respons JSON. Saya deploy, buka halaman detail, dan... tetap kosong. Atau lebih buruk lagi, kadang muncul sebagai string kosong yang bikin parser di frontend bingung.
Di sinilah saya sadar kalau tebakan saya tentang omitempty terlalu optimis. Database dan bahasa pemrograman ternyata punya definisi "kosong" yang berbeda.
Empat lapisan perjalanan data
Untuk memperbaiki ini, saya harus memetakan ulang bagaimana data bergerak dari database sampai ke respons HTTP. Ada empat titik yang wajib dikunci.
Pertama, layer model. Kolom ini didefinisikan sebagai sql.NullString dengan tag db:"attributes". Tipe ini adalah destinasi scan resmi untuk kolom yang bisa bernilai NULL [1]. Dia punya dua properti: String dan Valid.
Kedua, layer repository. Query SELECT harus secara eksplisit menyebutkan kolom attributes. Kalau lupa, datanya tidak akan pernah terambil, tidak peduli seberapa benar DTO saya.
Ketiga, layer DTO. Saya memakai json.RawMessage. Saya lebih milih pendekatan pass-through ini daripada mencoba unmarshal di layer backend. Alasannya sederhana: JSON yang disimpan oleh editor adalah JSON yang harus diterima klien, tanpa risiko re-ordering key atau kehilangan format asli akibat proses re-encode yang tidak perlu. Tipe ini memang dirancang untuk menunda decoding atau memakai encoding yang sudah dihitung sebelumnya [2].
Keempat, dan ini yang paling menentukan, adalah layer mapper. Di sinilah letak perbaikan utamanya.
Menjembatani kekosongan SQL dan Go
Masalah utama terjadi karena omitempty di Go hanya mengenali kekosongan versi Go: false, 0, nil pointer, nil interface, dan nilai berpanjang nol (array, slice, map, string) [2].
Sementara itu, di dunia SQL, kekosongan punya tiga wajah: NULL, string kosong '', atau string literal 'null'.
Ketika database mengembalikan NULL, sql.NullString mengatur Valid menjadi false. Tapi kalau saya langsung mengonversinya mentah-mentah ke json.RawMessage, saya bisa berakhir dengan RawMessage berisi string kosong. String kosong ini bukan dianggap kosong oleh omitempty karena dia bukan nil pointer. Akibatnya, field itu tetap muncul di respons API dengan nilai yang tidak berguna.
Solusinya adalah normalisasi eksplisit di mapper. Saya membuat fungsi kecil bernama rawJSONObject yang menerjemahkan kekosongan versi SQL menjadi kekosongan versi Go.
// rawJSONObject: NullString kolom JSON -> RawMessage; kosong/null/"null" -> nil
// agar omitempty membuang field (object JSON dipass verbatim ke response).
func rawJSONObject(ns sql.NullString) json.RawMessage {
if !ns.Valid || ns.String == "" || ns.String == "null" {
return nil
}
return json.RawMessage(ns.String)
}Dengan logika ini, kapan pun database mengembalikan salah satu dari tiga wajah kekosongan tadi, mapper mengembalikannya sebagai nil. Dan baru ketika nil inilah, tag omitempty di DTO benar-benar bekerja: menghilangkan field tersebut dari respons JSON sepenuhnya.
Perbaikan di backend tidak lengkap tanpa memastikan frontend bisa menanganinya dengan elegan. Di sisi klien, kita tidak boleh berasumsi field ini akan selalu ada.
Implementasi di frontend memakai Object.entries(), yang mengembalikan array pasangan kunci-nilai dari properti string-keyed milik objek itu sendiri [3].
const additionalInfo = Object.entries(entity.attributes ?? {});
// render ke ContactCard sebagai daftar dlPerhatikan operator ?? {} di sana. Itu jaring pengaman. Kalau server mengirim null atau field-nya hilang sama sekali karena omitempty, variabel additionalInfo tetap menjadi array kosong, bukan error.
Di sisi dokumentasi, skema OpenAPI mendefinisikan field ini sebagai {type: object, additionalProperties: true}. Di spesifikasi, additionalProperties boleh berupa boolean atau object, dan konsisten dengan JSON Schema default-nya adalah true [4]. Frontend bisa mengetiknya sebagai Record<string, unknown> tanpa khawatir ketidakcocokan tipe yang ketat.
Jadi lain kali kamu menyiapkan ekspos kolom JSON-TEXT ke endpoint publik, jangan berhenti di DTO. Kunci ada di mapper: pastikan sql.NullString diterjemahkan jadi nil untuk ketiga wajah kekosongan SQL. Itu satu-satunya cara supaya omitempty benar-benar menghapus field yang kosong, bukan sekadar mengirim string kosong yang menyesatkan.