{}JSONLint.app

Mastering JSON in JavaScript: An Advanced Guide

9 min read
Share:

JSON.parse and JSON.stringify look like two trivial functions, but they hide revivers, replacers, silent data loss, and a handful of exceptions that surface only in production. This guide is example-first and gotcha-driven: every section shows real output and the trap that catches people. It is JavaScript-primary, with one section contrasting Python and Go for dates and big integers.

Reviving Values with the JSON.parse Reviver

JSON.parse(text, reviver) takes an optional second argument: a function called for every key/value pair as the result is built. Its most common job is turning ISO date strings back into real Date objects, because JSON has no date type.

const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?$/;

function dateReviver(key, value) {
  if (typeof value === "string" && ISO.test(value)) {
    return new Date(value);
  }
  return value;
}

const json = '{"id":7,"createdAt":"2026-06-25T10:30:00Z","tags":["a","b"]}';
const obj = JSON.parse(json, dateReviver);

obj.createdAt instanceof Date; // true
obj.createdAt.getUTCFullYear(); // 2026

The critical detail is order: the reviver runs bottom-up. Leaf values are transformed first, then their containing objects, and the root last. By the time your reviver sees a parent object, its children have already been replaced. The root itself is passed with an empty-string key "" as the final call:

JSON.parse('{"a":{"b":1}}', (key, value) => {
  console.log(JSON.stringify(key), value);
  return value;
});
// "b" 1          <- leaf first
// "a" { b: 1 }   <- then the object containing it
// ""  { a: ... } <- root last

Gotcha: if you return undefined from the reviver, that property is deleted from the result rather than set to undefined. This is a handy way to strip fields, but it bites people who return undefined by accident (for example, forgetting the final return value). Also note this inside the reviver is bound to the object currently holding the key, which lets you inspect siblings if needed.

The JSON.stringify Replacer and toJSON()

JSON.stringify(value, replacer, space) has two forms of replacer. The array form is an allowlist of property names to keep:

const user = { id: 1, name: "Ada", password: "secret", ssn: "000-00-0000" };
JSON.stringify(user, ["id", "name"]);
// '{"id":1,"name":"Ada"}'

The function form is called per key/value (top-down, unlike the reviver) and lets you transform values:

JSON.stringify({ amount: 19.5, secret: "x" }, (key, value) =>
  key === "secret" ? undefined : value
);
// '{"amount":19.5}'

Independently, any object can expose a toJSON() method. JSON.stringify calls it and serializes the return value instead of the object. This is exactly how Date becomes a string:

new Date("2026-06-25T00:00:00Z").toJSON(); // "2026-06-25T00:00:00.000Z"

class Money {
  constructor(cents) { this.cents = cents; }
  toJSON() { return (this.cents / 100).toFixed(2); }
}
JSON.stringify({ price: new Money(1995) }); // '{"price":"19.95"}'

What stringify Silently Drops

This is the section that prevents the most bugs. JSON.stringify does not throw on unsupported values inside structures — it quietly omits or rewrites them, and the rules differ between objects and arrays:

JSON.stringify({
  a: undefined,        // dropped
  b: () => 1,          // function dropped
  c: Symbol("s"),      // symbol dropped
  d: NaN,              // -> null
  e: Infinity,         // -> null
  f: 1,
});
// '{"d":null,"e":null,"f":1}'

JSON.stringify([undefined, () => 1, Symbol("s"), NaN, 3]);
// '[null,null,null,null,3]'

In objects, undefined, functions, and symbols vanish. In arrays, they become null so positions are preserved. NaN and Infinity always become null. Gotcha: a round trip through JSON is therefore lossy — JSON.parse(JSON.stringify(x)) is not an identity function for anything containing dates, undefined, functions, or non-finite numbers.

BigInt Does Not Serialize

JSON.stringify({ id: 10n });
// Uncaught TypeError: Do not know how to serialize a BigInt

BigInt is the one common type that throws rather than being silently dropped. The fix is a replacer/reviver pair that round-trips the value as a tagged object:

function bigIntReplacer(key, value) {
  return typeof value === "bigint" ? { $bigint: value.toString() } : value;
}
function bigIntReviver(key, value) {
  if (value && typeof value === "object" && "$bigint" in value) {
    return BigInt(value.$bigint);
  }
  return value;
}

const text = JSON.stringify({ id: 9007199254740993n }, bigIntReplacer);
// '{"id":{"$bigint":"9007199254740993"}}'
JSON.parse(text, bigIntReviver).id; // 9007199254740993n

Why bother? Because any integer above Number.MAX_SAFE_INTEGER (2^53 − 1) loses precision when stored as a JS number — the value 9007199254740993 silently becomes 9007199254740992. Database IDs, X/Twitter snowflake IDs, and 64-bit counters routinely exceed that boundary. See JSON Numbers and Precision for the full breakdown of why these IDs break and how to keep them as strings end to end.

Circular References

const a = {};
a.self = a;
JSON.stringify(a);
// Uncaught TypeError: Converting circular structure to JSON

A reference cycle makes the serializer throw. The standard fix is a replacer backed by a WeakSet that drops any object it has already seen:

function getCircularReplacer() {
  const seen = new WeakSet();
  return (key, value) => {
    if (typeof value === "object" && value !== null) {
      if (seen.has(value)) return "[Circular]";
      seen.add(value);
    }
    return value;
  };
}

JSON.stringify(a, getCircularReplacer());
// '{"self":"[Circular]"}'

