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

結論:先檢查 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 服務)都會踩到。
處置方案(按推薦度排序):
- ID 全鏈路用字串:
{"id": "9007199254740993"},最省心; - 服務端用高精度型別解析:Java 的
BigInteger/BigDecimal、Go 的json.Number、Python 的decimal.Decimal; - 前端用
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 中文亂碼的三種來源
- 檔案本身是 GBK,被當作 UTF-8 讀;
- 響應頭宣告瞭
charset=gbk,但實際是 UTF-8; - 經過一層「先解碼成字串再編碼」的中介軟體,中文被二次轉碼。
排查順序:先看位元組(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」的歧義。