JSON 工具箱

六種型別、數字精度與編碼陷阱

結論:JSON 只有六種型別,但每種都有讓資料悄悄變形的能力。這一篇把數字、字串、null 的坑一次說透。

入門約 12 分鐘發佈於 2026-10-30更新於 2026-10-30
六種型別、數字精度與編碼陷阱封面

結論:先檢查 ID、金額這類數字欄位是否超過 2^53 - 1 或需要精確到小數後多位,再檢查字串長度按碼點還是按 UTF-16 單元計算;超出安全整數範圍的值一律用字串承載,不要指望解析器替你保住精度。

JSON 的型別比多數人以為的更少,坑卻更多:數字會變、字串會斷、null 與缺失鍵是兩回事。

一、型別全景

型別 字面量示例 JSON 中的約束 常見對映
物件 {"a": 1} 鍵必須是字串,值任意 Object / Map / struct
陣列 [1, "a", null] 元素可異構 Array / List / slice
字串 "文字" UTF-8,雙引號 String
數字 1、-1.5、1e9 無前導零、無 +、無 NaN double / Decimal
布林 true / false 小寫 bool
null null 唯一空值 null / Option::None

注意兩個「沒有」:沒有日期型別,沒有整數型別。所有日期都是字串,所有整數和浮點共用「數字」這一個型別。

二、數字:雙精度是預設答案

2.1 安全整數邊界

IEEE 754 雙精度能精確表示的整數上限是 2^53 - 1 = 9007199254740991。超過之後,相鄰整數之間會有間隙:

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

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

這不是 JSON 的問題,是「文字 → 雙精度」這一步的問題。任何以 JavaScript 為中間環節的鏈路(閘道器、BFF、前端、Node 服務)都會踩到。

處置方案(按推薦度排序):

  1. ID 全鏈路用字串:{"id": "9007199254740993"},最省心;
  2. 服務端用高精度型別解析:Java 的 BigInteger / BigDecimal、Go 的 json.Number、Python 的 decimal.Decimal;
  3. 前端用 JSON.parse 的 reviver 或支援大數的解析器(如 json-bigint)把欄位轉成 BigInt,但要注意 BigInt 無法直接 JSON.stringify。

2.2 小數不是精確值

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

金額一律用「最小貨幣單位的整數(分)」或字串表示,不要用浮點做累加。

2.3 序列化時的數字陷阱

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 與 NaN 會被靜默變成 null,這是最難排查的一類「資料憑空消失」。

2.4 各語言的預設行為對照

語言 預設數字型別 需要顯式處理的情況
JavaScript double 大整數、BigInt 序列化
Go float64(encoding/json) 用 json.Number 或 Decoder.UseNumber() 保精度
Java(Jackson) int/long/double/BigDecimal 按字面量推斷 強制 DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS
Python int(任意精度)/ float 用 parse_float=Decimal 保小數精度
Rust(serde_json) i64/u64/f64 任意精度需開 arbitrary_precision

三、字串:UTF-8 位元組流,UTF-16 視角

3.1 長度不等於字元數

JavaScript 的 "🎉".length 是 2,因為 emoji 屬於輔助平面,用一對代理項(surrogate pair)表示。 服務端按「字元數」限制長度、前端按 length 校驗,兩邊必然對不上。

'🎉'.length                 // 2
Array.from('🎉').length     // 1
[...'👨‍👩‍👧'].length           // 5(含零寬連線符)

需要嚴謹計數時用 Intl.Segmenter(按字形簇)或後端按 Unicode 碼點計數,並在介面文件裡寫清口徑。

3.2 孤立代理項

"\ud800" 這種只有高位的孤立代理項在 JSON 語法上合法(\uXXXX 允許任意四位十六進位制),但它不是合法的 Unicode 文字。 ES2019 起 JSON.stringify 會把孤立代理項轉義成 \ud800,保證輸出是「well-formed」的。 跨語言傳輸時如果對方嚴格校驗 Unicode,這類值會被拒。