A WeakSet is the right tool here: it holds objects without preventing garbage collection, and it gives O(1) membership checks. Gotcha: create the WeakSet inside a factory and return a fresh replacer each call. If you share one WeakSet across multiple stringify calls, the same object legitimately appearing in two separate serializations would wrongly be reported as circular on the second pass.

Deep Clone: structuredClone() vs the JSON Trick

For years the idiom for a deep copy was JSON.parse(JSON.stringify(x)). Modern runtimes (Node 17+, all current browsers) provide structuredClone(), which is both safer and more capable.

const original = {
  when: new Date("2026-06-25T00:00:00Z"),
  tags: new Set(["a", "b"]),
  lookup: new Map([["k", 1]]),
  bytes: new Uint8Array([1, 2, 3]),
  note: undefined,
};
original.loop = original; // cycle

// JSON trick:
JSON.parse(JSON.stringify(original));
// Throws: Converting circular structure to JSON
//   ...and even without the cycle it would mangle the rest (below)

Compare the two approaches on a non-circular object:

Feature JSON.parse(JSON.stringify(x)) structuredClone(x)
Date becomes a string stays a real Date
Map / Set becomes {} preserved
typed arrays becomes {"0":1,...} preserved
undefined props dropped preserved
functions dropped throws DataCloneError
circular refs throws handled correctly
BigInt throws preserved
const safeCopy = structuredClone(original);
safeCopy.when instanceof Date; // true
safeCopy.lookup instanceof Map; // true
safeCopy.loop === safeCopy; // true (cycle preserved)

Gotcha: structuredClone cannot clone functions or DOM nodes, and it does not preserve class identity — it throws DataCloneError on functions and returns a plain object (losing the prototype) for class instances. Use the JSON trick only when you specifically want a JSON-shaped, function-free, date-as-string snapshot.

Performance and Safety

JSON.parse and JSON.stringify are synchronous and run on the main thread. A multi-megabyte string can block rendering and input for hundreds of milliseconds, freezing the UI. For large payloads, move parsing into a Web Worker (or a Node worker thread) so the main thread stays responsive, or use a streaming parser that yields incrementally instead of buffering the whole document. See Parsing Large JSON Files for streaming and chunking strategies.

// worker.js
self.onmessage = (e) => {
  const data = JSON.parse(e.data); // heavy work off the main thread
  self.postMessage(data);
};

Gotcha — never use eval to parse JSON. It is slower, and any string you parse becomes executable code, which is a remote-code-execution hole the moment the input is untrusted. JSON.parse only ever produces data, never runs it. There is no scenario where eval('(' + text + ')') is the right call.

A Reusable safeParse Helper

JSON.parse throws on malformed input, so unguarded calls are a frequent crash source. A discriminated result type lets callers branch without try/catch at every site:

function safeParse(text, reviver) {
  try {
    return { ok: true, value: JSON.parse(text, reviver) };
  } catch (error) {
    return { ok: false, error: error instanceof Error ? error.message : String(error) };
  }
}

const result = safeParse(req.body);
if (result.ok) {
  use(result.value);
} else {
  console.warn("Bad JSON:", result.error); // e.g. "Unexpected token } in JSON at position 14"
}

The shape { ok: true, value } | { ok: false, error } is a discriminated union: checking result.ok narrows the type so TypeScript knows value exists only on success. For the catalog of messages you might see in error, see JSON Parse Errors.

Cross-Language Contrast: Dates and Big Integers

JavaScript's date-as-string and 2^53 number ceiling are language quirks, not JSON quirks. Other languages hook in differently.

Python parses integers with arbitrary precision natively, so 9007199254740993 survives untouched. Dates still need a hook — object_hook runs per decoded object, the rough equivalent of a reviver:

import json
from datetime import datetime

def date_hook(obj):
    for k, v in obj.items():
        if isinstance(v, str) and v.endswith("Z"):
            try:
                obj[k] = datetime.fromisoformat(v.replace("Z", "+00:00"))
            except ValueError:
                pass
    return obj

data = json.loads('{"id": 9007199254740993, "at": "2026-06-25T10:30:00Z"}', object_hook=date_hook)
print(type(data["id"]), data["id"])  # <class 'int'> 9007199254740993
print(type(data["at"]))              # <class 'datetime.datetime'>

Go decodes JSON numbers into float64 by default — which means it also loses precision above 2^53 unless you opt into json.Decoder.UseNumber(), which keeps them as exact json.Number strings. Dates are handled by implementing UnmarshalJSON on a custom type:

dec := json.NewDecoder(strings.NewReader(`{"id": 9007199254740993}`))
dec.UseNumber()
var m map[string]json.Number
dec.Decode(&m)
id, _ := m["id"].Int64() // 9007199254740993, exact

type Event struct {
    At time.Time `json:"at"` // time.Time implements UnmarshalJSON for RFC 3339
}

Same JSON document in all three languages:

{ "id": 9007199254740993, "at": "2026-06-25T10:30:00Z" }

The takeaway: JSON itself stores numbers and date strings faithfully. Precision and type loss are introduced by the parser — JavaScript and Go default to lossy floats, Python defaults to exact integers, and dates always require explicit revival.

Tools

  • JSON Validator — Catch the malformed input that makes JSON.parse throw before it reaches your code.
  • JSON Stringify — Preview exactly what stringify keeps, drops, and rewrites without writing a script.
  • JSON Formatter — Pretty-print serialized output to inspect dropped fields and nesting at a glance.
  • JSON to TypeScript — Generate types so your parsed safeParse results are statically checked.

Learn More

Related Articles