JSON Toolkit

หกประเภท ความแม่นยำของตัวเลข และกับดักการเข้ารหัส

สรุปสำคัญ: JSON มีเพียงหกประเภท แต่แต่ละประเภทสามารถเปลี่ยนรูปข้อมูลของคุณได้อย่างเงียบเชียบ บทความนี้รวบรวมกับดักของตัวเลข สตริง และ null ไว้ในรอบเดียว

ผู้เริ่มต้นอ่านประมาณ 12 นาทีเผยแพร่ 2026-10-30อัปเดต 2026-10-30
ภาพหน้าปกบทความหกประเภท ความแม่นยำของตัวเลข และกับดักการเข้ารหัส

สรุปสั้น: ตรวจก่อนว่าฟิลด์ ID หรือฟิลด์เงินมีค่ามากกว่า 2^53 - 1 หรือต้องใช้ทศนิยมหลายตำแหน่งหรือไม่ จากนั้นตรวจว่าข้อจำกัดความยาวสตริงนับเป็น code point หรือนับเป็นหน่วย UTF-16; ค่าใดก็ตามที่เกินช่วงจำนวนเต็มปลอดภัยให้ส่งเป็นสตริง อย่าไว้ใจให้ตัวแยกวิเคราะห์รักษาความแม่นยำไว้ให้

JSON มีประเภทน้อยกว่าที่คนส่วนใหญ่คิด แต่มีกับดักมากกว่า: ตัวเลขเพี้ยน สตริงพัง และ null ก็ไม่เหมือนกับคีย์ที่หายไป

1. ภาพรวมของประเภทข้อมูล

ประเภท ตัวอย่างค่าคงที่ ข้อจำกัดใน JSON การแมปที่พบบ่อย
Object {"a": 1} คีย์ต้องเป็นสตริง ค่าเป็นอะไรก็ได้ Object / Map / struct
Array [1, "a", null] องค์ประกอบต่างชนิดกันได้ Array / List / slice
String "text" UTF-8 เครื่องหมายคำพูดคู่ String
Number 1, -1.5, 1e9 ห้ามมีศูนย์นำหน้า ห้ามมี + ห้ามมี NaN double / Decimal
Boolean true / false ตัวพิมพ์เล็ก bool
null null ค่า null เพียงค่าเดียว null / Option::None

สังเกตสองสิ่งที่ไม่มีอยู่: ไม่มีประเภทวันที่ และไม่มีประเภทจำนวนเต็ม วันที่ทั้งหมดเป็นสตริง ส่วนจำนวนเต็มและค่าทศนิยมทั้งหมดใช้ประเภท "number" เดียวร่วมกัน

2. ตัวเลข: ความแม่นยำสองเท่าคือคำตอบโดยค่าเริ่มต้น

2.1 ขอบเขตจำนวนเต็มปลอดภัย

จำนวนเต็มที่ใหญ่ที่สุดที่ IEEE 754 ความแม่นยำสองเท่าแทนได้อย่างแม่นยำคือ 2^53 - 1 = 9007199254740991 เกินกว่านั้นจะเกิดช่องว่างระหว่างจำนวนเต็มที่อยู่ติดกัน:

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

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

นี่ไม่ใช่ปัญหาของ JSON แต่เป็นปัญหาของขั้นตอน "ข้อความ → ความแม่นยำสองเท่า" ไปป์ไลน์ใดที่มี JavaScript อยู่ตรงกลาง (gateway, BFF, ฟรอนต์เอนด์, บริการ Node) จะเจอปัญหานี้

