JSON.parse(JSON.stringify(x)) ไม่ได้ก๊อปปี้ข้อมูล เพราะ id 9007199254740993 กลับมาเป็น 9007199254740992 โดยไม่มี error
JSON.parse(JSON.stringify(x)) ที่ใช้ก๊อปปี้ข้อมูลกันจนติดมือ ทำให้ id 9007199254740993 กลับมาเป็น 9007199254740992 โดยไม่มี error สักตัว ไล่ดูของที่หายระหว่างทางทั้งชุด ตั้งแต่ undefined · Date · NaN จนถึงลำดับ key พร้อมเช็กลิสต์ที่เอาไปไล่กับโปรเจกต์ตัวเองได้

สามบรรทัดนี้วางลงใน console ของเบราว์เซอร์ได้เลย แล้ว JSON.stringify จะคืนตัวเลขที่ไม่ตรงกับที่ใส่เข้าไป
const payload = { id: 9007199254740993 }
console.log(JSON.stringify(payload))
// {"id":9007199254740992}
เลขที่พิมพ์เข้าไปจบด้วย 3 ส่วนข้อความที่ออกมาจบด้วย 2 ไม่มีอะไรแจ้งเตือน ไม่มี error โผล่ขึ้นมา และของที่ได้ก็ยังเป็น JSON ที่อ่านได้ถูกต้องทุกบรรทัด ถ้าเลขก้อนนี้เป็น id ที่ซ่อนอยู่กลางข้อมูลกองใหญ่ ก็แทบไม่มีอะไรสะกิดให้รู้ว่ามันเปลี่ยนไปแล้ว
หน้าตาของบรรทัด JSON.parse(JSON.stringify(x)) เหมือนการก๊อปปี้ข้อมูลก้อนหนึ่งออกมาอีกก้อน แต่สิ่งที่เกิดขึ้นจริงคือการแปลงข้อมูลในหน่วยความจำให้กลายเป็นข้อความเพื่อส่งออกไป แล้วแปลงกลับเข้ามาเป็นข้อมูลอีกรอบ ระหว่างทางมีของหายไปหลายอย่าง และไม่มีอะไรบอกว่ามันหายตอนไหน
บทความของ Gábor Koós ไล่ทดสอบของที่หายเหล่านี้ทีละค่า ด้วยโค้ดสั้นๆ ที่รันตามได้ทุกบรรทัด ถ้าแอปที่เราทำอยู่รับ id ยาวๆ หรือค่าเวลาจากบริการข้างนอกเข้ามาใช้ทุกวัน นี่คือรายการของที่มีสิทธิ์หายไปแล้วตั้งแต่วันไหนก็ไม่รู้
เลขเพี้ยนตั้งแต่ก่อน JSON จะได้เห็นด้วยซ้ำ

