JSON Toolkit

Enam tipe, presisi angka, dan jebakan encoding

Intinya: JSON hanya punya enam tipe, tetapi masing-masing bisa diam-diam mengubah bentuk data Anda. Artikel ini membahas jebakan angka, string, dan null dalam satu kali jalan.

Pemula±12 menit bacaDiterbitkan 2026-10-30Diperbarui 2026-10-30
Sampul untuk Enam tipe, presisi angka, dan jebakan encoding

Intinya: periksa dulu apakah field ID atau uang melebihi 2^53 - 1 atau membutuhkan banyak tempat desimal presisi, lalu periksa apakah batas panjang string menghitung code point atau unit UTF-16; bawa nilai apa pun di luar rentang safe integer sebagai string alih-alih mempercayai parser untuk mempertahankan presisi.

JSON punya lebih sedikit tipe daripada yang dibayangkan kebanyakan orang, dan lebih banyak jebakan: angka bergeser, string rusak, dan null adalah hal yang berbeda dari kunci yang hilang.

1. Lanskap tipe

Tipe Contoh literal Batasan dalam JSON Pemetaan umum
Object {"a": 1} Kunci harus string, nilai boleh apa saja Object / Map / struct
Array [1, "a", null] Elemen boleh heterogen Array / List / slice
String "text" UTF-8, kutip ganda String
Number 1, -1.5, 1e9 Tanpa nol di depan, tanpa +, tanpa NaN double / Decimal
Boolean true / false Huruf kecil bool
null null Satu-satunya nilai null null / Option::None

Perhatikan dua hal yang tidak ada: tidak ada tipe tanggal, dan tidak ada tipe integer. Semua tanggal adalah string, dan semua integer serta floating-point berbagi satu tipe "number" yang sama.

2. Angka: presisi ganda adalah jawaban default

2.1 Batas safe integer

Integer terbesar yang dapat direpresentasikan persis oleh IEEE 754 presisi ganda adalah 2^53 - 1 = 9007199254740991. Di luar itu, muncul celah di antara integer yang berdekatan:

JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992

JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'

Ini bukan masalah JSON; ini masalah langkah "teks → presisi ganda". Pipeline apa pun yang memiliki JavaScript di tengahnya (gateway, BFF, frontend, layanan Node) akan terkena.

Solusi (diurutkan berdasarkan seberapa kuat saya merekomendasikannya):

  1. Gunakan string untuk ID di sepanjang pipeline: {"id": "9007199254740993"}, paling sedikit masalahnya;
  2. Parse dengan tipe presisi tinggi di server: BigInteger / BigDecimal milik Java, json.Number milik Go, decimal.Decimal milik Python;
  3. Di frontend, gunakan reviver JSON.parse atau parser yang mendukung angka besar (seperti json-bigint) untuk mengubah field menjadi BigInt, tetapi perhatikan bahwa BigInt tidak dapat diteruskan langsung ke JSON.stringify.

2.2 Desimal bukan nilai eksak

0.1 + 0.2            // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"

Selalu representasikan jumlah uang sebagai integer dalam unit mata uang terkecil (sen) atau sebagai string; jangan pernah mengakumulasikannya dengan floating point.

2.3 Jebakan angka saat serialisasi

JSON.stringify({ a: 1e21 })        // {"a":1e+21}
JSON.stringify({ a: -0 })          // {"a":0}
JSON.stringify({ a: Infinity })    // {"a":null}
JSON.stringify({ a: NaN })         // {"a":null}
JSON.stringify({ a: 10n })         // TypeError: Do not know how to serialize a BigInt

Infinity dan NaN secara diam-diam diubah menjadi null, dan inilah kelas "data tiba-tiba lenyap" yang paling sulit dilacak.

2.4 Perilaku default menurut bahasa

Bahasa Tipe angka default Kasus yang membutuhkan penanganan eksplisit
JavaScript double Integer besar, serialisasi BigInt
Go float64 (encoding/json) Gunakan json.Number atau Decoder.UseNumber() untuk mempertahankan presisi
Java (Jackson) int/long/double/BigDecimal disimpulkan dari literal Paksa DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS
Python int (presisi arbitrer) / float Gunakan parse_float=Decimal untuk mempertahankan presisi desimal
Rust (serde_json) i64/u64/f64 Presisi arbitrer memerlukan pengaktifan arbitrary_precision

3. String: aliran byte UTF-8 yang dilihat melalui UTF-16

3.1 Panjang tidak sama dengan jumlah karakter

Di JavaScript, "🎉".length bernilai 2, karena emoji berada di bidang tambahan dan direpresentasikan oleh pasangan surrogate. Jika server membatasi panjang berdasarkan "jumlah karakter" sementara frontend memvalidasi dengan length, kedua sisi dipastikan tidak sepakat.

'🎉'.length                 // 2
Array.from('🎉').length     // 1
[...'👨‍👩‍👧'].length           // 5 (including zero-width joiners)

Ketika Anda membutuhkan penghitungan yang ketat, gunakan Intl.Segmenter (berdasarkan grapheme cluster) atau hitung code point Unicode di backend, dan nyatakan definisinya dengan jelas dalam dokumentasi API.

3.2 Surrogate tunggal

