1128 字
6 分鐘

JSON.stringify 欄位不見了?undefined、null 與更新契約排錯

表單狀態是 { nickname: undefined },送出的 JSON 卻成了 {}。若後端把省略欄位理解成「保持原值」,使用者按下清空後,資料就沒有改變。undefined 在物件屬性中會被省略,在陣列位置中會變成 null;要清空欄位,必須先和 API 約定明確表示法。

這裡討論的是序列化前後的欄位意義;JSON 語法本身可能完全合法,不能只看 HTTP 成功或 JSON.parse() 沒拋錯就判定保存成功。

三個位置,三種結果#

MDN JSON.stringify 文件記載了 undefined 的省略與替代規則。把下面存成獨立 json-fields.mjs,執行 node json-fields.mjs:

import assert from 'node:assert/strict';
const input = { nickname: undefined, note: null };
assert.equal(Object.hasOwn(input, 'nickname'), true);
const raw = JSON.stringify(input);
assert.equal(raw, '{"note":null}');
const output = JSON.parse(raw);
assert.equal(Object.hasOwn(output, 'nickname'), false);
assert.equal(output.note, null);
assert.equal(Object.hasOwn(input, 'nickname'), true); // 原物件沒有被刪欄位
assert.equal(JSON.stringify([undefined, null]), '[null,null]');
assert.equal(JSON.stringify(new Array(2)), '[null,null]');
assert.equal(JSON.stringify(undefined), undefined); // 不是字串 "undefined"
assert.equal(JSON.stringify({ score: NaN }), '{"score":null}');
assert.equal(JSON.stringify({ score: Infinity }), '{"score":null}');
console.log('欄位省略、陣列替代與根值檢查通過');

這個對照能定位損失發生在哪裡:原物件還有自有屬性,輸出的 JSON 卻沒有。如果 DevTools 顯示的是稍後才展開的物件,應另外保留序列化當下的字串;不要把畫面中的物件外觀當成送出的本文。

陣列中的空洞和 undefined 在這份 JSON 裡都變成 null,回讀後無法知道原本是哪一種。若問題起於陣列初始化,先看 new Array 的 empty slots 與 map;不能期待 JSON 往返保留所有 JavaScript 資料差異。

把「不修改」和「清空」寫成可驗收的契約#

以下是假設後端已同意的單欄 API:缺少 nickname 代表不修改,字串代表設定,null 代表清空。這是範例協定,不是所有 PATCH endpoint 的共同規則;接入前應核對後端 schema、Content-Type 與實際處理方式。

在這個協定下,自己的前端函式拒絕明確傳入 undefined,避免呼叫端誤把未決定的狀態當成「不修改」。它只接受一般資料物件與自有資料屬性,拒絕額外欄位、accessor 和 Symbol key;不打算處理任意 Proxy。

import assert from 'node:assert/strict';
function nicknameUpdate(input) {
if (input === null || typeof input !== 'object' ||
Object.getPrototypeOf(input) !== Object.prototype) {
throw new TypeError('Expected a plain update object');
}
const keys = Reflect.ownKeys(input);
if (keys.some(key => key !== 'nickname')) {
throw new TypeError('Unexpected field');
}
if (keys.length === 0) return '{}';
const descriptor = Object.getOwnPropertyDescriptor(input, 'nickname');
if (!Object.hasOwn(descriptor, 'value')) {
throw new TypeError('Expected a data property');
}
const value = descriptor.value;
if (value !== null && typeof value !== 'string') {
throw new TypeError('nickname must be a string or null');
}
return JSON.stringify({ nickname: value });
}
assert.equal(nicknameUpdate({}), '{}');
assert.equal(nicknameUpdate({ nickname: '小夏' }), '{"nickname":"小夏"}');
assert.equal(nicknameUpdate({ nickname: null }), '{"nickname":null}');
assert.equal(nicknameUpdate({ nickname: '' }), '{"nickname":""}');
for (const input of [
{ nickname: undefined }, { nickname: 0 }, { nickname: false },
{ nickname: NaN }, { nickname: 1n }, { extra: true }, [], null,
{ [Symbol('hidden')]: 'x' },
]) {
assert.throws(() => nicknameUpdate(input), TypeError);
}
let reads = 0;
assert.throws(() => nicknameUpdate({
get nickname() { reads += 1; return 'x'; },
}), TypeError);
assert.equal(reads, 0);
console.log('保留、設定、清空與拒絕邊界通過');

範例沒有把空字串轉成 null:兩者是否等價要由業務決定。也沒有替任何字串做長度、格式或名稱可用性驗證;那些應依真實 schema 補上。它的新增價值是讓「呼叫端尚未決定」在送出前明確失敗,而非靠序列化默默省略。

為什麼不直接把所有 undefined 都轉成 null#

JSON.stringify(value, (_key, item) => item === undefined ? null : item) 會把範圍擴大到整棵資料,連巢狀物件和根值也一起改變。若某些欄位的 null 代表刪除,這個通用替換就改掉了原本「沒有提供」的意思。只有整個 API 契約都同意這種轉換時才適合採用;單欄更新優先明確建立 payload。

ECMAScript JSON 序列化規則也包含 toJSON() 與 replacer 步驟。若一般欄位仍不見,檢查是否存在自訂 toJSON()、replacer 函式或欄位白名單;先用最小普通資料物件比較,別直接修改全域原型。BigInt 與循環參照還可能讓序列化拋錯,這和「成功但省略欄位」不同;大整數 ID 的表示方式可接著看 JSON 精度與字串契約。

本文兩個完整程式區塊由維護流程於 2026-10-28 在 macOS、Node.js v24.13.0 執行通過,未呼叫真實 API,也未測試後端 PATCH 或框架表單。正式驗收應比較送出前的 payload、Network 原始本文與保存後重新讀取的值;本機序列化 assertion 無法證明後端接受清空語意。

參考資料:

MDN:JSON.stringify()

ECMAScript:JSON.stringify

JSON.stringify 欄位不見了?undefined、null 與更新契約排錯
https://laplusda.com/posts/javascript-json-stringify-undefined-fields/
作者
Zero
發佈於
2026-10-28
許可協議
CC BY-NC-SA 4.0