skip to content

What does JSON.stringify() do when the value it is serializing has a toJSON() method, and what is the practical consequence for a Date instance?

level: middleimportance: should knowfreq 52%

answer

  1. the value gets to serialize itself
  2. a method named toJSON, if callable
  3. it is called with the property key
  4. Date returns its ISO string
  5. invalid dates come out as null

basics

~10 s

If a value has a callable toJSON method, JSON.stringify serializes whatever that method returns instead of the value itself. Date uses this hook, so a Date becomes an ISO 8601 string.

solid answer

~50 s

Before serializing a value, `JSON.stringify` checks it for a callable `toJSON` property. If there is one, it calls it and serializes the **returned** value in place of the original — the object's own properties are never looked at. The method is invoked with one argument, the key this value sits under (the array index for elements, and the empty string for the top-level value). `Date.prototype.toJSON` is the built-in example: it returns `this.toISOString()`, which is why a `Date` serializes to a quoted ISO 8601 string rather than to an empty object, and why an invalid `Date` serializes to `null`. Your own classes can implement `toJSON` to control their wire shape — expose a stable subset, hide internal caches, flatten a wrapper into a primitive. The round trip stays asymmetric, though: parsing gives you back the plain string or object that `toJSON` produced, not an instance, so restoring a type needs an explicit revival step.

code

javascript · 27 lines
javascript
class Money {
  constructor(cents, currency) {
    this.cents = cents;
    this.currency = currency;
    this.formatterCache = null; // internal, must not ship
  }
  toJSON() {
    return { amount: this.cents / 100, currency: this.currency };
  }
}

console.log(JSON.stringify({ price: new Money(1250, 'EUR') }));
// {"price":{"amount":12.5,"currency":"EUR"}}

const when = new Date(Date.UTC(2026, 0, 1));
console.log(JSON.stringify({ when }));
// {"when":"2026-01-01T00:00:00.000Z"}

// Serialization is one-way: the hook has no counterpart on parse.
console.log(typeof JSON.parse(JSON.stringify({ when })).when); // string

// An invalid Date returns null from toJSON instead of throwing.
console.log(JSON.stringify(new Date(NaN)));                    // null

// A non-callable toJSON is ignored entirely.
console.log(JSON.stringify({ toJSON: 'nope', a: 1 }));
// {"toJSON":"nope","a":1}

go deeper

for a junior

Know that a Date turns into a quoted ISO 8601 string when serialized, and that this happens because Date supplies its own toJSON method rather than because stringify special-cases dates.

for a middle

Explain the mechanism: stringify looks for a callable toJSON, calls it with the key, and serializes the return value instead of the object, so the original's own properties never reach the output.

for a senior

Demonstrate the production judgment — adding toJSON to a widely used type changes every payload and every debug dump in the process, and the round trip stays asymmetric, so plan the revival step rather than assuming types survive.

for a principal

Own the policy: decide whether wire shapes are defined by the types themselves through toJSON or by an explicit mapping layer, and be able to argue which keeps the serialized contract stable as internal models change.

