~/blog

JSON.stringify turns Maps and Sets into {}

published

#javascript#json#nodejs

TL;DR

JSON.stringify(new Map([['a', 1]])) returns "{}". So does JSON.stringify(new Map()). There is no error and no warning — an empty Map and a full one serialise identically, so the bug looks like “the data was never there” rather than “the data was dropped on write”. Fix it with a replacer that converts Maps and Sets on the way out, or give the class a toJSON(). If the destination is another JS runtime rather than a wire format, use structuredClone instead — it keeps both types intact.

The problem

You refactor a plain object into a Map, because you want ordered keys and non-string keys and every blog post for a decade has told you to. Then you cache the result:

const sessions = new Map([
  ['u_1', { seen: 3 }],
  ['u_2', { seen: 9 }],
]);

await fs.writeFile('cache.json', JSON.stringify({ sessions }));
{"sessions":{}}

Nothing threw. The file was written. The process exited 0. Tomorrow the cache reads back as an empty object and the only symptom is that every session looks new.

Set does the same thing:

JSON.stringify({ tags: new Set(['a', 'b']) });
// '{"tags":{}}'

The reason this one bites so hard is that the failure is indistinguishable from success. Compare the two calls:

JSON.stringify(new Map());               // '{}'
JSON.stringify(new Map([['a', 1]]));     // '{}'

There is no output difference to notice in a log, no shape difference to catch in a schema check that only asserts “is an object”, and no exception to trip a test.

Why it happens

JSON.stringify has no special case for Map or Set. Its rule for a plain object is: walk the object’s own enumerable string-keyed properties. A Map does not keep its entries in properties — they live in internal slots reachable only through Map.prototype.get/set, which the serialiser never calls. So the walk finds zero own enumerable properties and writes {}, entirely correctly by its own rules.

That same rule is what quietly drops a longer list of things. Verified on Node v26.2.0:

ValueJSON.stringify resultWhat happened
new Map([['a',1]]){}entries live in internal slots
new Set([1,2]){}same
{ a: 1, b: undefined }{"a":1}key removed entirely
[1, undefined, 3][1,null,3]can’t drop an index, so it becomes null
{ a: 1, f() {} }{"a":1}functions are skipped
{ a: Infinity, b: NaN }{"a":null,"b":null}no JSON literal for either
{ [Symbol('k')]: 1, v: Symbol('x') }{}symbol keys and values both vanish
new Uint8Array([1,2,3]){"0":1,"1":2,"2":3}serialises as index properties, not an array
{ a: 1n }throwsTypeError: Do not know how to serialize a BigInt
circular referencethrowsTypeError

Note the asymmetry that makes this genuinely dangerous: BigInt and circular references throw, so you find them in the first test run. Maps, Sets, undefined and functions do not. The loud failures are the safe ones.

What to do

Option 1 — a replacer, when the fix belongs at the call site

The replacer runs before serialisation and sees the live value, so it can hand back something JSON understands:

const jsonSafe = (_key, value) => {
  if (value instanceof Map) return Object.fromEntries(value);
  if (value instanceof Set) return [...value];
  return value;
};

JSON.stringify({ sessions, tags: new Set(['a']) }, jsonSafe);
// '{"sessions":{"u_1":{"seen":3},"u_2":{"seen":9}},"tags":["a"]}'

⚠️ Object.fromEntries only round-trips a Map whose keys are strings. Object and number keys get coerced by the object literal itself, and two different keys can collide:

const m = new Map([[{ id: 1 }, 'a'], [2, 'b']]);

JSON.stringify(Object.fromEntries(m));
// '{"2":"b","[object Object]":"a"}'   ← key destroyed

For a Map with non-string keys, serialise the entries array instead — it survives intact and rebuilds exactly:

if (value instanceof Map) return [...value];        // in the replacer

const revived = new Map(JSON.parse(text));          // on the way back

Option 2 — toJSON(), when the fix belongs on the type

JSON.stringify calls toJSON() on any value that has one, so a subclass fixes every call site at once:

class Tags extends Set {
  toJSON() {
    return [...this];
  }
}

JSON.stringify({ t: new Tags([1, 2]) });
// '{"t":[1,2]}'

This is the right shape when the type is yours and the JSON form is a property of the type rather than of one particular write.

Option 3 — don’t use JSON at all

If you are copying between two JavaScript contexts — a worker, an iframe, an IndexedDB write, a Node postMessagestructuredClone preserves both types with no adapter:

const copy = structuredClone({ m: new Map([['a', 1]]), s: new Set([1]) });

copy.m instanceof Map;  // true
copy.s instanceof Set;  // true

It has its own exclusions — functions throw, class prototypes are stripped — covered in structuredClone rejects functions and strips prototypes.

Caveats

References