Skip to content
JavaScript

5 JSON Map and Set Serialization Tests Before API Release

·11 min read
5 JSON Map and Set Serialization Tests Before API Release - JavaScript guide from JSON Prettifier

Quick answer: Before releasing Map or Set data through JSON, test the default empty-object result, select an explicit wire shape, detect key coercion collisions, prove nested tagged round trips, and validate every decoded marker and entry. These five checks prevent silent collection loss and accidental type changes across an API boundary.

JavaScript collections are useful because they behave differently from plain objects and arrays. JSON does not carry those types. A request can serialize successfully, remain valid JSON, and still lose every Map or Set entry. These five tests make the conversion contract explicit.

Wikipedia's associative-array comparison provides background on mapping types across languages. Technical behavior below comes from MDN, ECMAScript, and executable assertions rather than that overview alone.

1. Does the default serializer silently erase Map and Set contents?

JSON.stringify normally produces an empty object for a Map or Set because their entries are not enumerable own string-keyed properties. Test both a root collection and collections nested inside an object. Serialization does not throw, so checking only for an exception or valid JSON will miss complete and silent data loss.

The current ECMAScript keyed-collection specification stores collection data in internal slots. MDN's JSON.stringify documentation explains that enumerable own properties are visited unless custom behavior returns another representation.

import assert from "node:assert/strict";
const roles = new Set(["admin", "editor"]);
const quotas = new Map([["api", 100], ["export", 20]]);
assert.equal(JSON.stringify(roles), "{}");
assert.equal(JSON.stringify(quotas), "{}");
assert.equal(JSON.stringify({ roles, quotas }), '{"roles":{},"quotas":{}}');
assert.equal(roles.size, 2);
assert.equal(quotas.size, 2);

The size assertions prove that the sources were populated; loss happened at serialization. Put the regression at the boundary that creates an HTTP body, queue message, cache value, or file. A unit test on collection construction cannot detect a framework that calls JSON.stringify later.

Reject raw collections before transport when an adapter was expected. A recursive check can report the exact path and required wire representation. If a framework owns serialization, confirm whether schema serializers, decorators, or class transformers run first. Do not infer behavior from native JSON alone.

The broader JSON.parse and JSON.stringify guide shows where collection conversion fits beside ordinary parsing, error handling, and outbound request creation. Keep the specialized adapter close to that transport boundary rather than scattering one-off conversions through business logic.

Also test empty and populated collections separately. Both produce the same empty object under default serialization, so a happy-path assertion against an empty collection proves nothing. Seed each collection with at least two distinct entries and compare the decoded output with the complete expected fixture.

Add an observability check at this boundary. Log only the collection type, expected entry count, emitted representation, and a safe correlation identifier; never dump secrets merely to prove serialization. A count mismatch should fail the operation before transport. That signal distinguishes encoding loss from a later receiver, network, or storage failure without exposing payload contents.

Formatting cannot restore lost entries. The JSON Viewer can inspect only text that already exists. If serialization emitted empty objects, the values are gone before the viewer receives them. Treat the viewer as a final shape inspection tool, not a collection encoder.

2. Does the selected wire shape match the collection contract?

Choose a wire representation deliberately. A string-keyed Map can become a JSON object, while an entry-pair array preserves broader key types and ordering more honestly. A Set usually becomes an array. Test exact JSON and parsed values so a refactor cannot switch representations without an API compatibility decision.

MDN's Map reference states that JSON has no native Map serialization, though replacer and reviver functions can provide it. Simple explicit adapters use Object.fromEntries, Array.from, or spread syntax.

import assert from "node:assert/strict";
const settings = new Map([["theme", "dark"], ["pageSize", 25]]);
const flags = new Set(["beta", "compact"]);
const objectShape = JSON.stringify(Object.fromEntries(settings));
const entryShape = JSON.stringify([...settings]);
const setShape = JSON.stringify([...flags]);
assert.equal(objectShape, '{"theme":"dark","pageSize":25}');
assert.equal(entryShape, '[["theme","dark"],["pageSize",25]]');
assert.equal(setShape, '["beta","compact"]');
assert.deepEqual(JSON.parse(entryShape), [["theme", "dark"], ["pageSize", 25]]);

Object shape is convenient when keys are known safe strings and object semantics match the domain. Entry arrays are better when the map itself is data, keys are dynamic, or a consumer must distinguish key types. They are also straightforward to validate as two-item tuples.

For a Set, state whether array order is meaningful. Set iteration follows insertion order in JavaScript, but many APIs communicate membership only. Clients should not treat the first value as preferred unless the contract says so. Define sorting if stable output is required for snapshots, caching, or signatures.

