Quick answer: Test a
JSON.parsereviver for syntax failure, child-first traversal, explicit returns, deletion shape, empty-key root calls, primitive source text, trusted callback selection, andthisbinding. These eight checks expose transformations that a happy-path date example misses, while keeping parsing, validation, and authorization as separate responsibilities.
A reviver is only a callback, but it runs across an entire parsed document. One missing return or unchecked callback source can change far more data than the field you meant to convert. The eight tests below are deliberately small, executable contracts for the JavaScript runtime and wrapper your application actually deploys.
Wikipedia's JSON background covers the format's objects, arrays, strings, numbers, booleans, and null. Runtime behavior here is grounded in the ECMAScript specification, official documentation, and tested examples rather than that overview alone.
1. Does invalid syntax fail before your reviver runs?
Invalid JSON must throw before any reviver transformation runs. Test malformed input and assert both the error type and a zero callback count. This prevents a wrapper from suggesting that a reviver repairs trailing commas, comments, truncated documents, or HTML responses when parsing actually failed before the callback could inspect a value.
The distinction matters at an API boundary. A reviver transforms values only after JSON text has been parsed. It is not an error-recovery hook. The current ECMAScript JSON.parse algorithm parses the text before it creates the root wrapper and begins internalizing properties with the reviver.
import assert from "node:assert/strict";
let calls = 0;
assert.throws(
() => JSON.parse('{"enabled":true,}', (key, value) => {
calls += 1;
return value;
}),
SyntaxError
);
assert.equal(calls, 0);
Add malformed fixtures that resemble real failures: an empty response, a truncated object, a single-quoted key, and an HTML error page. Do not pin the exact exception message across engines; assert SyntaxError and your wrapper's stable public error category. Engine wording can change without changing the contract.
If repair is an intentional product feature, make it a named preprocessing stage with its own acceptance tests and review. The JSON repair acceptance checklist explains why successfully parsed repaired output still needs verification. Do not hide repair inside a function named parseWithReviver.
2. Does the reviver visit children before their parents?
A reviver walks parsed data depth-first from nested properties toward their containers, then visits the root last. Test the exact order for one object and one array. Parent logic can safely observe already-revived children, but code that assumes top-down traversal may validate, aggregate, or convert a container before its descendants are ready.
MDN's JSON.parse reference describes this depth-first behavior. A compact trace makes it concrete and protects refactors that move logic between child and parent cases.
import assert from "node:assert/strict";
const order = [];
const result = JSON.parse('{"profile":{"age":41},"tags":["a"]}', (key, value) => {
order.push(key);
return value;
});
assert.deepEqual(order, ["age", "profile", "0", "tags", ""]);
assert.deepEqual(result, { profile: { age: 41 }, tags: ["a"] });
Array indexes arrive as strings, not numbers. If a transformation checks typeof key === "number", it never matches an array element. Test both the callback key and the completed parent value, especially when a parent rule depends on children becoming Date, BigInt, or another application type.
Keep validation order explicit. Child-first traversal is convenient for building a parent from revived children, but it does not turn a reviver into complete schema validation. Required-field checks, cross-field rules, authorization, and resource limits still need a contract outside this callback.
3. Does every branch return a value, including the root call?
Every callback branch must return the intended value. Returning undefined deletes a property, and an omitted return produces undefined too. Test transformed fields, untouched fields, and the final root call together. A reviver that handles one key correctly can still erase siblings or make JSON.parse return undefined for the complete document.
The specification replaces a property with the callback result unless that result is undefined, in which case it deletes the property. That rule also applies to the synthetic root property. An explicit default return value is therefore part of the data contract, not merely a style preference.
import assert from "node:assert/strict";
const good = JSON.parse('{"count":2,"label":"box"}', (key, value) => {
if (key === "count") return value * 10;
return value;
});
assert.deepEqual(good, { count: 20, label: "box" });
const broken = JSON.parse('{"count":2}', (key, value) => {
if (key === "count") return value * 10;
});
assert.equal(broken, undefined);
Use lint rules for consistent returns, but keep this runtime regression. A callback can be syntactically consistent and still deliberately return undefined on a broader branch than intended. Test false, zero, empty strings, and null as legitimate values so a truthiness shortcut does not accidentally route them into deletion logic.
When the transformation is more than a few conditions, extract a pure function for one value type and call it from a small reviver. That keeps matching logic testable without running a complete parse while preserving one integration test for callback coverage and the root return.
4. Does deleting a value preserve the object or array shape you expect?
Returning undefined removes an object property, but removing an array index leaves a sparse slot without shortening the array. Test object keys, first and middle array indexes, array length, and property ownership. If the desired result is a dense filtered list, perform an explicit array filter after parsing instead of relying on reviver deletion.
This difference is easy to miss in console output and serialization. JSON.stringify writes an array hole as null, which can make a deleted element look like an intentional JSON null during a later round trip. Check array ownership with Object.hasOwn rather than comparing only the visible values.
import assert from "node:assert/strict";
const data = JSON.parse('{"drop":1,"keep":2,"items":[1,2,3]}', (key, value) => {
if (key === "drop" || (key === "1" && Array.isArray(this))) return undefined;
return value;
});
// Arrow callbacks do not receive the holder as `this`, so use a normal function:
const checked = JSON.parse('{"drop":1,"keep":2,"items":[1,2,3]}', function (key, value) {
if (key === "drop" || (key === "1" && Array.isArray(this))) return undefined;
return value;
});
assert.deepEqual(checked.keep, 2);
assert.equal(Object.hasOwn(checked, "drop"), false);
assert.equal(checked.items.length, 3);
assert.equal(Object.hasOwn(checked.items, 1), false);
assert.equal(JSON.stringify(checked.items), "[1,null,3]");
assert.equal(data.items.length, 3);
The first parse intentionally shows why holder-dependent logic and arrow functions do not mix; the eighth test isolates that callback-binding contract. For production code, avoid combining deletion and type conversion unless the output shape is clearly documented.
If filtering is the goal, revive first and call filter on the completed array with a named predicate. That makes index movement visible and separates “this value is invalid” from “remove this list member.” Test the result against a complete expected array rather than merely asserting that a forbidden value is absent.
5. Does an empty data key collide with the synthetic root key?
The root callback uses an empty string as its key, but a JSON object may also contain a real empty-string property. Test both calls with different values. Never identify the root solely with key === ""; use transformation logic that works for either call or track the holder and expected lifecycle explicitly.
This is a protocol edge case, not invalid JSON. The wrapper created by JSON.parse owns a synthetic empty-string property containing the parsed root. If the parsed object itself owns an empty-string member, the reviver sees two different calls with the same key text.
import assert from "node:assert/strict";
const seen = [];
const result = JSON.parse('{"":7,"name":"sample"}', function (key, value) {
if (key === "") seen.push({ value, holderKeys: Object.keys(this) });
return value;
});
assert.equal(seen.length, 2);
assert.equal(seen[0].value, 7);
assert.deepEqual(seen[0].holderKeys, ["", "name"]);
assert.deepEqual(seen[1].value, { "": 7, name: "sample" });
assert.deepEqual(seen[1].holderKeys, [""]);
assert.deepEqual(result, { "": 7, name: "sample" });
Do not reject an empty key globally unless the application contract forbids it. JSON permits that member name. If your schema disallows it, report a validation failure after parsing rather than changing generic reviver semantics and surprising another caller that uses the same helper.
This test is especially important when the root is a primitive. The final callback still uses an empty key, but the value might be a number, string, Boolean, or null. Include one primitive-root fixture if your parser wrapper accepts top-level values instead of requiring an object.
6. Does context.source preserve the numeric token you intend to transform?
Test source-text access with an integer beyond JavaScript's safe range and a normal primitive. The value argument may already be rounded, while context.source can preserve the original token. Feature-detect the third argument in every supported runtime, and choose an explicit fallback or rejection path when source text is unavailable.
Source-text access was added to ECMAScript 2026 and appears in the current ECMAScript JSON.parse specification. The context object's source property is available for unmodified primitive values, not object or array callbacks. MDN's JSON documentation shows why reconstructing a large integer from the already-rounded value is too late.
import assert from "node:assert/strict";
let sourceSupported = false;
const parsed = JSON.parse('{"id":9007199254740993,"active":true}', (key, value, context) => {
if (key === "id") {
sourceSupported = typeof context?.source === "string";
if (!sourceSupported) throw new Error("JSON.parse source access required");
return BigInt(context.source);
}
return value;
});
assert.equal(sourceSupported, true);
assert.equal(parsed.id, 9007199254740993n);
assert.equal(parsed.active, true);
Run this check in each actual production runtime, not only your newest local Node.js. If a supported browser or server lacks source access, sending large identifiers as JSON strings remains the broadly interoperable design. The JSON number precision guide covers that representation choice in detail.
Do not convert every integer token to BigInt automatically. That type cannot be serialized by ordinary JSON.stringify without a policy, and application arithmetic may expect Number. Match named fields or a documented schema, then test the next consumer as well as the parse result.
7. Is the selected reviver an explicitly trusted callback?
Treat the reviver function itself as executable application code. If configuration chooses it, require an own property, verify its type, and prefer a closed set of known callbacks. Test inherited and missing values. Untrusted JSON should supply data to a reviver, never JavaScript code or a callback name that resolves through an unsafe prototype chain.
An Axios security advisory published April 24, 2026 documents a response-tampering path involving an inherited parseReviver configuration property after separate prototype pollution. That report is a concrete implementation case, not evidence that JSON.parse itself pollutes prototypes.
import assert from "node:assert/strict";
const inherited = { reviver: (key, value) => key === "role" ? "admin" : value };
const options = Object.create(inherited);
assert.equal(Object.hasOwn(options, "reviver"), false);
assert.equal(JSON.parse('{"role":"reader"}', options.reviver).role, "admin");
function selectReviver(config) {
if (!Object.hasOwn(config, "reviver")) return undefined;
if (config.reviver !== reviveDates) throw new TypeError("Unknown reviver");
return config.reviver;
}
function reviveDates(key, value) { return value; }
assert.equal(selectReviver(options), undefined);
assert.equal(selectReviver({ reviver: reviveDates }), reviveDates);
assert.throws(() => selectReviver({ reviver: "reviveDates" }), TypeError);
Use Object.hasOwn to distinguish declared configuration from inherited state. A type check alone is insufficient because the inherited value can be a function. Keep reviver selection close to trusted application setup rather than letting each response or request choose arbitrary behavior.
After parsing, validate allowed fields and downstream object operations. The prototype-pollution regression checklist covers assignment, arbitrary keys, inherited reads, and consumers. A trusted callback does not make every returned object safe for every merge or authorization decision.
8. Does callback binding behave the same in your deployed implementation?
Use a normal function when reviver logic needs the holder object through this; an arrow function keeps lexical this instead. Test the wrapper your framework calls, not just a standalone callback. Prefer value-based transformation when possible, because hidden dependence on holder state makes reuse, typing, and isolated tests harder to reason about.
The reviver receives the object containing the current property as its this value when it is a normal function. That enables context-sensitive checks, but it also couples the callback to invocation semantics. The behavior is documented in the MDN reviver reference.
import assert from "node:assert/strict";
const holders = [];
const parsed = JSON.parse('{"items":[{"price":4}]}', function (key, value) {
if (key === "price") holders.push(this);
return value;
});
assert.equal(holders.length, 1);
assert.deepEqual(holders[0], { price: 4 });
assert.equal(holders[0], parsed.items[0]);
let arrowThis;
JSON.parse('{"price":4}', (key, value) => {
if (key === "price") arrowThis = this;
return value;
});
assert.equal(arrowThis, undefined);
ECMAScript modules use lexical undefined for top-level this, which makes the arrow assertion deterministic in the .mjs test shown here. A script, transpiler, or bundler can supply a different surrounding this, but it still will not be the current property holder. Test the artifact and module mode you ship.
Finish with an integration fixture that enters through the real fetch, file, queue, or storage adapter. Compare the entire transformed result, confirm the original text is handled according to your retention policy, and validate the result before use. You can inspect a non-sensitive fixture in the JSON Viewer; its tree confirms the parsed structure but does not execute or certify your reviver.