JSON.stringify() looks simple. You pass it an object, you get a string back. But there are a handful of behaviors that catch people off guard, usually when the data you thought you were logging just shows up as {} or a field goes missing from a POST request.
Here are the ones worth knowing before you run into them in production.
undefined values silently disappear
const obj = {
name: 'Alice',
score: undefined,
active: true,
};
JSON.stringify(obj);
// '{"name":"Alice","active":true}'
score is gone. No error, no warning. Functions and symbols get the same treatment.
const obj = {
name: 'Alice',
greet: () => 'hello',
};
JSON.stringify(obj);
// '{"name":"Alice"}'
This is a common source of "why is this field missing from my POST request" bugs. Your API client serializes the request body with JSON.stringify(), some fields are undefined, and they never make it to the server.
In arrays, undefined becomes null rather than disappearing:
JSON.stringify([1, undefined, 3]);
// '[1,null,3]'
Consistent with the rest? Not really. But that's how it works.
Circular references throw, with an unhelpful error message
const a = {};
a.self = a;
JSON.stringify(a);
// TypeError: Converting circular structure to JSON
The error doesn't tell you where the cycle is. In a large object graph this can take some time to trace.
If you're working with data that might have circular references (ORM model instances, React component trees, anything with backreferences), handle it before stringifying. A replacer function that tracks seen objects is one option:
function safeStringify(obj) {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
});
}
The replacer argument is more useful than people realize
JSON.stringify() takes a second argument: a replacer. It can be an array of keys to include, or a function that transforms values.
Array form -- include only specific keys:
const user = { id: 1, name: 'Alice', password: 'secret', role: 'admin' };
JSON.stringify(user, ['id', 'name']);
// '{"id":1,"name":"Alice"}'
Good for logging or sending to an API when you want to strip certain fields without restructuring the object.
Function form -- transform values or drop keys:
JSON.stringify(data, (key, value) => {
if (key === 'password') return undefined; // drops the key
if (value instanceof Date) return value.toISOString();
return value;
});
Returning undefined from the replacer drops that key from the output. This is how you filter dynamically or convert non-serializable values cleanly.
toJSON() controls how an object serializes
If an object has a toJSON() method, JSON.stringify() calls it and serializes the return value instead of the object itself.
const obj = {
name: 'Alice',
createdAt: new Date('2024-01-15'),
};
JSON.stringify(obj);
// '{"name":"Alice","createdAt":"2024-01-15T00:00:00.000Z"}'
Date has a built-in toJSON() that returns the ISO string. That's why dates serialize as strings without you doing anything special.
You can add toJSON() to your own classes:
class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
toJSON() {
return `${this.amount} ${this.currency}`;
}
}
JSON.stringify({ price: new Money(49.99, 'USD') });
// '{"price":"49.99 USD"}'
NaN and Infinity become null
JSON.stringify({ x: NaN, y: Infinity, z: -Infinity });
// '{"x":null,"y":null,"z":null}'
JSON has no representation for these values, so they silently become null. If you're storing numbers that could be Infinity (from division results, physics calculations, anything unbounded), you can lose data without any error being thrown.
The third argument controls indentation
JSON.stringify(obj, null, 2) produces readable output with 2-space indentation. The third argument can be a number (spaces) or a string (used as the indent character).
JSON.stringify({ a: 1, b: [2, 3] }, null, 2);
/*
{
"a": 1,
"b": [
2,
3
]
}
*/
The null in the second position is the replacer slot. You need to pass it even if you don't want a replacer, since the arguments are positional.
When you need to debug what JSON actually looks like
If you're not sure what an object looks like after a stringify/parse roundtrip, the JSON formatter lets you paste the string and see the structure with readable indentation. The JSON validator will catch syntax errors if you're building JSON strings manually. And if you're on the receiving end with a string that throws on JSON.parse(), the JSON parse error tool shows you exactly where the parser failed.
None of these are obscure. They are all documented behavior. But they are easy to miss until the bug is already in your logs.
Top comments (0)