Document the JSON representation instead of merely calling the field a Map. Python, PHP, Java, and database consumers need a wire schema, complete examples, duplicate policy, and ordering rules. An implementation-specific collection name does not answer those questions.

Test the receiver, not just the encoder. JSON.parse returns an object or array; it does not reconstruct Map or Set automatically. Assert whether the consumer should keep the wire representation or create a collection inside its domain layer.

Generate or hand-write a consumer fixture in another supported language when the API is public. Confirm that object keys, tuple arrays, and uniqueness rules map cleanly into that client's available types. A JavaScript-only round trip can preserve a convention that a Java, PHP, Python, or mobile SDK interprets differently even though all parsers accept the JSON.

3. Does object conversion detect Map key coercion collisions?

Object.fromEntries coerces Map keys into property keys, so distinct Map keys can collapse or change meaning. Test numeric versus string keys, object keys, and special names before choosing object shape. Entry-pair arrays preserve JSON-compatible key values more faithfully and expose unsupported keys for deliberate rejection.

Map accepts values of any type as keys. JSON object member names are strings. Converting numeric key 1 and string key "1" cannot preserve both. The later entry overwrites the earlier entry without a JSON error.

import assert from "node:assert/strict";
const map = new Map([
  [1, "numeric"],
  ["1", "string"],
  [{ id: 7 }, "object"]
]);
const object = Object.fromEntries(map);
assert.equal(map.size, 3);
assert.equal(object["1"], "string");
assert.equal(object["[object Object]"], "object");
assert.equal(Object.keys(object).length, 2);
const entries = [...map];
assert.equal(entries.length, 3);
assert.equal(typeof entries[0][0], "number");
assert.equal(typeof entries[1][0], "string");

Entry arrays still need key rules because arbitrary objects, symbols, functions, and BigInt values are not automatically safe JSON data. Define a supported key domain, such as strings and finite numbers, and reject anything else before serialization.

Treat coercion as a data-integrity problem, not formatting. A permissions table, rate map, or localization registry can assign a value to the wrong logical key after a collision. Assert input entry count, output entry count, and a round-trip lookup for every supported key type.

When all keys are strings, test names such as "proto", "constructor", and "prototype" if a parsed object will later be merged. Serialization alone does not create prototype pollution, but unsafe assignment can. The prototype-pollution checks cover that consumer boundary.

Avoid automatic fallback from object shape to entry shape based on one surprising key. The same endpoint would then expose two schemas. Reject unsupported input or version the field so every consumer knows which representation to expect.

If maps are used for user-controlled lookup data, also test normalization rules before collision detection. Case folding, Unicode normalization, trimming, and locale-specific comparison can merge distinct strings after JSON decoding. The wire format should preserve original keys, while the application layer applies its documented comparison policy and reports any collision rather than silently selecting one value.

4. Does a tagged round trip restore nested Map and Set values?

A tagged replacer and reviver can preserve nested Map and Set types, but tests must cover root values, nested values, and collections inside arrays. Assert reconstructed instances and their contents. A round trip that restores visible values while returning plain arrays or objects has not preserved collection behavior.

MDN's JSON.parse examples explain that type information must be present for a value to round-trip. A namespaced tag is clearer than guessing from an array's shape.

import assert from "node:assert/strict";
const replacer = (key, value) => {
  if (value instanceof Map) return { $collection: "Map", entries: [...value] };
  if (value instanceof Set) return { $collection: "Set", values: [...value] };
  return value;
};
const reviver = (key, value) => {
  if (value?.$collection === "Map") return new Map(value.entries);
  if (value?.$collection === "Set") return new Set(value.values);
  return value;
};
const source = {
  access: new Map([["team", new Set(["read", "write"])]]),
  groups: [new Set(["a", "b"])]
};
const restored = JSON.parse(JSON.stringify(source, replacer), reviver);
assert(restored.access instanceof Map);
assert(restored.access.get("team") instanceof Set);
assert.deepEqual([...restored.access.get("team")], ["read", "write"]);
assert(restored.groups[0] instanceof Set);

Nested conversion works because replacement wrappers are traversed and the reviver visits children before their parent. Keep encoder, decoder, and schema together. Version the wrapper if a future representation changes tag names, entry forms, or supported keys.

Do not patch Map.prototype or Set.prototype globally just to avoid passing a replacer. Global toJSON methods alter serialization in dependencies, logging, and unrelated endpoints. A local adapter keeps the boundary visible and reduces surprising cross-feature behavior.

The JSON.stringify replacer tests cover root calls, toJSON precedence, unsupported values, and cycles that still apply here. Collection support does not make every nested value JSON-safe.

