JSON parse and stringify Options You Probably Missed
The extra arguments of JSON.stringify and JSON.parse, tested in Node 24: toJSON, replacer arrays, indent limits, call order and exact numbers.

Most code calls JSON.stringify(x) and JSON.parse(text) with one argument and never looks at the rest of the signature. The extra arguments are small, well specified and solve problems that otherwise get solved with hand-written walkers: redacting fields, controlling output, writing a class's wire format, and turning strings back into dates or exact numbers. This article goes through each one with the output I got from Node 24, including the ordering and clamping rules that are easy to get wrong.
The three arguments of stringify
JSON.stringify(value, replacer, space)All three are optional. replacer is either a function or an array, and space is a number or a string. Passing null for the replacer is how you reach the third argument without filtering.
Replacer function
The function is called with (key, value) for every property, array element and finally the root (with the key as an empty string), and this is the object that holds the property. Its return value is what gets written. Returning undefined removes a property from an object, or writes null in an array. A common use is redaction at any depth:
JSON.stringify(
{ a: 1, secret: "x", n: { secret: "y", k: 2 } },
(k, v) => (k === "secret" ? undefined : v)
)
// {"a":1,"n":{"k":2}}The key was matched at both levels, which is why a replacer is better than deleting the property by hand: you do not need to know where it is. Because the function runs for every value it must be cheap. Returning a changed value for objects is allowed too, and the function is then called again on the contents of the new value, so a replacer that wraps an object in another object will visit the wrapper's children (the keys seen were "", a, wrapped, y).
Replacer array
An array of strings (or numbers) is an allow-list of property names, applied at every level. With ["a", "c"] on { a: 1, b: 2, c: { a: 3, d: 4 } } the output was {"a":1,"c":{"a":3}}: b was omitted at the top, and d was omitted inside c as well. That second part is the surprise. If you list id to keep a record's id, you also list it for the nested objects, and any nested key you did not list vanishes. Arrays of objects are traversed normally, but their elements' keys are filtered the same way. The array form is also a sort order: keys appear in the order of the list, not the order of the object, for the keys that exist, which can be handy for stable output of a known set of fields (["c", "a"] on { a: 1, b: 2, c: 3 } gave {"c":3,"a":1}).
space
A number is a count of spaces per level, and a string is the indentation itself. Both are limited to ten. I asked for 100 and each indented line carried exactly ten spaces; a 16 character string, "abcdefghijklmnop", produced abcdefghij. A tab works as given ("\t"), and 0, an empty string or no value gives compact output. There is no way to ask for a different indent per level or to keep short arrays on one line: [1, 2] always breaks onto three lines with indentation. If you want a compact array inside pretty output, you need a library or your own formatter. For how this plays with printing to a console, see pretty printing JSON in the browser console and in Node.
toJSON
If a value has a toJSON method, it is called first with the property name as its argument, and its return value is what continues through the process. That is how Date serializes, and how you give a class a stable wire format:
class Money {
constructor(cents, cur) { this.cents = cents; this.cur = cur; }
toJSON(key) { return { amount: (this.cents / 100).toFixed(2), currency: this.cur }; }
}The key argument is the property name or the array index as a string ({ x: { toJSON(k) { return "key=" + k; } } } gave {"x":"key=x"}), which lets one method behave differently by where it sits. Defining toJSON on your own classes is better than a replacer when the rule belongs to the type, because it travels with the class. Replacing toJSON on a built-in prototype is a global change; I would not do it outside a script. Setting it on one instance works: a Date whose toJSON was overridden serialized as "x".
The order matters: toJSON, then the replacer
toJSON runs before the replacer. So when a replacer is given a Date-valued property, the value argument is already the ISO string, not a Date. I logged typeof this[k] + "/" + typeof v for a date key and got object/string: the original is still reachable as this[key], the converted value is v. A replacer that wants to treat dates specially (to write local time, say) must read the original from this, and it must use a normal function, not an arrow function, since an arrow's this is not the holder.
What happens to unusual values
The replacer sees what is about to be written, so it is the place to catch types JSON cannot hold. A BigInt throws if it reaches the writer, so convert it in the replacer: (k, v) => typeof v === "bigint" ? v.toString() : v gave {"n":"12345678901234567890"}. A Map serializes as {} unless you convert it, (k, v) => v instanceof Map ? Object.fromEntries(v) : v. And wrapper objects unwrap on their own: [new String("a"), new Number(1), new Boolean(false)] serialized as ["a",1,false]. The complete list of what is dropped is in the guide on doing JSON in JavaScript and Node carefully, and the date and BigInt cases get their own article (dealing with dates and BigInt in JSON stringify).
Two details about output text are worth knowing. Property order is integer-like keys ascending, then the rest in insertion order: { z: 1, 10: 1, 9: 1, a: 1 } gave {"9":1,"10":1,"z":1,"a":1}. And strings are always well formed: a lone surrogate comes out as the escape "\ud800", not as broken bytes, so the output can be encoded as UTF-8 without error.
The reviver of parse
JSON.parse(text, reviver)The reviver is called with (key, value, context) for every value, after the text has been parsed, and its return value replaces the value. The key point is the order: innermost first. For {"a":{"b":1},"c":[2]} the keys visited were b, a, 0, c and finally the empty string for the root. A reviver that depends on a parent having been processed will be disappointed; one that works on leaves, like converting date strings, is natural.
Returning undefined deletes the property ({"a":1,"b":2} with b returning undefined gave { a: 1 }), and returning the value unchanged keeps it. this is the holder, and since children have already been processed, a reviver for a can see its sibling b: Object.keys(this) was ['a', 'b'].
Dates by key
const o = JSON.parse(text, (k, v) => (k === "d" ? new Date(v) : v));That produced a real Date (instanceof Date was true, with the right timestamp). By key is safer than by pattern, because a regular expression that recognizes ISO strings also converts any text field that happens to be one. In my test, a field named id holding a timestamp-shaped string was converted, and a note field with extra words was left alone only by luck. The full discussion of this trade-off is in using a reviver function to revive dates in JSON.parse.
Exact numbers with context.source
JSON numbers longer than 2^53 are rounded by the parser before the reviver sees them: 12345678901234567890 arrived as 12345678901234567000. In Node 24 the reviver has a third argument, and context.source holds the original text of a primitive. For the document {"n": 12345678901234567890}:
JSON.parse(text, (k, v, ctx) => (k === "n" ? BigInt(ctx.source) : v)).n
// 12345678901234567890nThat recovered the exact value as a BigInt. The same property shows trailing zeros the number would drop: for [1.50, "x", true, null] the sources were '1.50', '"x"', 'true' and 'null'. For objects and arrays the context is an empty object, with no source. Be careful with the runtime: a reviver written this way throws a TypeError on an engine without the feature, because ctx is undefined. Guard it (ctx?.source ?? String(v)) or check typeof JSON.rawJSON === "function" first.
Writing the number back: JSON.rawJSON
The mirror image on the stringify side is JSON.rawJSON(text), which creates a marker object that is written into the output verbatim:
JSON.stringify({ n: JSON.rawJSON("12345678901234567890") })
// {"n":12345678901234567890}Combined with a replacer, (k, v) => typeof v === "bigint" ? JSON.rawJSON(v.toString()) : v, a BigInt is written as a real JSON number with no quotes, so { n: 10n } became {"n":10}. JSON.isRawJSON tests for one. The text must be a valid JSON primitive: JSON.rawJSON("{}"), JSON.rawJSON("[1]") and JSON.rawJSON("abc") all threw SyntaxError, while '"hi"' was accepted. This pair is how a JavaScript program can round-trip large integers without converting them to strings, which is the right choice only if the other side parses them exactly (it is not, if the consumer is another JavaScript program using plain JSON.parse).
Combining them
A realistic use puts several together: a class with toJSON for its wire format, a replacer to strip fields by name on the way to a log, space for readable files, and a reviver that rebuilds known types.
const logLine = JSON.stringify(event, (k, v) =>
/token|password|secret/i.test(k) ? "[redacted]" : v);A replacer used for logging should avoid walking huge or cyclic structures. A cycle still throws inside the replacer path (see stopping JSON.stringify from throwing on circular references), so log a projection of the event, not the raw object graph. For files read back later, pair the stringify settings with the options for the parse side so that a round trip is exact for the types you care about, and keep key order stable by sorting keys in a replacer if the output goes under version control.
When these options are the wrong tool
You need to validate, not transform. A reviver can throw on bad data, and it cannot tell you every problem at once. A schema validator reports them all (validating JSON with jsonschema in Python shows the idea in another language).
The rule belongs to a type. Put toJSON on the class. A global replacer that knows about twenty types is a maintenance burden.
The data is large. A replacer or reviver runs a JavaScript function for every value. For a 36 MB document that overhead is real; measure it, or process records one line at a time (streaming large JSON in Node).
Output format must be exact. If a downstream tool needs particular spacing or key order, check the output; the hooks do not give full control.
You are on an old runtime. context.source and rawJSON are not available everywhere, and a fallback that parses numbers as strings with a library is the portable path.
The specification text for these functions is in the ECMAScript JSON object definition, and the friendlier reference is the MDN pages for JSON.stringify and JSON.parse. To see a document before you write the hooks for it, use the JSON formatter.


