JSON.parse BigInt: Keep Large Integers Exact

JSON.parse turns every JSON number into a JavaScript Number, and any integer above 9,007,199,254,740,991 gets rounded before your code sees it. To keep it exact, pass a reviver that reads the third argument, context.source, and returns BigInt(context.source). To write it back out, return JSON.rawJSON(value.toString()) from a JSON.stringify replacer.

Both pieces ship in Chrome 114, Firefox 135, Safari 18.4, Node.js 21, Deno 1.33 and Bun 1.1.43, per MDN’s compatibility data. MDN marks JSON.rawJSON Baseline 2025, newly available since March 2025. The TC39 proposal is at Stage 4 and listed for ES2026.

Why does JSON.parse change large numbers?

JSON itself has no size limit on numbers. JavaScript does. A Number is a 64-bit float, so integers are only exact up to Number.MAX_SAFE_INTEGER, which is 2^53 - 1. Anything bigger lands on the nearest representable double.

JSON.parse('{"id":12345678901234567890}').id;
// 12345678901234567000

That trailing 000 is the bug. It shows up when a 64-bit integer from a Java, Go, Rust or .NET service, or a bigint primary key, is serialised as a bare JSON number. Nothing throws. The rounded value is still a valid number, so a lookup with it quietly finds a different record or none at all.

The old reviver could not fix it. MDN’s JSON.parse page is blunt about the order of events: the reviver runs after the value is parsed, so a large number “may lose precision in the process”. By the time your function was called, the original digits were gone. The workarounds were to ask the API owner to send strings, or to swap the built-in parser for a library such as lossless-json.

How does context.source work?

The reviver now gets a third argument. MDN describes it as “a new object for each invocation”, holding a source property with the original JSON text of the value.

Two details matter. source only exists for primitives (numbers, strings, booleans, null), never for objects or arrays, so check for undefined. And it is the raw text, quotes included:

JSON.parse('{"s":"x","f":0.1}', (key, value, context) => {
  if (key) console.log(key, context);
  return value;
});
// s { source: '"x"' }
// f { source: '0.1' }

The proposal README puts it as source text “inclusive of punctuation but exclusive of leading/trailing insignificant whitespace”.

A JSON.parse BigInt reviver for unsafe integers only

The proposal’s own example turns every all-digit number into a BigInt. That works, but it hands you 1n where you expected 1, and BigInt does not mix with Number in arithmetic (1n + 1 throws a TypeError). A narrower rule is kinder to the rest of the codebase: only convert integers that Number could not hold exactly.

const INTEGER = /^-?\d+$/;

export function reviveBigInts(
  _key: string,
  value: unknown,
  context: JSONReviverContext,
): unknown {
  if (
    typeof value === "number" &&
    !Number.isSafeInteger(value) &&
    context.source !== undefined &&
    INTEGER.test(context.source)
  ) {
    return BigInt(context.source);
  }
  return value;
}

export function replaceBigInts(_key: string, value: unknown): unknown {
  return typeof value === "bigint" ? JSON.rawJSON(value.toString()) : value;
}

Small integers and decimals pass through untouched. A number such as 1e400 still becomes Infinity, because its source is not a plain integer; if your payloads carry exponents or long decimals, extend the rule or keep those as strings rather than guessing.

Round-tripping a payload with both helpers gives back the exact input:

import { reviveBigInts, replaceBigInts } from "./lossless-json.ts";

const text =
  '{"orderId":9007199254740993,"total":42.5,"lines":[{"sku":"A1","variantId":-12345678901234567890}]}';

const order = JSON.parse(text, reviveBigInts);
console.log(order.orderId); // 9007199254740993n
console.log(order.total); // 42.5
console.log(JSON.stringify(order, replaceBigInts) === text); // true

How do you JSON.stringify a BigInt?

Without help, you don’t. JSON.stringify({ a: 1n }) throws, and in V8 the message is TypeError: Do not know how to serialize a BigInt.