ค่าที่เปลี่ยนไปไม่ได้เกิดตอน JSON.stringify ทำงาน แต่เกิดก่อนหน้านั้น ตั้งแต่ตอนที่ JavaScript อ่านตัวเลขก้อนนี้เข้ามาเก็บ
const id = 9007199254740993
console.log(id)
// 9007199254740992
พิมพ์เลขเข้าไปเฉยๆ ยังไม่ได้แตะ JSON เลยสักนิด ค่าก็เปลี่ยนแล้ว เหตุผลอยู่ที่วิธีเก็บตัวเลขของภาษานี้ JavaScript วางตัวเลขทั่วไปทุกตัวไว้ในรูปแบบมาตรฐานที่ชื่อ IEEE 754 binary64 รูปแบบนี้เก็บจำนวนเต็มได้ตรงเป๊ะเฉพาะในช่วงปลอดภัย คือตั้งแต่ -(2^53 - 1) ถึง 2^53 - 1 ค่าปลายบนของช่วงนี้มีชื่อเรียกใน JavaScript ว่า Number.MAX_SAFE_INTEGER พ้นจากช่วงนี้ออกไป จำนวนเต็มสองตัวที่อยู่ติดกันมีสิทธิ์ตกลงไปอยู่ที่ค่าเดียวกัน
ที่หนักกว่านั้นคือฝั่งรับ ต้นทางจะเขียนตัวเลขมาครบทุกหลักแค่ไหนก็ไม่ช่วย พอ JSON.parse แปลงข้อความนั้นกลับมาเป็นประเภทตัวเลข (Number) ค่าก็เพี้ยนอยู่ดี
const text = '{"id":9007199254740993}'
const parsed = JSON.parse(text)
console.log(parsed.id)
// 9007199254740992
โปรแกรม JavaScript ที่รับข้อความนั้นไปจึงเก็บอีกค่าหนึ่งไว้แบบเงียบสนิท และถ้าระบบส่งค่านั้นต่อออกไปอีกรอบ ของที่เพี้ยนแล้วก็กลายเป็นข้อมูลจริงของระบบถัดไป ส่วนการตรวจข้อมูลหลังจากแปลงกลับ ก็เรียกค่าเดิมคืนมาไม่ได้ เพราะความต่างระหว่างเลขสองตัวนั้นหายไปก่อนที่ตัวตรวจจะได้เห็นมันเสียอีก
เรื่องนี้ไม่ได้อยู่แค่ในตัวอย่าง ค่าที่โดนคือของที่ธุรกิจใช้กันทุกวัน ทั้งรหัสอ้างอิงในฐานข้อมูล เลขที่บัญชี ยอดรวมท้ายใบแจ้งหนี้ ไปจนถึงจำนวนเงินที่ระบบเก็บเป็นหน่วยเล็กสุดอย่างสตางค์
ส่ง id ใหญ่เป็นสตริง เพราะ BigInt ข้ามไปเป็น JSON ไม่ได้
ทางแก้ที่บทความต้นทางเสนอนั้นตรงไปตรงมา คือเลิกส่งเลขใหญ่ออกไปในรูปของตัวเลข
แล้ว BigInt ล่ะ JavaScript มี BigInt ไว้เก็บจำนวนเต็มที่ใหญ่เกินช่วงปลอดภัยอยู่แล้ว แต่ JSON ไม่มีชนิดข้อมูลที่ตรงกันให้ใช้ พอสั่งแปลง มันจึงโยน error ทิ้งทันที
const id = 9007199254740993n
console.log(id)
// 9007199254740993n
JSON.stringify({ id })
// TypeError: Do not know how to serialize a BigInt
BigInt เก็บค่าไว้ครบ ลงท้ายด้วย 3 ตามเดิม แต่มันข้ามไปเป็น JSON ตรงๆ ไม่ได้ ทางที่เหลือคือส่งมันไปในรูปสตริงของตัวเลข
const json = JSON.stringify({ id: '9007199254740993' })
const parsed = JSON.parse(json)
console.log(parsed.id)
// "9007199254740993"
เงื่อนไขสำคัญของวิธีนี้อยู่ที่เอกสารกำกับข้อมูลที่สองฝั่งตกลงกัน หรือที่เรียกว่า schema ตรงนั้นต้องเขียนไว้ให้ชัดว่าช่อง id คือสตริงของตัวเลข ไม่ใช่ number ฝั่งที่รับไปจะเก็บมันไว้เป็นรหัสเฉยๆ ก็ได้ หรือถ้าต้องเอาไปคำนวณจริง ค่อยแปลงเป็น BigInt ตอนนั้น
undefined ใน object หายทั้งช่อง แต่พอไปอยู่ใน array กลับกลายเป็น null
เลขไม่ใช่ของชิ้นเดียวที่หาย ค่าที่ชื่อ undefined ก็หายเหมือนกัน แต่หายคนละแบบ ขึ้นอยู่กับว่ามันนั่งอยู่ตรงไหน
const value = {
name: 'Alice',
nickname: undefined
}
const json = JSON.stringify(value)
console.log(json)
// {"name":"Alice"}
ช่อง nickname ไม่ได้กลายเป็น null แต่หายไปทั้งช่อง เพราะ JSON ไม่มีค่าที่ชื่อ undefined ให้ใช้ ตัวแปลงจึงข้ามช่องนั้นไปเลย
พอย้ายค่าเดียวกันไปอยู่ใน array กติกากลับเปลี่ยน
const values = ['first', undefined, 'third']
console.log(JSON.stringify(values))
// ["first",null,"third"]
เหตุผลคือถ้าดึงสมาชิกออกไปกลางแถว ตำแหน่งของทุกตัวที่อยู่ถัดไปจะเลื่อนตามหมด ค่าที่ JSON แทนไม่ได้จึงกลายเป็น null เพื่อรักษาตำแหน่งเอาไว้
API ที่ให้แก้ข้อมูลทีละช่อง คือจุดที่เจ็บที่สุด
ระบบที่ยอมให้แก้ข้อมูลทีละช่องมักตกลงกติกากันไว้สองข้อ คือส่ง null มา แปลว่าให้ล้างค่าในช่องนั้นทิ้ง ส่วนช่องไหนไม่ส่งมาด้วย แปลว่าห้ามแตะ กติกานี้อ่านแล้วชัดเจนดี จนกระทั่ง undefined หลุดเข้ามา
const leaveUnchanged = {}
const clearNickname = { nickname: null }
const ambiguousUpdate = { nickname: undefined }
console.log(JSON.stringify(leaveUnchanged))
// {}
console.log(JSON.stringify(clearNickname))
// {"nickname":null}
console.log(JSON.stringify(ambiguousUpdate))
// {}
ก้อนแรกกับก้อนที่สามพิมพ์ออกมาเหมือนกันทุกตัวอักษร ทั้งที่ตอนอยู่ในหน่วยความจำ สองก้อนนี้คนละความหมายกัน ตอนนั้น JavaScript ยังแยกออกด้วยการถามว่ามีช่องนี้อยู่หรือเปล่า เช่น 'nickname' in value ที่ตอบ true แม้ค่าข้างในจะเป็น undefined พอผ่านการแปลงเป็นข้อความไปแล้ว คำถามเดิมตอบ false เท่ากันทั้งคู่
ปลายทางจึงแยกไม่ออกอีกต่อไปว่าคำสั่งที่ได้รับมาแปลว่าอย่าแตะช่องนี้ หรือแปลว่าเคยมีช่องนี้อยู่ แต่ค่าข้างในเป็น undefined เลยหล่นหายตอนแปลง สิ่งที่หายไปคือเจตนาของฝั่งส่ง ส่วนฝั่งรับก็ตัดสินใจไปแล้วทางหนึ่ง โดยไม่มีอะไรบอกว่าเลือกตรงกับที่ต้นทางตั้งใจหรือเปล่า
Date กลายเป็นข้อความ NaN กลายเป็น null และ Set เหลือแค่วงเล็บว่าง