3.3 \u2028 與 \u2029

行分隔符與段分隔符在 JSON 裡合法,但直接嵌進 JavaScript 原始碼或 <script> 標籤會導致語法錯誤。 把 JSON 內聯到 HTML 時,務必做轉義或改用 JSON.parse 從字串解析。

3.4 中文亂碼的三種來源

  1. 檔案本身是 GBK,被當作 UTF-8 讀;
  2. 響應頭宣告瞭 charset=gbk,但實際是 UTF-8;
  3. 經過一層「先解碼成字串再編碼」的中介軟體,中文被二次轉碼。

排查順序:先看位元組(xxd),再看響應頭,最後看中介軟體。

四、null 與「沒有這個鍵」

這兩者在 JSON 裡是不同的:

{"nickname": null}
{}
  • 前者表示「欄位存在,值為空」;
  • 後者表示「欄位不存在」。

到了 TypeScript 就變成 string | null 與 nickname?: string 的區別;到了 Java 就變成「顯式 null」與「預設值」的區別。 不做約定會出現這些分裂:

// 前端 A 的寫法
if (data.nickname === undefined) { /* 當作沒有暱稱 */ }
// 前端 B 的寫法
if (!data.nickname) { /* null 與空串一起處理 */ }

建議:介面文件規定「沒有值統一用 null,不使用預設」,或者反過來,但只選一種; 更新介面時用 PATCH 語義避免「不知道是沒傳還是傳了 null」的歧義。

五、陣列與物件

5.1 陣列不是「同構的」

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

語法合法,但型別定義會非常難寫。介面裡儘量保證陣列元素同構,異構場景改成帶 type 判別欄位的物件陣列。

5.2 物件鍵無序,但很多人依賴順序

規範說物件是「無序的名稱/值對集合」。實際上 V8 會保留插入順序(整數鍵除外,整數鍵會被排序)。 依賴鍵順序做邏輯(比如「取第一個鍵」)就是依賴實現細節。

5.3 稀疏與空洞不存在

JavaScript 的 [1, , 3] 序列化後是 [1,null,3]——空洞變 null。JSON 裡沒有「空位」概念。

六、一份可執行的檢查清單

  • [ ] 所有 ID、金額、超長整數在介面契約裡明確為字串;
  • [ ] 介面文件寫明 null 與預設欄位的語義;
  • [ ] 字串長度限制寫明按碼點還是按 UTF-16 單元;
  • [ ] 序列化前過濾 Infinity / NaN / undefined,或顯式轉 null;
  • [ ] 內聯到 HTML 的 JSON 做轉義;
  • [ ] 前後端各寫一條「大整數 + emoji + null」的契約測試用例。

常見問題

超過 2^53 - 1 的整數應該怎麼處理?

最省心的做法是 ID 這類欄位全鏈路用字串傳輸,例如 {"id": "9007199254740993"}。服務端可以用 Java 的 BigInteger / BigDecimal、Go 的 json.Number、Python 的 decimal.Decimal 保住精度。前端用 json-bigint 之類解析器轉成 BigInt 也可以,但要記住 BigInt 無法直接 JSON.stringify。

為什麼 Infinity 和 NaN 序列化後變成了 null?

JSON 的數字型別不接受 NaN 與 Infinity 這類非標準字面量,JSON.stringify 會把它們靜默轉換為 null,不報任何錯。這是最難排查的一類「資料憑空消失」。序列化前應顯式過濾這些值,或按業務約定轉成 null 並記錄日誌。

是否應該用 null 還是直接省略欄位?

兩種都行,但介面文件必須只選一種並寫清楚,否則前端會各自假設。原文的示例裡,{"nickname": null} 與 {} 在 TypeScript 中分別對應 string | null 與 nickname?: string。更新介面時用 PATCH 語義,可以避免「不知道是沒傳還是傳了 null」的歧義。