## The hook When `JSON.stringify` is about to serialize a value, its very first step is to look for a property named `toJSON` on that value. If the property exists and is callable, stringify invokes it and then serializes the **return value** instead. The original object is not inspected at all — its own properties never reach the output unless the returned value contains them. ```js const account = { id: 7, secret: 'do-not-ship', toJSON() { return { id: this.id }; } }; JSON.stringify(account); // '{"id":7}' ``` Three details matter. The property is looked up the ordinary way, so it may live on the prototype — which is exactly how classes use it. It must be **callable**: a `toJSON` property holding a string or a number is simply ignored, and the object serializes normally. And the check applies at every position in the graph, not only at the top, so a nested value with `toJSON` is transformed in place. ## The key argument Stringify calls the method with one argument: the key under which this value sits. For a property it is the property name, for an array element the index as a string, and for the outermost value the empty string `''`. ```js const probe = { toJSON(key) { return `key=${JSON.stringify(key)}`; } }; JSON.stringify(probe); // '"key=\\"\\""' → the empty string JSON.stringify({ a: probe }); // '{"a":"key=\\"a\\""}' JSON.stringify([probe]); // '["key=\\"0\\""]' ``` In practice this is rarely used, but it is a fair interview probe because it shows whether you know the hook is a real spec step with a defined signature rather than a convention some libraries happen to follow. ## Date is the built-in example `Date.prototype.toJSON` exists in the standard library. It returns the result of the date's `toISOString()`, so: ```js JSON.stringify({ when: new Date(Date.UTC(2026, 0, 1)) }); // '{"when":"2026-01-01T00:00:00.000Z"}' ``` Without the hook a `Date` would serialize as `{}` — its data lives in an internal slot, not in enumerable properties — so this is what makes dates usable in JSON at all. The ISO form is always UTC, with the trailing `Z`. There is one branch worth knowing: if the date's numeric value is not finite — an invalid date, such as `new Date('nonsense')` — `toJSON` returns `null` rather than throwing, so `JSON.stringify(new Date(NaN))` yields the text `null`. A silent `null` in a payload where you expected a timestamp is very often an invalid `Date` upstream. ## The asymmetry `toJSON` is a serialization hook only; there is no matching hook on the way back. `JSON.parse` sees a string and produces a string: ```js const text = JSON.stringify({ when: new Date() }); typeof JSON.parse(text).when; // 'string' ``` So a round trip through JSON converts a `Date` to text one way and leaves it as text the other way. Restoring the type requires an explicit revival step that you write — the format itself carries no type information, and this is true of every class, not just `Date`. ## Using it on your own classes Implementing `toJSON` gives a type control over its own wire representation, which is a genuinely useful piece of encapsulation: ```js class Money { #cents; constructor(cents, currency) { this.#cents = cents; this.currency = currency; } toJSON() { return { amount: this.#cents / 100, currency: this.currency }; } } JSON.stringify({ price: new Money(1250, 'EUR') }); // '{"price":{"amount":12.5,"currency":"EUR"}}' ``` This is the standard way to keep private or derived state out of a payload, to flatten a wrapper type down to a primitive, and to keep the serialized shape stable while the internal fields change. The value returned is serialized by the ordinary rules — if it is an object, its properties are walked as usual and any of *those* values with their own `toJSON` are transformed too — but the hook is not re-invoked on the value you just returned, so returning `this` from `toJSON` does not recurse forever; it serializes the object normally. ## The traps The hook is global to serialization: once a type has `toJSON`, **every** `JSON.stringify` call anywhere in the process sees the reduced shape. That is the point, but it bites when a debugging dump or a log line quietly loses the field you were trying to inspect. Adding `toJSON` to a widely-shared type is a contract change, not a local convenience. A related surprise is that the hook fires even when the value is nested deep inside data you did not author — a `Date` sitting anywhere in a large structure becomes a string. And because `toJSON` runs before any replacer, a replacer inspecting the value sees the transformed result, not the original object.

  • What argument does JSON.stringify pass to toJSON, and what is it for?
    It passes the key the value sits under: the property name for an object property, the index as a string for an array element, and the empty string for the outermost value. It lets one method serialize differently depending on where the value appears — rarely needed, but it is a defined part of the hook's signature rather than an accident.
  • Why does a Date not come back as a Date after a stringify/parse round trip?
    Because `toJSON` is a serialization hook with no counterpart on the way in. Stringify turns the date into an ISO string, and parse sees a JSON string and produces a JavaScript string — the text carries no type tag telling the parser to rebuild a Date. Restoring the type requires an explicit revival step you write yourself.
  • What does JSON.stringify produce for a Date whose value is invalid?
    The text `null`. `Date.prototype.toJSON` checks whether the date's numeric value is finite and returns `null` when it is not, rather than calling `toISOString()` — which would throw a RangeError. So an unexpected `null` where a timestamp belongs usually means an invalid Date was constructed further upstream.
  • If toJSON returns an object, is toJSON called again on that object?
    No. The hook fires once per value position: stringify takes what you returned and serializes it by the ordinary rules without re-invoking the hook on it, which is why returning `this` does not recurse forever. The returned object's own properties are still walked normally, so nested values with their own `toJSON` are transformed.

saying these in an interview costs you the question

  • Thinks a Date serializes to an empty object or a timestamp number
  • Believes toJSON is also consulted when parsing
  • Assumes toJSON must be an own property, not inherited
  • Says an invalid Date throws during stringify
  • Adds toJSON to a shared type without treating it as a contract change

context