Quick answer: Before sending API data, test a
JSON.stringifyreplacer for the synthetic root call,toJSONprecedence, object-versus-array omissions, recursive property allowlists,BigIntconversion, and circular references. These six checks catch silent shape changes and thrown errors that a simple field-masking example does not reveal.
A replacer sits on a serialization boundary: its return values become the text another system receives. Small callback assumptions can silently remove fields, insert null, or prevent any request body from being created. The following executable tests turn those assumptions into explicit contracts.
Wikipedia's JSON overview provides format background, including JSON's limited set of value types. The JavaScript behavior below is grounded in the current ECMAScript specification and MDN documentation, then demonstrated with assertions you can run in Node.js.
1. Does the empty key identify the root call reliably?
The replacer first receives the complete input under a synthetic empty-string key, but a real object property may also have an empty-string name. Test both calls and distinguish them by lifecycle or holder identity. A condition based only on key === "" can accidentally transform or discard legitimate application data.
The ECMAScript serialization algorithm creates a wrapper object whose empty-string property holds the starting value. It then serializes that property. If the starting object owns an empty-string key, the callback later receives the same key text for a different holder.
import assert from "node:assert/strict";
const calls = [];
const input = { "": 7, name: "Ada" };
const output = JSON.stringify(input, function (key, value) {
if (key === "") calls.push({ value, holder: this });
return value;
});
assert.equal(calls.length, 2);
assert.equal(calls[0].value, input);
assert.equal(calls[1].value, 7);
assert.notEqual(calls[0].holder, calls[1].holder);
assert.equal(output, '{"":7,"name":"Ada"}');
Do not automatically reject the second call. An empty member name is valid JSON even if your API schema forbids it. Schema validation should report that contract violation separately. In callback code, a closure flag is often sufficient when root-only behavior is genuinely required: the first replacer invocation is the synthetic root call.
Also test primitive roots. JSON.stringify(4, replacer) still begins with the synthetic empty key. Returning undefined on that first call makes the entire result undefined, which is not a JSON request body. Your HTTP wrapper should detect that result before setting a content type and sending data.
Record the root behavior in the helper's public contract. A function designed only for object payloads can reject primitive inputs early; a general serializer should accept them consistently. Include null, false, zero, and an empty string because truthiness guards often misclassify those valid JSON roots. Assert both the return type and exact parsed value.
2. Does toJSON change the value before the replacer sees it?
When a value defines toJSON, serialization calls that method before invoking the replacer for the property. Test the post-toJSON value rather than assuming the callback sees the original instance. Otherwise, a replacer may miss private fields, apply a transformation twice, or emit a shape different from the API contract.
The current ECMAScript SerializeJSONProperty steps invoke toJSON before the replacer function. Dates use this mechanism to produce ISO strings. Custom domain objects can return any JSON-compatible value, so review those methods as part of the wire-format implementation.
import assert from "node:assert/strict";
const trace = [];
const invoice = {
total: {
cents: 1250,
toJSON(key) {
trace.push(["toJSON", key]);
return { amount: "12.50", currency: "USD" };
}
}
};
const text = JSON.stringify(invoice, (key, value) => {
if (key === "total") trace.push(["replacer", value]);
return value;
});
assert.deepEqual(trace[0], ["toJSON", "total"]);
assert.deepEqual(trace[1], ["replacer", { amount: "12.50", currency: "USD" }]);
assert.equal(text, '{"total":{"amount":"12.50","currency":"USD"}}');
A replacer is therefore not a guaranteed last look at the original object. If redaction depends on inspecting a class's internal properties, toJSON may already have hidden or renamed them. Prefer constructing an explicit transfer object before serialization when security or compatibility requires a reviewable, typed boundary.
Test Dates separately because a replacer normally receives their strings. If the API expects a timestamp or date-only value, define that conversion before JSON.stringify or match the documented string deliberately. Avoid rules that depend on value instanceof Date inside the replacer unless you have accounted for toJSON running first.
Getters can also execute during serialization, and either a getter or toJSON method can throw. For data from application objects rather than plain transfer objects, add a failure fixture and verify that transport never starts. This makes serialization failure observable at the correct boundary instead of surfacing as an unrelated network or logging error.
3. Do unsupported values change objects and arrays differently?
Undefined, functions, and symbols are omitted when they are object-property values, but they become null in arrays so positions remain stable. The same distinction applies when a replacer returns undefined. Test both container types because a payload can remain valid JSON while its field presence and array semantics change.
MDN's JSON.stringify reference documents these container-dependent results. They matter when absence, explicit null, and positional alignment have different meanings to the receiver.
import assert from "node:assert/strict";
const unsupported = [undefined, () => 1, Symbol("x")];
assert.equal(
JSON.stringify({ a: undefined, b: () => 1, c: Symbol("x") }),
"{}"
);
assert.equal(JSON.stringify(unsupported), "[null,null,null]");
const data = { keep: 1, drop: 2, values: [1, 2, 3] };
const text = JSON.stringify(data, (key, value) =>
key === "drop" || key === "1" ? undefined : value
);
assert.equal(text, '{"keep":1,"values":[1,null,3]}');
That last predicate is intentionally broad: the string key "1" can identify an array index or an object member. When behavior depends on the holder, use a normal function and inspect this; arrow functions do not receive the holder as their own this value. Better still, construct the outbound shape explicitly when rules depend on a schema path rather than a name.
Do not treat null as a harmless placeholder without checking the contract. A JSON Merge Patch endpoint, for example, may assign special deletion meaning to null. Compare the final parsed output with an expected fixture, not merely the generated string's validity. The null versus undefined guide covers the boundary in more detail.
Test sparse arrays as well. A pre-existing hole and an explicit undefined element both serialize as null, although JavaScript can distinguish their property ownership before serialization. If that difference carries business meaning, JSON is not preserving it. Normalize the array into an explicit application representation or reject it rather than guessing on the receiving side.
4. Does an array replacer allowlist every nested object as intended?
An array-form replacer is a property-name list applied to every non-array object encountered, not a path-specific projection of only the root. Array positions still serialize, while objects inside arrays also receive the same allowlist. Test nested objects so a convenient root filter does not silently remove required descendant fields.
The specification stores array-form entries as a PropertyList for object serialization. Numbers become property-name strings, duplicates collapse, and other entry types are ignored. This is useful for a uniform field vocabulary, but it is too blunt for many API schemas whose nested objects have different keys.
import assert from "node:assert/strict";
const input = {
id: 1,
profile: { id: 2, name: "Ada", role: "admin" },
items: [{ id: 3, name: "book", price: 9 }]
};
const text = JSON.stringify(input, ["id", "profile", "items"]);
assert.equal(
text,
'{"id":1,"profile":{"id":2},"items":[{"id":3}]}'
);
assert.deepEqual(JSON.parse(text), {
id: 1,
profile: { id: 2 },
items: [{ id: 3 }]
});
Adding "name" would admit that name at every object level, not just profile or items. If path-specific selection is required, map the application model to a dedicated request object or use a function replacer that tracks context carefully. An allowlist does not prove that required values exist, have correct types, or are authorized for disclosure.
After serialization, open a non-sensitive fixture in the JSON Viewer to inspect the actual tree and copy paths for assertions. The viewer shows the resulting JSON; it does not execute the replacer or validate an API schema. Keep executable tests as the source of truth.
Maintain separate fixtures for each endpoint version. Reusing one global property list feels convenient, but a newly required nested field can disappear without a type or syntax error. A full expected-object assertion makes the omission visible in review. It also documents why each allowed name exists, which a loose substring check cannot communicate.
5. Does the replacer convert every BigInt under an agreed contract?
JSON.stringify throws when it encounters a BigInt unless custom behavior converts it first. Test root and nested BigInt values with the exact representation your receiver expects. A decimal string preserves digits, but it changes the JSON type, so document that contract and reject serialization when no compatible encoding exists.
MDN lists encountering a BigInt among JSON.stringify exceptions. A function replacer can return a string before the algorithm reaches the unsupported primitive. This solves serialization mechanics, not interoperability: the receiving application must deliberately parse that field without losing precision.
import assert from "node:assert/strict";
assert.throws(() => JSON.stringify({ id: 9007199254740993n }), TypeError);
const encodeBigInt = (key, value) =>
typeof value === "bigint" ? value.toString(10) : value;
const nested = JSON.stringify({ account: { id: 9007199254740993n } }, encodeBigInt);
const root = JSON.stringify(9007199254740993n, encodeBigInt);
assert.equal(nested, '{"account":{"id":"9007199254740993"}}');
assert.equal(root, '"9007199254740993"');
Avoid appending a magic suffix unless both ends formally define it. A tagged object such as { "type": "bigint", "value": "..." } is more verbose but can make intent explicit. Whatever representation you choose, validate it against the API schema and include maximum length or range constraints appropriate to the destination.
The JSON number precision guide explains why converting an already-rounded JavaScript Number to a string cannot recover lost digits. Preserve the exact source earlier in the pipeline. Replacer logic only sees the in-memory value it receives.
Run the receiver-side test too: parse the serialized string with the same library and configuration used in production, then confirm the value is reconstructed or intentionally remains a string. Cross-language clients differ in numeric models. A passing JavaScript encoder test alone cannot prove that a database driver, mobile client, or analytics job interprets the field correctly.
6. Does circular-reference handling preserve shared values correctly?
Native JSON.stringify throws on a circular reference. A replacer can omit or label an ancestor link, but a global seen-object set also removes harmless repeated references. Test both a real cycle and two properties sharing one object. Use an ancestor-aware strategy or an explicit data model when shared identity matters.
JSON has no native reference type, so there is no universally correct output. MDN's cyclic object error reference shows a replacer approach and explains the format limitation. Your API must define whether to reject, omit, label, or encode references separately.
import assert from "node:assert/strict";
function omitCycles() {
const ancestors = [];
return function (key, value) {
if (typeof value !== "object" || value === null) return value;
while (ancestors.length && ancestors.at(-1) !== this) ancestors.pop();
if (ancestors.includes(value)) return undefined;
ancestors.push(value);
return value;
};
}
const root = { id: 1, child: { id: 2 } };
root.child.parent = root;
assert.equal(JSON.stringify(root, omitCycles()), '{"id":1,"child":{"id":2}}');
const shared = { enabled: true };
assert.equal(
JSON.stringify({ first: shared, second: shared }, omitCycles()),
'{"first":{"enabled":true},"second":{"enabled":true}}'
);
This example preserves repeated references that are not current ancestors, but it still discards the circular edge. That may be acceptable for a diagnostic snapshot and unacceptable for a domain graph. The existing circular-reference guide compares rejection, placeholders, and reference-aware formats.
Run cycle tests before a fetch call or queue publish, not only in a logging fallback. If serialization throws after other side effects, retries become harder to reason about. Build the request text, assert that it is a string, parse it back for shape checks, and only then hand it to transport code.
Keep diagnostic serialization separate from API serialization. Replacing a cycle with "[Circular]" may be useful in logs, while silently sending that marker to a service changes the domain data. Name each policy, give it independent fixtures, and ensure a catch-all logger never becomes the accidental encoder for durable messages or signed request bodies.