Pretty-print JSON in JavaScript with JSON.stringify(): indentation, replacers, and what silently gets lost
You don't need a library or a recursive function to format an object as readable JSON. JSON.stringify takes an indentation argument:
const o = { a: 1, b: 'test', c: [1, 2, 3] };
console.log(JSON.stringify(o));
console.log(JSON.stringify(o, null, 2));
console.log(JSON.stringify(o, null, '\t'));$ node basics.mjs
{"a":1,"b":"test","c":[1,2,3]}
{
"a": 1,
"b": "test",
"c": [
1,
2,
3
]
}
{
"a": 1,
"b": "test",
"c": [
1,
2,
3
]
}The third argument is either a number of spaces (capped at 10) or a string to indent with, such as '\t'. The second argument, here null, is the replacer, covered below. Everything in this post was run on node:24-slim and node:26-slim, with identical output. JSON works the same way in browsers.
What JSON can't represent
JSON has strings, numbers, booleans, null, arrays and plain objects, and nothing else. JSON.stringify doesn't complain about anything else. It quietly drops or converts it:
const value = {
missing: undefined,
fn() {},
symbol: Symbol('x'),
notANumber: NaN,
infinity: Infinity,
date: new Date('2026-10-01T12:00:00Z'),
map: new Map([['a', 1]]),
set: new Set([1, 2]),
regex: /ab+c/,
error: new Error('boom'),
list: [undefined, () => {}, NaN],
};
console.log(JSON.stringify(value, null, 2));$ node lossy.mjs
{
"notANumber": null,
"infinity": null,
"date": "2026-10-01T12:00:00.000Z",
"map": {},
"set": {},
"regex": {},
"error": {},
"list": [
null,
null,
null
]
}undefined, functions and symbols vanish as object properties (missing,fnandsymbolare simply gone), but becomenullinside arrays, so that indexes don't shift.NaNandInfinitybecomenull.Datebecomes an ISO string through itstoJSON()method.JSON.parsewon't turn it back into aDate.Map,Set,RegExpandErrorall become{}. Their data isn't in enumerable own properties, so there's nothing to write. That catches people logging errors as JSON.
Two things throw instead:
$ node throws.mjs
BigInt: TypeError: Do not know how to serialize a BigInt
circular: TypeError: Converting circular structure to JSONThe replacer: pick, redact and convert
An array replacer is an allowlist of property names. A function replacer is called for every key and value, and whatever it returns is written instead (return undefined to drop the key):
const user = { id: 7, name: 'Ada', password: 'hunter2', roles: new Set(['admin']), createdAt: new Date('2026-10-01T12:00:00Z'), quota: 12345678901234567890n };
// 1. An array replacer is an allowlist of keys
console.log(JSON.stringify(user, ['id', 'name'], 2));
// 2. A function replacer can redact, convert and drop
console.log(JSON.stringify(user, (key, value) => {
if (key === 'password') return undefined; // drop
if (value instanceof Set) return [...value]; // Set → array
if (typeof value === 'bigint') return value.toString(); // BigInt → string
return value;
}, 2));$ node replacer.mjs
{
"id": 7,
"name": "Ada"
}
{
"id": 7,
"name": "Ada",
"roles": [
"admin"
],
"createdAt": "2026-10-01T12:00:00.000Z",
"quota": "12345678901234567890"
}The function version removes the password, turns the Set into an array, and turns the BigInt into a string instead of throwing. Note that createdAt reaches the replacer already as a string: toJSON() runs first, so value instanceof Date is never true inside a replacer.
The array replacer is a good habit when serialising objects that might gain sensitive fields later: only the keys you list are written.
toJSON(): let a class decide
Any object with a toJSON() method controls its own serialisation, which is how Date does it:
class Money {
constructor(cents, currency) { this.cents = cents; this.currency = currency; }
toJSON() { return `${(this.cents / 100).toFixed(2)} ${this.currency}`; }
}
console.log(JSON.stringify({ total: new Money(1999, 'EUR') }));{"total":"19.99 EUR"}Numbers too big for JavaScript
JSON from another system may contain integers larger than Number.MAX_SAFE_INTEGER, such as database ids or snowflake ids. JSON.parse rounds them silently. The newer JSON.rawJSON() and the reviver's third context argument fix both directions:
const big = 12345678901234567890n;
console.log(JSON.stringify({ id: JSON.rawJSON(big.toString()) }));
console.log(JSON.parse('{"id":12345678901234567890}').id);
console.log(JSON.parse('{"id":12345678901234567890}', (key, value, context) => key === 'id' ? BigInt(context.source) : value).id);{"id":12345678901234567890}
12345678901234567000
12345678901234567890nThe plain parse turned …890 into …000. With context.source, the reviver sees the original digits and can make a BigInt. Both features are available in Node.js 21+, Chrome 114, Firefox 135 and Safari 18.4.
For logging, use util.inspect instead
If you only want to look at a value, JSON is the wrong tool, because it hides Maps, BigInts and dates. In Node.js, util.inspect shows everything, and depth: null removes the default depth limit that turns nested objects into [Object]:
import { inspect } from 'node:util';
const value = { map: new Map([['a', 1]]), big: 10n, deep: { a: { b: { c: { d: { e: 1 } } } } }, date: new Date('2026-10-01T12:00:00Z') };
console.log(value); // console.log stops at depth 2
console.log(inspect(value, { depth: null, compact: false, sorted: true }));{
map: Map(1) { 'a' => 1 },
big: 10n,
deep: { a: { b: [Object] } },
date: 2026-10-01T12:00:00.000Z
}
{
big: 10n,
date: 2026-10-01T12:00:00.000Z,
deep: {
a: {
b: {
c: {
d: {
e: 1
}
}
}
}
},
map: Map(1) {
'a' => 1
}
}console.dir(value, { depth: null }) is the short form.
Pretty-print a JSON file from the command line
No jq installed? Node can do it:
$ node -e "process.stdout.write(JSON.stringify(JSON.parse(require('fs').readFileSync(0, 'utf8')), null, 2) + '\n')" < data.json
{
"name": "codewithnodejs",
"tags": [
"node",
"json"
],
"nested": {
"ok": true
}
}