Surrogate tinggi tunggal seperti "\ud800" valid secara tata bahasa JSON (\uXXXX menerima empat digit heksadesimal apa pun), tetapi bukan teks Unicode yang valid. Sejak ES2019, JSON.stringify meng-escape surrogate tunggal sebagai \ud800, sehingga outputnya dijamin "well-formed". Saat transfer antar bahasa, nilai seperti ini akan ditolak jika pihak lain memvalidasi Unicode secara ketat.

3.3 \u2028 dan \u2029

Pemisah baris dan pemisah paragraf valid dalam JSON, tetapi menyematkannya langsung ke dalam kode sumber JavaScript atau tag <script> menyebabkan error sintaks. Saat menyisipkan JSON ke dalam HTML, selalu escape keduanya atau beralih ke parsing dari string dengan JSON.parse.

3.4 Tiga sumber teks Tionghoa yang rusak

  1. File itu sendiri ber-GBK dan dibaca sebagai UTF-8;
  2. Header respons mendeklarasikan charset=gbk, padahal isinya sebenarnya UTF-8;
  3. Melewati middleware yang "mendekode ke string lalu meng-encode ulang", sehingga teks Tionghoa ditranskode dua kali.

Urutan investigasi: lihat byte-nya dulu (xxd), lalu header respons, dan terakhir middleware.

4. null versus "kunci ini tidak ada"

Dalam JSON keduanya berbeda:

{"nickname": null}
{}
  • Yang pertama berarti "field ada, dengan nilai kosong";
  • Yang kedua berarti "field tidak ada".

Di TypeScript ini menjadi perbedaan antara string | null dan nickname?: string; di Java menjadi perbedaan antara "null eksplisit" dan "nilai default". Tanpa konvensi yang disepakati, muncul perpecahan berikut:

// Frontend A's approach
if (data.nickname === undefined) { /* treat it as having no nickname */ }
// Frontend B's approach
if (!data.nickname) { /* handle null and the empty string together */ }

Rekomendasi: dokumentasi API harus menyatakan "gunakan null secara seragam saat nilai tidak ada, jangan pernah menghilangkan kunci", atau sebaliknya — tetapi pilih hanya satu; saat memperbarui API, gunakan semantik PATCH untuk menghindari ambiguitas "apakah tidak dikirim, atau dikirim sebagai null".

5. Array dan object

5.1 Array bukan "homogen"

[1, "two", {"three": 3}, null]

Valid secara tata bahasa, tetapi definisi tipenya menjadi sangat sulit ditulis. Dalam API, usahakan elemen array tetap homogen; untuk kasus heterogen, beralih ke array object dengan field diskriminan type.

5.2 Kunci object tidak berurutan, tetapi banyak orang bergantung pada urutan

Spesifikasi menyatakan object adalah "kumpulan pasangan nama/nilai yang tidak berurutan". Dalam praktik V8 mempertahankan urutan penyisipan (kecuali kunci integer, yang diurutkan). Membangun logika berdasarkan urutan kunci (misalnya mengambil "kunci pertama") berarti bergantung pada detail implementasi.

5.3 Array jarang dan lubang tidak ada

[1, , 3] di JavaScript diserialisasi menjadi [1,null,3] — lubangnya berubah menjadi null. JSON tidak memiliki konsep "slot".

6. Daftar periksa yang bisa langsung dijalankan

  • [ ] Semua ID, jumlah uang, dan integer kebesaran secara eksplisit berupa string dalam kontrak API;
  • [ ] Dokumentasi API menyatakan semantik null versus field yang dihilangkan;
  • [ ] Batas panjang string menyatakan apakah menghitung code point atau unit UTF-16;
  • [ ] Menyaring Infinity / NaN / undefined sebelum serialisasi, atau mengubahnya menjadi null secara eksplisit;
  • [ ] JSON yang disisipkan ke dalam HTML telah di-escape;
  • [ ] Frontend dan backend masing-masing memiliki kasus uji kontrak yang mencakup "integer besar + emoji + null".

FAQ

Bagaimana cara menangani integer di atas 2^53 - 1?

Pendekatan yang paling minim masalah adalah mengirim field seperti ID sebagai string di sepanjang pipeline, misalnya {"id": "9007199254740993"}. Di server Anda dapat mempertahankan presisi dengan BigInteger / BigDecimal milik Java, json.Number milik Go, atau decimal.Decimal milik Python. Di frontend, parser seperti json-bigint dapat mengubah field menjadi BigInt, tetapi ingat bahwa BigInt tidak dapat diteruskan langsung ke JSON.stringify.

Mengapa Infinity dan NaN menjadi null setelah serialisasi?

Tipe number JSON tidak menerima literal non-standar seperti NaN dan Infinity, sehingga JSON.stringify mengubahnya menjadi null secara diam-diam tanpa error apa pun. Inilah kelas "data tiba-tiba lenyap" yang paling sulit dilacak. Saring nilai-nilai ini secara eksplisit sebelum serialisasi, atau ubah menjadi null sesuai konvensi bisnis Anda dan catat di log.

Haruskah saya mengirim null atau menghilangkan field sepenuhnya?

Keduanya bisa, tetapi dokumentasi API harus memilih satu dan menyatakannya dengan jelas, jika tidak setiap frontend akan mengasumsikan hal yang berbeda. Dalam contoh, {"nickname": null} dan {} memetakan ke string | null dan nickname?: string di TypeScript. Gunakan semantik PATCH saat memperbarui API untuk menghindari ambiguitas "apakah tidak dikirim, atau dikirim sebagai null".