JSON.stringify Undefined-in-Array Trap: The Null That Wasn't There2026-07-10
This scheduler tracks a fixed-size roster of jobs. Empty slots are marked undefined, so the array indices stay meaningful (slot 0, slot 1, slot 2…). Persist to disk, load back, keep going. What could go wrong?
function saveRoster(jobs) {
localStorage.setItem("roster", JSON.stringify(jobs));
}
function loadRoster() {
const raw = localStorage.getItem("roster");
return raw ? JSON.parse(raw) : [];
}
function runRoster(jobs) {
for (const job of jobs) {
if (job === undefined) continue; // skip cleared slots
console.log(`Running job ${job.id}: ${job.name}`);
}
}
// Boot up, clear slot 1, save, reload, run.
let roster = [
{ id: 1, name: "reindex" },
undefined, // slot cleared earlier
{ id: 3, name: "backup" },
];
saveRoster(roster);
roster = loadRoster();
runRoster(roster);
// TypeError: Cannot read properties of null (reading 'id')
JSON has no undefined. When JSON.stringify hits an undefined inside an object, it silently drops the key. But arrays are different — dropping a slot would shift every subsequent index, silently corrupting data. So the spec chose the lesser evil: undefined in an array is emitted as null.
JSON.stringify({ a: undefined, b: 2 }) // '{"b":2}' key vanishes
JSON.stringify([undefined, 2]) // '[null,2]' becomes null
The round-trip is lossy but asymmetric. Your in-memory array had undefined at index 1. After save-and-load, index 1 is null. The check job === undefined is false for null (strict equality), so continue never fires. The loop falls through and tries to read null.id. Crash.
What makes it fiendish: the bug hides across a persistence boundary. Every unit test that skips serialization passes. The array's length is preserved, the shape looks identical in the debugger, and even a console.log of the restored array shows [{…}, null, {…}] — which reads like "yeah, obviously null." The distinction between "no value assigned yet" (undefined) and "explicitly nothing" (null) survives inside a single process and evaporates the moment JSON touches it.
Other silently-dropped values compound the trap: functions, Symbols, and BigInt throw or vanish too. Inside objects they disappear; inside arrays functions and symbols also become null, while BigInt throws outright.
Pick one canonical "empty" sentinel and check for it defensively. In practice, treat null and undefined as interchangeable at any deserialization boundary — the == null loose-equality check catches both:
function runRoster(jobs) {
for (const job of jobs) {
if (job == null) continue; // catches null AND undefined
console.log(`Running job ${job.id}: ${job.name}`);
}
}
If you actually need to preserve the difference (some slots were never assigned, others were explicitly cleared), JSON can't carry that — use a tagged representation like { cleared: true }, or serialize to a format that encodes undefined (structured clone via IndexedDB, MessagePack, or a custom replacer/reviver pair).
The general rule: JSON is not a mirror of your JavaScript value graph. It's a lossy projection into a smaller type system, and the losses aren't symmetric between arrays and objects.
JSON.stringify drops undefined from object keys but coerces it to null inside arrays — so a round-trip through JSON quietly rewrites your "empty slot" sentinel, and === undefined checks stop firing.
