Preserve Large JSON IDs with Decimal Strings in JavaScript

A player profile loads successfully, but opening that player’s details returns the wrong record. The API sent a large integer ID, and JavaScript rounded it while parsing the response. Converting the parsed value to a string afterward preserves the rounded digits.

For identifiers that can exceed JavaScript’s safe integer range, define a string field in the API contract and keep it as a string through parsing, lookup, and serialization. This example uses positive unsigned 64-bit player IDs; adapt the validation rules to your own identifier format.

Reproduce the Precision Loss

Run this example in a JavaScript console or Node.js:

1
2
3
4
5
const parsed = JSON.parse('{"playerId":9007199254740993}');

console.log(parsed.playerId);                  // 9007199254740992
console.log(String(parsed.playerId));          // "9007199254740992"
console.log(Number.isSafeInteger(parsed.playerId)); // false

The maximum safe integer is 2 ** 53 - 1, or 9007199254740991. Beyond that range, different mathematical integers can map to the same JavaScript Number. Some larger integers remain exactly representable, but consecutive integers are no longer reliably distinguishable. See the ECMAScript definition of Number.MAX_SAFE_INTEGER .

An ordinary JSON.parse call produces Number values for JSON numbers. Calling String, toFixed, or BigInt on an already rounded value cannot recover the original digits. A two-argument reviver that receives only the parsed value has the same limitation. Runtimes supporting the reviver’s context.source can expose the original token, as documented in the JSON.parse reference ; a quoted string contract avoids depending on that feature.

Send the Identifier as a JSON String

Have the server send this shape:

1
2
3
4
{
  "playerId": "9007199254740993",
  "displayName": "Archer"
}

The quotes are part of the JSON representation. They must be present before the browser parses the response, including when using response.json(). The backend must serialize from its exact integer or string representation; converting an already rounded floating-point value to text on the server is too late as well.

Use the same string representation for small and large IDs. A field that alternates between a number and a string creates unnecessary branches and inconsistent map keys. In TypeScript, declare this API field as string, then validate incoming data at runtime; a type annotation does not change how JSON is parsed.

This contract applies specifically to identifiers. Counts, coordinates, and other numeric fields need their own range and arithmetic rules.

Validate the ID at the Application Boundary

For this example, valid player IDs range from 1 through 18446744073709551615, the maximum unsigned 64-bit integer. The canonical form contains ASCII decimal digits, has no leading zeros, and allows no sign or whitespace.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function requirePlayerId(value) {
  const maximum = "18446744073709551615";
  if (
    typeof value !== "string" ||
    value.length === 0 ||
    value.length > maximum.length ||
    value[0] === "0" ||
    /[^0-9]/.test(value) ||
    (value.length === maximum.length && value > maximum)
  ) {
    throw new TypeError("playerId must be a canonical positive uint64 string");
  }
  return value;
}

function parsePlayerId(jsonText) {
  const player = JSON.parse(jsonText);
  if (player === null || typeof player !== "object" || Array.isArray(player)) {
    throw new TypeError("Expected a player object");
  }
  return requirePlayerId(player.playerId);
}

The range check uses string length first. For equally long strings containing only ASCII digits, lexicographic order matches numeric order. No floating-point conversion is needed. Rejecting numeric input also catches a server regression even when a particular small ID would still be safe.

Do not silently trim, call parseInt, or remove leading zeros at this boundary. Those operations would accept values outside the chosen contract. If another system treats "0012" as a distinct identifier, define a different validator and preserve those zeros.

Keep Lookups and Outgoing JSON Exact

Use the validated string consistently:

1
2
3
4
5
6
7
const playerId = parsePlayerId('{"playerId":"9007199254740993"}');
const playersById = new Map();
playersById.set(playerId, { displayName: "Archer" });

console.log(playersById.get(playerId).displayName); // "Archer"
console.log(JSON.stringify({ playerId }));
// {"playerId":"9007199254740993"}

Avoid Number(playerId), unary +, and numeric coercion in shared helpers. A Map distinguishes string keys from number keys, so mixing representations can also cause a lookup to fail without any rounding.

If an integration requires a BigInt, construct it directly from the validated string and convert it back explicitly at the JSON boundary:

1
2
3
const exactId = BigInt(requirePlayerId("9007199254740993"));
const body = JSON.stringify({ playerId: exactId.toString() });
console.log(body); // {"playerId":"9007199254740993"}

By default, serializing a BigInt directly with JSON.stringify throws a TypeError; see the BigInt JSON guidance . Explicit field conversion keeps this API contract visible. Most ID lookups need no integer conversion at all.

Test the Boundary and the Round Trip

Save the two validation functions above in a Node.js module, then append these assertions and run it with node:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import assert from "node:assert/strict";

for (const id of ["1", "9007199254740991", "9007199254740993", "18446744073709551615"]) {
  const json = JSON.stringify({ playerId: id });
  assert.equal(parsePlayerId(json), id);
  assert.equal(BigInt(id).toString(), id);
}

for (const id of [0, 12, null, "", "0", "01", "-1", "+1", "1.0", "1e3", " 1", "1\n", "18446744073709551616"]) {
  assert.throws(() => parsePlayerId(JSON.stringify({ playerId: id })), TypeError);
}

assert.throws(() => parsePlayerId('{"playerId":9007199254740993}'), TypeError);
assert.throws(() => parsePlayerId("{}"), TypeError);
assert.throws(() => parsePlayerId("null"), TypeError);
assert.throws(() => parsePlayerId("[]"), TypeError);
assert.throws(() => parsePlayerId("{"), SyntaxError);

console.log("Player ID boundary checks passed.");

Use an .mjs extension so Node.js treats the file as an ES module. Include a real server response in integration tests as well: a client-only round trip cannot prove that the backend emits quoted, exact IDs. Inspect the raw response body when diagnosing a mismatch, then compare the ID at parsing, cache lookup, and outgoing request boundaries.