JSON Toolkit

Sáu kiểu, độ chính xác số và cạm bẫy mã hóa

Kết luận: JSON chỉ có sáu kiểu, nhưng mỗi kiểu đều có thể âm thầm định hình lại dữ liệu của bạn. Bài viết này gói gọn cạm bẫy của số, chuỗi và null trong một lượt.

Người mớiĐọc khoảng 12 phútXuất bản 2026-10-30Cập nhật 2026-10-30
Ảnh bìa cho Sáu kiểu, độ chính xác số và cạm bẫy mã hóa

Điểm mấu chốt: trước tiên hãy kiểm tra xem các trường ID hoặc tiền tệ có vượt quá 2^53 - 1 hay cần nhiều chữ số thập phân chính xác không, rồi mới kiểm tra xem giới hạn độ dài chuỗi đếm theo code point hay theo đơn vị UTF-16; hãy mang mọi giá trị vượt quá phạm vi số nguyên an toàn dưới dạng chuỗi thay vì tin rằng trình phân tích sẽ giữ nguyên độ chính xác.

JSON có ít kiểu dữ liệu hơn nhiều người tưởng, và nhiều cạm bẫy hơn: số bị lệch, chuỗi bị vỡ, và null là một thứ khác với khóa không tồn tại.

1. Toàn cảnh các kiểu

Kiểu Ví dụ literal Ràng buộc trong JSON Ánh xạ phổ biến
Object {"a": 1} Khóa phải là chuỗi, giá trị có thể là bất cứ thứ gì Object / Map / struct
Array [1, "a", null] Phần tử có thể thuộc nhiều kiểu khác nhau Array / List / slice
String "text" UTF-8, dấu ngoặc kép String
Number 1, -1.5, 1e9 Không có số 0 đứng đầu, không có +, không có NaN double / Decimal
Boolean true / false Viết thường bool
null null Giá trị null duy nhất null / Option::None

Hãy để ý hai thứ không tồn tại: không có kiểu ngày tháng, và không có kiểu số nguyên. Mọi ngày tháng đều là chuỗi, còn mọi số nguyên và số thực đều dùng chung một kiểu "number" duy nhất.

2. Số: độ chính xác kép là câu trả lời mặc định

2.1 Ranh giới số nguyên an toàn

Số nguyên lớn nhất mà độ chính xác kép IEEE 754 biểu diễn chính xác được là 2^53 - 1 = 9007199254740991. Vượt qua ngưỡng đó, giữa các số nguyên liền kề sẽ xuất hiện khoảng trống:

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

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

Đây không phải vấn đề của JSON, mà là vấn đề của bước "văn bản → độ chính xác kép". Bất kỳ đường ống nào có JavaScript ở giữa (gateway, BFF, frontend, dịch vụ Node) đều sẽ gặp phải.

Cách khắc phục (xếp theo mức độ tôi khuyến nghị):

  1. Dùng chuỗi cho ID trên toàn bộ đường ống: {"id": "9007199254740993"}, ít phiền toái nhất trong mọi cách;
  2. Phân tích bằng các kiểu có độ chính xác cao ở phía máy chủ: BigInteger / BigDecimal của Java, json.Number của Go, decimal.Decimal của Python;
  3. Ở frontend, dùng reviver của JSON.parse hoặc một trình phân tích hỗ trợ số lớn (như json-bigint) để biến trường đó thành BigInt, nhưng lưu ý rằng BigInt không thể truyền trực tiếp cho JSON.stringify.

2.2 Số thập phân không phải giá trị chính xác

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

Luôn biểu diễn số tiền dưới dạng số nguyên theo đơn vị tiền tệ nhỏ nhất (xu) hoặc dưới dạng chuỗi; đừng bao giờ cộng dồn chúng bằng số dấu phẩy động.

2.3 Cạm bẫy số khi tuần tự hóa

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 và NaN bị biến thành null một cách âm thầm, và đây là loại "dữ liệu vừa biến mất" khó truy nhất.

2.4 Hành vi mặc định theo từng ngôn ngữ

Ngôn ngữ Kiểu số mặc định Trường hợp cần xử lý tường minh
JavaScript double Số nguyên lớn, tuần tự hóa BigInt
Go float64 (encoding/json) Dùng json.Number hoặc Decoder.UseNumber() để giữ độ chính xác
Java (Jackson) int/long/double/BigDecimal suy ra từ literal Buộc bật DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS
Python int (độ chính xác tùy ý) / float Dùng parse_float=Decimal để giữ độ chính xác thập phân
Rust (serde_json) i64/u64/f64 Độ chính xác tùy ý cần bật arbitrary_precision

3. Chuỗi: một dòng byte UTF-8 được nhìn qua lăng kính UTF-16

3.1 Độ dài không giống số ký tự

Trong JavaScript, "🎉".length bằng 2, vì emoji nằm ở các mặt phẳng bổ sung và được biểu diễn bằng một cặp surrogate. Nếu máy chủ giới hạn độ dài theo "số ký tự" trong khi frontend kiểm tra bằng length, hai bên chắc chắn sẽ không khớp nhau.

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

Khi cần đếm chặt chẽ, hãy dùng Intl.Segmenter (đếm theo cụm grapheme) hoặc đếm code point Unicode ở backend, và nêu rõ định nghĩa trong tài liệu API.

3.2 Surrogate mồ côi

Một high surrogate mồ côi như "\ud800" là JSON hợp lệ về mặt ngữ pháp (\uXXXX chấp nhận bất kỳ bốn chữ số hex nào), nhưng nó không phải văn bản Unicode hợp lệ. Từ ES2019, JSON.stringify escape các surrogate mồ côi thành \ud800, bảo đảm đầu ra là "well-formed". Khi truyền qua các ngôn ngữ khác nhau, những giá trị như vậy sẽ bị từ chối nếu bên nhận kiểm tra Unicode một cách nghiêm ngặt.