วิธีแก้ (เรียงตามลำดับที่แนะนำ):

  1. ใช้สตริงสำหรับ ID ตลอดทั้งไปป์ไลน์: {"id": "9007199254740993"} เป็นวิธีที่ยุ่งที่สุดน้อยที่สุด;
  2. แยกวิเคราะห์ด้วยประเภทความแม่นยำสูงฝั่งเซิร์ฟเวอร์: BigInteger / BigDecimal ของ Java, json.Number ของ Go, decimal.Decimal ของ Python;
  3. ฝั่งฟรอนต์เอนด์ใช้ reviver ของ JSON.parse หรือตัวแยกวิเคราะห์ที่รองรับจำนวนมาก (เช่น 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

3. สตริง: กระแสไบต์ UTF-8 ที่ถูกมองผ่าน UTF-16

3.1 ความยาวไม่เท่ากับจำนวนอักขระ

ใน JavaScript "🎉".length มีค่าเป็น 2 เพราะ emoji อยู่ในระนาบเสริมและถูกแทนด้วย surrogate pair ถ้าเซิร์ฟเวอร์จำกัดความยาวด้วย "จำนวนอักขระ" ขณะที่ฟรอนต์เอนด์ตรวจด้วย length สองฝั่งจะขัดกันแน่นอน

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

เมื่อต้องนับอย่างเข้มงวด ให้ใช้ Intl.Segmenter (นับตาม grapheme cluster) หรือนับ Unicode code point ฝั่งแบ็กเอนด์ และระบุคำนิยามให้ชัดเจนในเอกสาร API

3.2 Surrogate ที่อยู่โดดเดี่ยว

Surrogate สูงที่อยู่โดดเดี่ยวอย่าง "\ud800" เป็น JSON ที่ถูกต้องตามไวยากรณ์ (\uXXXX รับเลขฐานสิบหกสี่หลักใดก็ได้) แต่ไม่ใช่ข้อความ Unicode ที่ถูกต้อง ตั้งแต่ ES2019 JSON.stringify จะ escape surrogate ที่อยู่โดดเดี่ยวเป็น \ud800 เพื่อรับประกันว่าเอาต์พุต "well-formed" เมื่อส่งข้ามภาษา ค่าแบบนี้จะถูกปฏิเสธถ้าฝั่งปลายทางตรวจสอบ Unicode อย่างเข้มงวด

3.3 \u2028 และ \u2029

ตัวคั่นบรรทัดและตัวคั่นย่อหน้าใช้ได้ใน JSON แต่ถ้าแทรกลงในซอร์สโค้ด JavaScript หรือแท็ก <script> โดยตรงจะทำให้เกิด syntax error เมื่อฝัง JSON ลงใน HTML ต้อง escape ค่าเหล่านี้เสมอ หรือเปลี่ยนไปแยกวิเคราะห์จากสตริงด้วย JSON.parse

3.4 ต้นเหตุสามประการของข้อความจีนที่กลายเป็นตัวขยะ

  1. ไฟล์เองเป็น GBK แต่ถูกอ่านเป็น UTF-8;
  2. ส่วนหัวของ response ประกาศ charset=gbk แต่เนื้อหาจริงเป็น UTF-8;
  3. ผ่านมิดเดิลแวร์ที่ "ถอดรหัสเป็นสตริงแล้วเข้ารหัสใหม่" ข้อความจีนจึงถูกแปลงรหัสซ้ำเป็นครั้งที่สอง

ลำดับการตรวจสอบ: ดูไบต์ก่อน (xxd) ตามด้วยส่วนหัวของ response และสุดท้ายคือมิดเดิลแวร์

4. null กับ "คีย์นี้ไม่มีอยู่"

ใน JSON สองสิ่งนี้ ต่างกัน:

{"nickname": null}
{}
  • แบบแรกหมายถึง "ฟิลด์มีอยู่ แต่มีค่าว่าง";
  • แบบหลังหมายถึง "ฟิลด์ไม่มีอยู่".

ใน TypeScript ความต่างนี้กลายเป็นความต่างระหว่าง string | null กับ nickname?: string; ใน Java กลายเป็นความต่างระหว่าง "null ที่ระบุชัด" กับ "ค่าเริ่มต้น" ถ้าไม่มีข้อตกลงร่วมกัน ความแตกแยกแบบนี้จะเกิดขึ้น:

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

ข้อแนะนำ: เอกสาร API ควรระบุว่า "เมื่อค่าไม่มี ให้ใช้ null อย่างสม่ำเสมอ ห้ามละคีย์" หรือกลับกัน — แต่เลือกเพียงอย่างเดียว; เมื่ออัปเดต API ให้ใช้ความหมายแบบ PATCH เพื่อเลี่ยงความกำกวมว่า "ไม่ได้ส่งมา หรือส่งมาเป็น null"

5. Array และ object

5.1 Array ไม่ได้ "เป็นเนื้อเดียวกัน"

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

ถูกต้องตามไวยากรณ์ แต่คำนิยามประเภทเขียนได้ยากมาก ใน API ควรพยายามให้องค์ประกอบของ array เป็นชนิดเดียวกัน; กรณีต่างชนิดให้เปลี่ยนเป็น array ของ object ที่มีฟิลด์จำแนก type

5.2 คีย์ของ object ไม่มีลำดับ แต่หลายคนพึ่งพาลำดับ

มาตรฐานบอกว่า object คือ "ชุดของคู่ชื่อ/ค่าที่ไม่มีลำดับ" ในทางปฏิบัติ V8 รักษาลำดับการแทรกไว้ (ยกเว้นคีย์ที่เป็นจำนวนเต็มซึ่งจะถูกจัดเรียง) การสร้างตรรกะบนลำดับคีย์ (เช่นเอา "คีย์แรก") เท่ากับพึ่งพารายละเอียดการทำงานภายในของการนำไปใช้งาน

5.3 Sparse array และช่องว่างไม่มีอยู่

[1, , 3] ของ JavaScript ถูกทำให้เป็นอนุกรมเป็น [1,null,3] — ช่องว่างกลายเป็น null JSON ไม่มีแนวคิดเรื่อง "ช่อง"

6. รายการตรวจสอบที่ลงมือทำได้

  • [ ] ID ทั้งหมด จำนวนเงิน และจำนวนเต็มที่ใหญ่เกินไป ระบุเป็นสตริงอย่างชัดเจนในสัญญา API;
  • [ ] เอกสาร API ระบุความหมายของ null เทียบกับฟิลด์ที่ละไว้;
  • [ ] ข้อจำกัดความยาวสตริงระบุว่านับ code point หรือนับหน่วย UTF-16;
  • [ ] กรอง Infinity / NaN / undefined ออกก่อนทำให้เป็นอนุกรม หรือแปลงเป็น null อย่างชัดเจน;
  • [ ] JSON ที่ฝังลงใน HTML ถูก escape;
  • [ ] ฟรอนต์เอนด์และแบ็กเอนด์มีกรณีทดสอบสัญญาครบทั้ง "จำนวนเต็มขนาดใหญ่ + emoji + null".

คำถามที่พบบ่อย

ควรจัดการจำนวนเต็มที่มากกว่า 2^53 - 1 อย่างไร

วิธีที่ยุ่งที่สุดน้อยที่สุดคือส่งฟิลด์อย่าง ID เป็นสตริงตลอดทั้งไปป์ไลน์ เช่น {"id": "9007199254740993"} ฝั่งเซิร์ฟเวอร์รักษาความแม่นยำได้ด้วย BigInteger / BigDecimal ของ Java, json.Number ของ Go หรือ decimal.Decimal ของ Python ฝั่งฟรอนต์เอนด์ตัวแยกวิเคราะห์อย่าง json-bigint แปลงฟิลด์เป็น BigInt ได้ แต่ต้องจำว่า BigInt ส่งเข้า JSON.stringify โดยตรงไม่ได้

ทำไม Infinity และ NaN กลายเป็น null หลังทำให้เป็นอนุกรม

ประเภท number ของ JSON ไม่รับค่าคงที่ที่ไม่อยู่ในมาตรฐานอย่าง NaN และ Infinity ดังนั้น JSON.stringify จึงแปลงเป็น null อย่างเงียบ ๆ โดยไม่แจ้ง error นี่คือกรณี "ข้อมูลหายไปเฉย ๆ" ที่ตามรอยได้ยากที่สุด ให้กรองค่าเหล่านี้อย่างชัดเจนก่อนทำให้เป็นอนุกรม หรือแปลงเป็น null ตามข้อตกลงทางธุรกิจพร้อมบันทึก log

ควรส่ง null หรือละฟิลด์ไปเลย

ทำได้ทั้งสองแบบ แต่เอกสาร API ต้องเลือกอย่างหนึ่งและระบุให้ชัดเจน ไม่อย่างนั้นฟรอนต์เอนด์แต่ละตัวจะสมมติต่างกัน ในตัวอย่าง {"nickname": null} และ {} แมปเป็น string | null และ nickname?: string ใน TypeScript ใช้ความหมายแบบ PATCH เมื่ออัปเดต API เพื่อเลี่ยงความกำกวมว่า "ไม่ได้ส่งมา หรือส่งมาเป็น null"