ชนิดข้อมูลที่ JavaScript มีแต่ JSON ไม่มี ต้องแปลงร่างเป็นอย่างอื่นก่อนออกเดินทางเสมอ และของที่แปลงร่างแล้วก็ไม่เหลือป้ายบอกว่าเดิมเป็นอะไร เริ่มจาก Date
const createdAt = new Date('2026-07-21T12:00:00Z')
const json = JSON.stringify({ createdAt })
console.log(json)
// {"createdAt":"2026-07-21T12:00:00.000Z"}
ตัวแปลงไม่ได้อ่านค่าข้างในของ Date เอง แต่เรียกเมธอด toJSON() ของมัน แล้ว toJSON() ของ Date ก็ไปเรียก toISOString() ต่ออีกที สิ่งที่ออกมาจึงเป็นสตริงเวลาตามมาตรฐาน UTC ล้วนๆ พอ parse กลับเข้ามา คำสั่งเช็กประเภท typeof ของช่องนั้นตอบว่า "string" และเช็กว่าเป็น Date ด้วย instanceof Date ได้ false ทั้งที่ทุกอย่างดูปกติดี
เวลาที่หายไปไม่ได้มีแค่ชนิดข้อมูล แต่รวมถึงโซนเวลาที่ผู้ใช้กรอกเข้ามาด้วย
const scheduledAt = new Date('2026-07-21T09:00:00-04:00')
console.log(JSON.stringify({ scheduledAt }))
// {"scheduledAt":"2026-07-21T13:00:00.000Z"}
คนกรอกเวลา 09:00 ในโซนที่ห่างจาก UTC สี่ชั่วโมง สิ่งที่ออกไปคือ 13:00 แบบ UTC จังหวะเวลาจริงยังตรงเป๊ะ ไม่ได้ผิดไปไหนสักวินาที แต่เลขบนหน้าปัดที่คนกรอกเห็น กับข้อมูลว่ามันเป็นเวลาของโซนไหน หายไปพร้อมกันทั้งคู่ ระบบจองคิวที่เอาค่านี้ไปแสดงต่อ จึงมีสิทธิ์โชว์เวลาที่ลูกค้าไม่เคยพิมพ์
ค่าตัวเลขพิเศษก็มีกติกาของตัวเอง
const measurements = {
current: NaN,
upper: Infinity,
lower: -Infinity
}
console.log(JSON.stringify(measurements))
// {"current":null,"upper":null,"lower":null}
สามค่านี้คนละความหมายกันสิ้นเชิง แต่ออกมาเป็น null เหมือนกันหมด และเพราะ null เป็น JSON ที่ถูกต้อง ทุกอย่างจึง parse ผ่านฉลุย ปลายทางที่รับไปจึงแยกไม่ออกแล้วว่าช่องนี้คือค่าที่ยังวัดไม่ได้ หรือค่าที่คำนวณแล้วพังจนได้ NaN หรือค่าที่ไม่มีขอบเขต
ของอีกกลุ่มหายหนักกว่านั้น คือกล่องเก็บข้อมูลที่ภาษามีมาให้
const original = {
tags: new Set(['json', 'js']),
lookup: new Map([['answer', 42]]),
pattern: /json/i,
error: new Error('failed')
}
const copy = JSON.parse(JSON.stringify(original))
console.log(copy)
// { tags: {}, lookup: {}, pattern: {}, error: {} }
ของสี่ชิ้นที่ไม่เหมือนกันเลยสักอย่าง กลายเป็นวงเล็บปีกกาว่างสี่อันเท่ากันหมด ทั้งชนิดข้อมูลเดิมและของข้างในหายไปพร้อมกัน ไม่เหลือร่องรอยให้ฝั่งรับเดาย้อนกลับ
ของที่ปั้นออกมาจากแม่แบบ class ก็เจอแบบเดียวกัน แต่เบากว่าหน่อย ช่อง field ที่เก็บข้อมูลไว้ยังรอดมาได้ ส่วนฟังก์ชันเมธอดหายหมด ช่อง private field ที่ขึ้นต้นด้วย # ก็ไม่ไปด้วย สิ่งที่ได้กลับมาคือ object ธรรมดาที่ไม่มีสายสัมพันธ์กับ class เดิมอีกแล้ว เช็ก instanceof ก็ตอบ false
ลำดับ key ใน JSON ที่ดูน่าเชื่อถือ แต่ข้ามภาษาแล้วการันตีไม่ได้
ถ้ามองอยู่ในฝั่ง JavaScript อย่างเดียว ลำดับ key ที่เขียนออกมาคงที่จนดูเชื่อถือได้ มาตรฐานของภาษากำหนดไว้ว่า key ที่เป็นเลขดัชนีมาก่อนโดยเรียงจากน้อยไปมาก แล้วตามด้วย key ที่เป็นข้อความเรียงตามลำดับที่ใส่เข้าไป รันกี่รอบก็ได้ผลเดิม
แต่มาตรฐานของ JSON เองมองต่างออกไป เอกสารมาตรฐานที่ชื่อ RFC 8259 ระบุไว้ว่า object คือกลุ่มของสมาชิกที่ไม่มีลำดับ ตัวถอดรหัสในแต่ละภาษาจึงไม่จำเป็นต้องส่งลำดับเดิมต่อให้โค้ดที่เรียกใช้ ตัวอย่างที่บทความยกมาคือบริการที่เขียนด้วยภาษา Go ฝั่งนั้นถอดข้อมูลก้อนเดียวกันลงในโครงสร้างแบบ map แล้ววนอ่านทีละช่อง ลำดับที่ได้ออกมาไม่มีอะไรรับประกัน
ปัญหาจะโผล่ตอนที่แอปให้ความหมายกับตำแหน่ง เช่น ถือว่าช่องแรกคืองานที่ต้องทำก่อนใคร หรือเขียนเทสที่เอาข้อความ JSON สองก้อนมาเทียบกันตัวอักษรต่อตัวอักษร ทั้งที่สิ่งที่อยากรู้จริงๆ คือข้างในมีสมาชิกเหมือนกันหรือเปล่า ถ้าตำแหน่งมีความหมายจริงๆ ให้ย้ายมันไปอยู่ใน array แทน
{
"steps": [
{ "name": "validate" },
{ "name": "charge" },
{ "name": "confirm" },
{ "name": "notify" }
]
}array เป็นของที่มีลำดับตามมาตรฐาน JSON ตัวถอดรหัสที่ทำตามมาตรฐานจึงรักษาลำดับนี้ไว้ให้เหมือนกันหมด และคนที่มาอ่านโค้ดทีหลังก็เห็นทันทีว่าลำดับคือส่วนหนึ่งของข้อตกลง
เช็กลิสต์ JSON ที่เอาไปไล่กับโปรเจกต์ตัวเองได้เลย
ปัญหาทุกข้อข้างบนมีต้นตอเดียวกัน คือสองฝั่งไม่เคยตกลงกันเป็นลายลักษณ์อักษรว่าข้อมูลที่ส่งข้ามกันหน้าตาเป็นยังไง ฝั่งส่งก็โยน object ที่มีอยู่ในมือออกไป ฝั่งรับก็เดาเอาว่าของที่ได้ยังมีความหมายเหมือนเดิม งานประกาศให้ชัดตรงนี้เป็นงานของคน ไม่ใช่ของ JSON และในโปรเจกต์ที่ให้ AI ช่วยเขียนโค้ด งานตรวจก็ตกมาอยู่กับเราเต็มๆ เพราะ vibe coding คือการสลับบทบาทจากคนเขียนโค้ดมาเป็นคนกำกับและตรวจงานของ AI
- ปั้น object ชุดใหม่ไว้ส่งออกโดยตรง ไม่ใช่หยิบ object ที่ใช้อยู่ข้างในโยนออกไปทั้งก้อน
- ส่ง id ที่ยาวเกินช่วงปลอดภัยไปเป็นสตริง แล้วเขียนกำกับไว้ใน schema ว่าช่องนี้เก็บสตริง ไม่ใช่ตัวเลข
- ค่าไหนที่ JSON แทนตรงๆ ไม่ได้ ให้แนบชื่อชนิดข้อมูลไปด้วย ในรูปแบบที่สองฝั่งตกลงกันไว้ล่วงหน้า
- ลำดับที่มีความหมาย เก็บใน array ไม่ใช่ฝากไว้กับลำดับ key ของ object
- เรียก
JSON.parseเสร็จเมื่อไร ให้ schema ตรวจข้อมูลต่อทุกครั้ง - ปฏิเสธข้อมูลก้อนที่ใหญ่เกินกำหนดตั้งแต่ก่อนเรียก
JSON.parse - เขียนเทสฝั่งรับเอาไว้ด้วย ไม่ใช่ดูแค่ว่าฝั่งส่งพ่นอะไรออกมา
ข้อแรกทำได้ด้วยฟังก์ชันสั้นๆ ตัวเดียว คือเขียนฟังก์ชันที่หยิบเฉพาะช่องที่ปลายทางต้องใช้ แล้วแปลงแต่ละช่องให้อยู่ในรูปที่ JSON เก็บได้ตั้งแต่ตอนนั้น
function toUserResponse(user) {
return {
id: user.id.toString(),
name: user.name,
createdAt: user.createdAt.toISOString(),
roles: [...user.roles]
}
}
ฟังก์ชันแบบนี้ทำหน้าที่เป็นเอกสารไปในตัว อ่านแล้วรู้เลยว่า id เดินทางเป็นสตริง เวลาเดินทางเป็นข้อความ UTC และโครงสร้าง Set ของ role กลายเป็น array ที่มีลำดับ วันหลังใครไปแก้ class ข้างใน ข้อมูลที่ส่งออกก็ไม่เปลี่ยนตามจนกว่าจะมีคนมาแก้ฟังก์ชันนี้
ส่วนข้อที่ว่าต้องตรวจข้อมูลหลัง parse นั้นมีเหตุผลที่คนใช้ TypeScript มักคาดไม่ถึง JSON.parse ตรวจแค่ว่าข้อความถูกไวยากรณ์ JSON ไหม ไม่ได้ตรวจว่าข้างในมีช่องที่แอปต้องใช้หรือเปล่า
const value = JSON.parse('{"name":42,"createdAt":"yesterday"}')
ข้อความก้อนนี้เป็น JSON ที่ถูกต้องสมบูรณ์ แต่ข้อมูลข้างในใช้งานไม่ได้เลย ช่องชื่อเป็นตัวเลข ส่วนช่องวันที่เป็นคำว่า yesterday
TypeScript ก็ช่วยตรงนี้ไม่ได้ เพราะชนิดข้อมูลที่มันประกาศไว้หายไปตั้งแต่ตอนคอมไพล์ ไม่มีอะไรเหลือมาถึงตอนที่โปรแกรมรันจริง งานตรวจจึงต้องมีตัวช่วยที่ลงมือตอนนั้น ไลบรารีที่บทความยกมาสำหรับงานนี้คือ Zod · Valibot · TypeBox หน้าตาคำสั่งของแต่ละตัวต่างกัน แต่หน้าที่เดียวกัน คือคัดข้อมูลที่ผิดรูปทิ้งตั้งแต่ก่อนที่โค้ดฝั่งธุรกิจจะหยิบไปใช้
ส่วนค่าที่ JSON แทนตรงๆ ไม่ได้ ให้ส่งมันไปพร้อมป้ายชื่อชนิดข้อมูล แทนที่จะปล่อยให้ปลายทางเดาจากหน้าตาของสตริง
{
"requestId": "9007199254740993",
"createdAt": {
"$type": "Date",
"value": "2026-07-21T12:00:00.000Z"
}
}ป้ายชื่อแบบนี้ทำให้ฝั่งรับไม่ต้องเดาจากหน้าตาของข้อความอีกต่อไป เห็น $type เป็น Date ก็แปลงกลับเป็นวันที่ เห็นช่องที่ตกลงกันว่าเป็นสตริงของตัวเลข ก็เก็บมันไว้เป็นรหัสตามเดิม
ถ้าข้อมูลในโปรเจกต์เลยขอบเขตที่ JSON แทนได้จริงๆ ยังมีทางเลือกอื่นอีก อย่างรูปแบบ MessagePack ที่เก็บข้อมูลเป็นไบนารีขนาดกะทัดรัด และเปิดช่องให้เพิ่มชนิดข้อมูลนอกเหนือจากที่มีมาให้ ส่วนรูปแบบ CBOR แยกข้อความออกจากข้อมูลไบนารีได้ และรองรับตัวเลขได้กว้างกว่า แต่ทั้งสองรูปแบบก็ไม่ได้เก็บ object ของ JavaScript ไว้ครบเองอัตโนมัติ สองฝั่งยังต้องตกลงกันอยู่ดีว่าจะแทนค่าอะไรด้วยอะไร การเปลี่ยนรูปแบบจึงแค่เพิ่มชนิดข้อมูลที่ส่งได้ ไม่ได้แปลว่าเลิกต้องตกลงกัน
เช็กลิสต์ชุดนี้เขียนลงไฟล์กฎของ AI agent ในโปรเจกต์ได้เลย เขียนครั้งเดียวแล้วให้เครื่องมือ ai-rules-sync แปลงต่อไปเป็นไฟล์กฎของตัวอื่นให้อัตโนมัติก็ได้
โค้ดที่พังจะส่งเสียงเอง แต่ข้อมูลที่เพี้ยนไม่มีเสียงให้ฟัง คนที่เดินมาบอกว่าตัวเลขไม่ตรงจึงเป็นคนที่ถือใบเสร็จอยู่ในมือ ไม่ใช่บรรทัด error บนจอ
ที่มา: บทความ Your JSON Is Lying to You จาก blog.gaborkoos.com
ชอบเรื่องแนวนี้ มีอีบุ๊คฟรีให้อ่านต่อ
NotebookLM ฉบับเข้าใจง่าย โยนเอกสารให้ AI อ่าน แล้วได้สรุป พอดแคสต์ และคลังความรู้ส่วนตัว
กดสมัครแล้วเราจะส่งเทคนิค AI และของแจกใหม่ๆ ให้ทางอีเมล เลิกรับได้ตลอด
Claude Cowork · The Business Playbook

ฉบับภาษาไทย 15 บท เรียนรู้ผ่านโปรเจกต์จำลองต่อเนื่องทั้งเล่ม ตั้งแต่ตั้งค่า Workspace จัดการไฟล์ เชื่อมแอป ตั้งระบบอัตโนมัติ จนถึงสร้าง Plugin