3.3 \u2028 và \u2029

Dấu phân cách dòng và dấu phân cách đoạn là hợp lệ trong JSON, nhưng nhúng trực tiếp chúng vào mã nguồn JavaScript hoặc thẻ <script> sẽ gây lỗi cú pháp. Khi nhúng JSON vào HTML, hãy luôn escape chúng hoặc chuyển sang phân tích từ chuỗi bằng JSON.parse.

3.4 Ba nguồn gốc của văn bản tiếng Trung bị lỗi font

  1. Bản thân tệp là GBK nhưng lại được đọc như UTF-8;
  2. Header phản hồi khai báo charset=gbk, nhưng nội dung thực tế là UTF-8;
  3. Nó đi qua một middleware "giải mã thành chuỗi rồi mã hóa lại", khiến văn bản tiếng Trung bị chuyển mã lần thứ hai.

Thứ tự điều tra: xem byte trước (xxd), sau đó xem header phản hồi, và cuối cùng là middleware.

4. null so với "khóa này không tồn tại"

Trong JSON, hai thứ này khác nhau:

{"nickname": null}
{}
  • Cái trước nghĩa là "trường tồn tại, với giá trị rỗng";
  • Cái sau nghĩa là "trường không tồn tại".

Trong TypeScript, điều này trở thành khác biệt giữa string | null và nickname?: string; trong Java, nó trở thành khác biệt giữa "null tường minh" và "giá trị mặc định". Nếu không có quy ước thống nhất, sẽ xuất hiện những cách chia này:

// 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 */ }

Khuyến nghị: tài liệu API nên nêu rõ "dùng null thống nhất khi giá trị vắng mặt, không bao giờ bỏ qua khóa", hoặc ngược lại — nhưng chỉ chọn một; khi cập nhật API, hãy dùng ngữ nghĩa PATCH để tránh sự mơ hồ "không được gửi, hay được gửi là null".

5. Array và object

5.1 Array không phải là "đồng nhất"

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

Hợp lệ về ngữ pháp, nhưng định nghĩa kiểu sẽ rất khó viết. Trong API, hãy cố giữ các phần tử của array đồng nhất; với trường hợp không đồng nhất, hãy chuyển sang array của các object kèm trường phân biệt type.

5.2 Khóa của object không có thứ tự, nhưng nhiều người vẫn phụ thuộc vào thứ tự

Đặc tả nói object là "một tập hợp không có thứ tự gồm các cặp name/value". Trên thực tế V8 giữ nguyên thứ tự chèn (trừ khóa dạng số nguyên, vốn bị sắp xếp). Xây dựng logic dựa trên thứ tự khóa (ví dụ lấy "khóa đầu tiên") nghĩa là phụ thuộc vào một chi tiết triển khai.

5.3 Array thưa và lỗ hổng không tồn tại

[1, , 3] của JavaScript được tuần tự hóa thành [1,null,3] — lỗ hổng biến thành null. JSON không có khái niệm "ô trống".

6. Danh sách kiểm tra có thể hành động ngay

  • [ ] Mọi ID, số tiền và số nguyên quá lớn đều được khai báo tường minh là chuỗi trong hợp đồng API;
  • [ ] Tài liệu API nêu rõ ngữ nghĩa của null so với trường bị bỏ qua;
  • [ ] Giới hạn độ dài chuỗi nêu rõ đếm theo code point hay theo đơn vị UTF-16;
  • [ ] Lọc bỏ Infinity / NaN / undefined trước khi tuần tự hóa, hoặc chuyển chúng thành null một cách tường minh;
  • [ ] JSON được nhúng vào HTML đều được escape;
  • [ ] Frontend và backend đều có một ca kiểm thử hợp đồng bao phủ "số nguyên lớn + emoji + null".

Câu hỏi thường gặp

Xử lý số nguyên lớn hơn 2^53 - 1 như thế nào?

Cách ít phiền toái nhất là gửi các trường như ID dưới dạng chuỗi trên toàn bộ đường ống, ví dụ {"id": "9007199254740993"}. Ở phía máy chủ, bạn có thể giữ độ chính xác bằng BigInteger / BigDecimal của Java, json.Number của Go, hoặc decimal.Decimal của Python. Ở frontend, một trình phân tích như json-bigint có thể biến trường đó thành BigInt, nhưng hãy nhớ rằng BigInt không thể truyền trực tiếp cho JSON.stringify.

Vì sao Infinity và NaN lại thành null sau khi tuần tự hóa?

Kiểu số của JSON không chấp nhận các literal không chuẩn như NaN và Infinity, nên JSON.stringify âm thầm chuyển chúng thành null mà không báo lỗi. Đây là loại "dữ liệu vừa biến mất" khó truy nhất. Hãy lọc các giá trị này một cách tường minh trước khi tuần tự hóa, hoặc chuyển chúng thành null theo quy ước nghiệp vụ của bạn và ghi log lại.

Nên gửi null hay bỏ qua hẳn trường đó?

Cách nào cũng được, nhưng tài liệu API phải chọn một và nêu rõ, nếu không mỗi frontend sẽ tự giả định một kiểu khác nhau. Trong các ví dụ, {"nickname": null} và {} ánh xạ tới string | null và nickname?: string trong TypeScript. Hãy dùng ngữ nghĩa PATCH khi cập nhật API để tránh sự mơ hồ "không được gửi, hay được gửi là null".