Test object identity separately. JSON copies value structure and does not preserve shared references. Two fields pointing to one object can become two objects after parsing. If identity affects Set membership or Map lookup, transmit a stable identifier and rebuild references explicitly.

Keep reference reconstruction out of a generic reviver. A reviver sees local values during traversal but lacks the endpoint's authorization, ownership, and completeness rules. Decode the validated wire shape first, then resolve identifiers in a domain-specific phase that can reject missing targets, duplicate identifiers, unauthorized references, and cycles with meaningful errors.

5. Does strict decoding reject forged tags and malformed entries?

Never revive a collection from a marker alone. Validate the wrapper's exact keys, payload type, tuple lengths, allowed key types, entry limits, and duplicate policy before constructing Map or Set. Otherwise ordinary user data or a forged marker can trigger conversion, hide duplicate entries, or consume excessive memory.

A marker such as dataType: "Map" may occur naturally in business data. Namespacing reduces accidental collisions but does not replace validation. If the schema already identifies collection paths, path-specific decoding can be clearer than reviving every matching object.

import assert from "node:assert/strict";
function decodeMap(value) {
  assert.deepEqual(Object.keys(value).sort(), ["$collection", "entries"]);
  assert.equal(value.$collection, "Map");
  assert(Array.isArray(value.entries));
  assert(value.entries.length <= 100);
  const result = new Map();
  for (const entry of value.entries) {
    assert(Array.isArray(entry) && entry.length === 2);
    assert.equal(typeof entry[0], "string");
    assert(!result.has(entry[0]), "duplicate Map key");
    result.set(entry[0], entry[1]);
  }
  return result;
}
assert.equal(decodeMap({ $collection: "Map", entries: [["a", 1]] }).get("a"), 1);
assert.throws(() => decodeMap({
  $collection: "Map", entries: [["a", 1], ["a", 2]]
}), /duplicate/);
assert.throws(() => decodeMap({ $collection: "Map", entries: "not-an-array" }));

JSON objects cannot expose two independent values for the same name in many parsers, while entry arrays can. Decide whether later entries overwrite earlier ones or duplicates are invalid. Rejection is usually easier to audit at an API boundary.

Apply the same discipline to Set arrays. JSON can contain duplicates even though Set construction silently removes them. If duplicates indicate a producer bug, compare array length with Set size and reject instead of normalizing without notice. Validate element types and maximum count before allocation.

Resource limits matter even for well-formed entries. Set maximum collection size, nesting depth, string length, and total request size before allocating large Maps or Sets. These limits belong beside schema validation rather than inside a generic reviver with no endpoint context.

Run the decoder against truncated wrappers, extra properties, null entries, arrays with three items, and values at the maximum allowed size. These negative fixtures document the rejection surface. Fuzzing can supplement them, but it should not replace a small deterministic suite whose failures clearly identify which API rule changed.

After encoding a non-sensitive fixture, inspect it in the JSON Viewer to verify wrappers, entries, arrays, and values. The viewer shows the JSON tree; it does not restore collection instances or validate decoder policy. Keep those guarantees in executable contract tests.

Frequently Asked Questions

Why does JSON.stringify return an empty object for Map and Set?
JSON.stringify visits enumerable own string-keyed properties on ordinary objects. Map and Set keep their entries in internal collection slots rather than enumerable own properties, so their contents are not emitted automatically. Convert them to an agreed JSON shape or use a tested replacer before crossing an API or storage boundary.
Should a JavaScript Map become a JSON object or an array of entries?
Use an object only when every key is already a unique safe string and object semantics match the contract. Use an array of key-value entry pairs when key types, insertion order, or distinctions such as numeric 1 versus string 1 must survive. Document the selected shape for non-JavaScript consumers.
How should a JavaScript Set be represented in JSON?
A Set usually becomes a JSON array, but the receiver must know whether uniqueness is contractual and how equality is defined. JSON parsing returns an array, not a Set. Validate every element, reject unexpected duplicates if required, and reconstruct a Set only inside code that needs Set operations.
Can a replacer and reviver preserve nested Map and Set values?
Yes. A replacer can wrap each collection in a tagged JSON object, and a reviver can reconstruct it during parsing. The tag and payload schema must be namespaced, validated, and collision-resistant. Otherwise ordinary user data that happens to match the marker can be converted into a collection unexpectedly.
Does JSON preserve object identity inside a Map or Set?
No. JSON represents values, not JavaScript object identity. Repeated references are serialized as separate structures unless they form a cycle, which throws without custom handling. After parsing, equal-looking objects are different instances, and a Set may contain both. Use a stable application identifier when identity matters across the boundary.

Inspect the serialized collection

Open a non-sensitive result as a JSON tree and verify its final keys, arrays, values, and type markers.

Open the JSON Viewer