The MDN BigInt page offers two older answers: patch BigInt.prototype.toJSON, or use a replacer that emits { "$bigint": "..." }. Both produce a string or an object on the wire, so the receiver has to know your convention.

JSON.rawJSON writes an actual JSON number. It takes a string of JSON text and returns a frozen, null-prototype object that JSON.stringify inlines verbatim. That is what replaceBigInts above does. JSON.isRawJSON tells you whether a value is one of these objects.

MDN’s rawJSON page lists the limit: the string “must be valid JSON representing a primitive value”. Pass it "[1]" or an object and you get a SyntaxError. You cannot use it to splice a pre-rendered JSON fragment into a larger document.

Reading 64-bit integers from node:sqlite

The same problem shows up on the database side. Read a value above 2^53 from node:sqlite with default settings and Node throws ERR_OUT_OF_RANGE (“Value is too large to be represented as a JavaScript number”). Turn on setReadBigInts(true) and you get a BigInt, which then needs the replacer before it can go out as JSON. (Our node:sqlite and better-sqlite3 comparison covers the rest of that API.)

import { DatabaseSync } from "node:sqlite";
import { replaceBigInts } from "./lossless-json.ts";

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE orders (id INTEGER PRIMARY KEY)");
db.prepare("INSERT INTO orders (id) VALUES (?)").run(9007199254740993n);

const select = db.prepare("SELECT id FROM orders");
select.setReadBigInts(true);
const row = select.get(); // { id: 9007199254740993n }

console.log(JSON.stringify(row, replaceBigInts)); // {"id":9007199254740993}

On Node 24.12 this also prints the ExperimentalWarning that node:sqlite still carries.

What about fetch and TypeScript?

Response.json() takes no arguments (MDN), so there is nowhere to pass a reviver. Read the body as text and parse it yourself:

import { reviveBigInts } from "./lossless-json.ts";

export async function getJsonLossless(url: string): Promise<unknown> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
  return JSON.parse(await res.text(), reviveBigInts);
}

TypeScript is behind the runtimes. As of TypeScript 7.0.2, the standard library still types the reviver as (this: any, key: string, value: any) => any and has no rawJSON at all. Pass a three-argument reviver and the compiler refuses:

error TS2345: Argument of type '(_key: string, value: unknown, context: JSONReviverContext) => unknown' is not assignable to parameter of type '(this: any, key: string, value: any) => any'.
  Target signature provides too few arguments. Expected 3 or more, but got 2.

A global declaration file fixes it until the lib catches up. Keep it free of imports and exports so it merges into the built-in JSON interface:

// json-source.d.ts: no imports or exports, so this merges into the global JSON
interface JSONReviverContext {
  source?: string;
}

interface JSONRawJSON {
  readonly rawJSON: string;
}

interface JSON {
  parse(
    text: string,
    reviver: (this: any, key: string, value: any, context: JSONReviverContext) => any,
  ): any;
  rawJSON(text: string): JSONRawJSON;
  isRawJSON(value: unknown): value is JSONRawJSON;
}

Delete it when the official types land, or the two declarations will sit side by side as overloads.

Should the API send IDs as strings instead?

If you own the API, yes. A string ID comes through any JSON parser intact, and nobody downstream needs to know about context.source. IDs are labels; you never add two of them together, so a Number was never the right type.

Use source access when you consume numbers you cannot change: a partner’s int64 order IDs, a ledger service that sends amounts in minor units past 2^53, an old endpoint whose clients you cannot break. Keep the reviver narrow, as above, so the BigInt values stay confined to the fields that need them. If you still support runtimes older than the versions listed at the top, a parser library is the safer choice. The third argument is simply missing there, so the reviver above throws TypeError: Cannot read properties of undefined the first time it meets an unsafe integer.

Whoooop builds Node.js APIs and the integrations that consume them, which is where 64-bit IDs cross from another language’s integer type into JavaScript’s. Our Node.js development work covers both sides: the service that emits the JSON and the client that has to read it.

Need this built properly?

Get in touch