Serialization5 min read

JSON.parse context.source and JSON.rawJSON Preserve Exact Numbers

Use ECMAScript 2026 JSON source access and raw JSON values for lossless BigInt number round trips without changing JSON grammar.

  • JSON.parse
  • JSON.rawJSON
  • BigInt

ECMAScript 2026 gives a JSON.parse reviver access to the original source text for primitive values and adds JSON.rawJSON for inserting a validated primitive token during JSON.stringify. Together they can round-trip integers larger than JavaScript’s safe Number range without quoting them in the JSON document.

The JSON grammar did not change. JSON still has one number syntax and no BigInt literal suffix. The new APIs let application code decide how an exact number token maps to JavaScript and back.

These features are new in ECMAScript 2026. Use a feature check when supporting older engines:

const hasSourceAccess = (() => {
  let source;
  JSON.parse('1', (_key, value, context) => {
    source = context?.source;
    return value;
  });
  return source === '1';
})();

const hasRawJSON = typeof JSON.rawJSON === 'function';
console.log({ hasSourceAccess, hasRawJSON });

Parsing a Number can lose digits before the reviver runs

JSON numbers normally become JavaScript Number values. Integers above Number.MAX_SAFE_INTEGER may be rounded during that conversion. A traditional reviver sees only the rounded value, so converting it to BigInt is too late.

const text = '{"id":9007199254740993}';
const ordinary = JSON.parse(text);

console.log(ordinary.id); // 9007199254740992
console.log(BigInt(ordinary.id) === 9007199254740993n); // false

The ECMAScript 2026 JSON.parse contract passes a third context argument to the reviver. When the value is an unmodified primitive from the parsed text, context.source contains its exact token.

const text = '{"id":9007199254740993,"count":12}';

const parsed = JSON.parse(text, (key, value, context) => {
  if (key === 'id') return BigInt(context.source);
  return value;
});

console.log(parsed.id === 9007199254740993n); // true
console.log(parsed.count === 12);             // true

Use schema knowledge to decide which fields become BigInt. Turning every integer token into BigInt changes ordinary counts, array indices, arithmetic, and APIs that expect Number.

context.source belongs to primitive parse results

Strings, numbers, booleans, and null can receive a source property. Objects and arrays do not because their children already have individual source records.

const seen = [];

JSON.parse('{"label":"A\\u0042","active":true,"items":[1]}',
  (key, value, context) => {
    seen.push([key, context.source]);
    return value;
  }
);

console.log(seen);
// [
//   ['label', '"A\\u0042"'],
//   ['active', 'true'],
//   ['0', '1'],
//   ['items', undefined],
//   ['', undefined]
// ]

Revivers run bottom-up. Children are visited before their containing array or object, then the root arrives with the empty-string key. Returning undefined still deletes a property, and returning any other value replaces it.

The source is available only while the value still corresponds to its original primitive parse result. A reviver can mutate not-yet-visited data through this; if that breaks the link between the stored value and the parse record, the later callback receives no source. Avoid cross-property mutation in a lossless parser.

Validate the token before converting it

context.source is valid JSON source, but it may use decimal fractions or exponents. BigInt accepts integer strings, not every JSON number representation. A field-specific parser should reject a token that violates its schema.

function parseInteger(key, value, context) {
  if (key !== 'balance') return value;
  if (!/^-?(0|[1-9]\d*)$/.test(context.source)) {
    throw new TypeError('balance must be a JSON integer');
  }
  return BigInt(context.source);
}

console.log(JSON.parse('{"balance":-42}', parseInteger).balance); // -42n

try {
  JSON.parse('{"balance":1e3}', parseInteger);
} catch (error) {
  console.log(error instanceof TypeError); // true
}

This validation is about the application’s numeric type, not JSON validity. 1e3 is valid JSON and parses to the Number 1000; it simply does not meet the chosen integer-token contract.

JSON.rawJSON emits a validated primitive token

JSON.stringify normally throws when it encounters a BigInt. A replacer can wrap the decimal string with JSON.rawJSON, and the stringifier emits those digits without surrounding quotes.

function stringifyBigInt(_key, value) {
  return typeof value === 'bigint'
    ? JSON.rawJSON(value.toString())
    : value;
}

const text = JSON.stringify(
  { id: 9007199254740993n, active: true },
  stringifyBigInt
);

console.log(text);
// {"id":9007199254740993,"active":true}

The specified JSON.rawJSON operation parses its input immediately. It accepts only JSON text representing a string, number, boolean, or null. It rejects empty text, arrays, objects, invalid numbers, and extra surrounding whitespace.

for (const invalid of ['', ' 1', '[1]', '{"x":1}', '01']) {
  try {
    JSON.rawJSON(invalid);
  } catch (error) {
    console.log(invalid, error instanceof SyntaxError);
  }
}

The returned object has a null prototype, exposes its source as rawJSON, and is frozen. JSON.isRawJSON(value) is the reliable brand check. Copying its visible property into another object does not create another raw JSON value.

rawJSON is not a general string-splicing escape hatch. It cannot insert a prebuilt object or array, and validation prevents malformed JSON or multiple tokens from reaching the output.

A round trip needs policy at both ends

Source access does not infer whether a number is an ID, a currency amount, a decimal, or an ordinary Number. Raw JSON does not decide whether downstream systems can represent the emitted integer exactly. The producer and consumer still need an agreed schema.

For JavaScript-to-JavaScript exchange, a tagged string such as {"id":"9007199254740993"} may remain more compatible with older runtimes. Raw numeric tokens are useful when the wire schema requires JSON numbers and each consumer has an exact integer type.

Use context.source to recover the token before Number rounding destroys information. Use JSON.rawJSON only after converting an application value to a valid primitive token. That pair preserves syntax, not meaning. The schema supplies the meaning.