skip to content

For a JavaScript codebase handling values that exceed the safe integer range, how do you decide between representing them as BigInt, as opaque strings, or keeping them as numbers, and what does each choice cost across the system?

level: principalimportance: nice to knowfreq 25%

answer

  1. start from what the value is for
  2. identity versus magnitude
  3. arbitrary precision, not fixed width
  4. serialization is where BigInt bites
  5. make the bound checkable, not assumed

basics

~20 s

The deciding question is whether you do arithmetic on the value. Opaque strings suit identifiers you only compare and echo; BigInt suits genuine exact arithmetic and costs you JSON serialization, Math support and mixing with numbers; plain numbers stay only where values provably stay under 2^53.

solid answer

~50 s

Start from what the value is for. Identifiers, account numbers and cursors are compared and echoed, never summed — those become opaque strings, which round-trip through JSON exactly and impose no arithmetic rules on anyone. `BigInt` earns its place when you need exact arithmetic beyond 2^53, but it is invasive: `JSON.stringify` throws on it, every `Math.*` call throws, mixed arithmetic with a `number` throws, `typeof` reports `'bigint'` so existing guards need updating, and it is arbitrary-precision rather than fixed-width, so it does not model wraparound (use `BigInt64Array` if you need 64-bit wrapping). Plain numbers are fine where a bound is provable — millisecond timestamps, small counters — and a `Number.isSafeInteger` assertion at the boundary makes the assumption enforceable rather than hopeful. The second-order cost of BigInt is at the seams: every library, ORM and serializer on the path has to tolerate it.

code

javascript · 14 lines
javascript
const record = { id: 9007199254740993n, name: 'row' };

try {
  JSON.stringify(record);
} catch (e) {
  console.log(e.constructor.name); // TypeError
}

// A replacer makes the BigInt survive as text
const wire = JSON.stringify(record, (key, value) =>
  typeof value === 'bigint' ? value.toString() : value
);
console.log(wire); // {"id":"9007199254740993","name":"row"}
console.log(BigInt(JSON.parse(wire).id) === record.id); // true

go deeper

for a junior

Recall the three options and the one question that separates them: values you only compare and echo can be strings, while values you compute with need a numeric type that holds them exactly.

for a middle

Explain BigInt's concrete costs — JSON.stringify throwing, Math functions throwing, no mixing with numbers, a different typeof — so the choice is argued from mechanics rather than preference.

for a senior

Show the boundary discipline: one conversion point per direction, an enforceable Number.isSafeInteger assertion where data enters, and awareness that every library on the serialization path has to tolerate whatever you pick.

for a principal

Own the system-wide call and its migration: which fields change representation, how versioning and rollout work for existing consumers, what the lint or type-level defence is, and why arbitrary precision is not the same guarantee as fixed-width arithmetic.

## Frame the decision, not the type The three candidates are not interchangeable implementations of one idea. They answer different questions: - **Opaque string** — "this value has identity, not magnitude." - **`BigInt`** — "this value has magnitude, and I need exact arithmetic on it beyond 2^53." - **`number`** — "this value has magnitude and a provable bound below 2^53." Most values that push past the safe range in real systems are identifiers, and identifiers are in the first category. That is why the string answer wins more often than engineers expect. ## Opaque strings A string round-trips through `JSON.stringify`/`JSON.parse` with no loss, needs no special handling at any boundary, and every library on the path already accepts one. Equality, `Map` keys, `Set` membership and sorting-by-key all work. The costs are real but small. Comparison is lexicographic, so `'10'` sorts before `'9'` — if you need numeric ordering you must carry a separate sortable field or zero-pad. Nothing prevents a teammate writing `Number(id)` to "just sort it", which reintroduces the bug; a lint rule or a branded type in the type layer is the practical defence. And arithmetic is genuinely unavailable, which is the point: if you find yourself wanting it, the value was not an identifier after all. ## BigInt `BigInt` (ES2020) gives you exact integers of unbounded size. Choose it when the arithmetic is the requirement — cryptographic and hashing work, exact accumulation of very large counts, decomposing a composite 64-bit key into its bit fields. The costs compound at the seams: ```js JSON.stringify({ id: 1n }); // TypeError: Do not know how to serialize a BigInt Math.abs(-5n); // TypeError: Cannot convert a BigInt value to a number 1n + 1; // TypeError: Cannot mix BigInt and other types typeof 1n; // "bigint" — existing typeof guards do not match ``` So adopting it means: a replacer or `toJSON` on every serialization path, explicit conversions at every boundary with number-only code, hand-written replacements for the `Math` helpers you lose, and an audit of runtime type checks. It is also slower and heap-allocated compared with a double, which matters in hot loops but almost never in the request-handling code where identifiers live. One misconception worth naming explicitly: `BigInt` is *arbitrary precision*, not a fixed-width 64-bit integer. `2n ** 64n` is a perfectly good value and nothing wraps around. If you actually need fixed-width semantics — matching a producer that wraps at 64 bits — `BigInt64Array` and `BigUint64Array` provide wrapping elements; a bare `BigInt` does not. ## Keeping plain numbers Sometimes the right answer is to leave it alone. Millisecond epoch timestamps stay safe for hundreds of thousands of years; a per-tenant row counter will not approach 9 quadrillion. What separates engineering from wishful thinking here is making the bound *checkable*: ```js function toSafeCount(v) { if (!Number.isSafeInteger(v)) { throw new RangeError(`${v} exceeded the safe integer range`); } return v; } ``` That single assertion at the ingestion boundary converts an assumption into an alarm. Note the two variants that are *not* safe by default: nanosecond timestamps cross 2^53 immediately, and any identifier minted by a distributed generator is deliberately large. ## How to run the decision 1. **Does anything perform arithmetic on the value?** If no — string, and stop. 2. **If yes, is the magnitude provably below 2^53 forever?** If yes — number plus a boundary assertion. 3. **Otherwise, is exactness required, or would rounding be tolerable?** Exact means BigInt; tolerable means a documented number with the assertion. 4. **If BigInt: what is on the serialization path?** Every hop that must survive it — your own code, the HTTP layer, storage, logging, any structured-clone boundary — gets a stated conversion. `structuredClone` does handle BigInt; `JSON.stringify` does not. ## Mixed representations, deliberately A mature answer often ends up mixed and that is fine, provided the boundary is explicit: identifiers are strings in the domain model, converted to `BigInt` only inside the one module that does bit-field extraction, and never converted to `number` anywhere. The failure mode to avoid is the *implicit* mix, where the same field is a string in one layer and a number in another and nobody owns the conversion. ## What an interviewer is listening for That you ask what the value is used for before picking a type; that you can name BigInt's concrete costs rather than calling it "the fix for big numbers"; that you know it is arbitrary precision rather than a 64-bit int; and that whichever way you go, the assumption is enforced by a check at the boundary rather than left in a comment.

  • A teammate proposes BigInt so the client can model a producer's 64-bit wraparound exactly. What do you tell them?
    That BigInt does not wrap. It is arbitrary precision, so a value that would overflow 64 bits on the producer simply grows here, and the two sides diverge instead of agreeing. If fixed-width semantics are the requirement, use `BigInt64Array` or `BigUint64Array`, whose elements wrap at 64 bits, or apply an explicit mask yourself. Otherwise reconsider whether wraparound needs modelling on the client at all.
  • Where would you put the conversion boundary if identifiers are strings in the domain but BigInt inside one algorithm?
    At the module edge, in one direction each way, with no BigInt leaking outward. The module accepts strings, converts with `BigInt(str)` on entry, does its arithmetic, and returns `String(result)` — so serialization, logging, storage and every other consumer keep seeing a string. A single conversion point is also the only place that needs a test for malformed input, since `BigInt('abc')` throws a SyntaxError.
  • How do you stop a codebase from quietly reintroducing numbers for values you decided to keep as strings?
    Make the wrong thing visible and the right thing structural: assert `Number.isSafeInteger` in the one validation layer that parsed payloads pass through, keep identifiers as a distinct nominal type in the type layer so `Number(id)` does not typecheck silently, and add a contract test feeding an oversized identifier that expects a loud failure rather than a rounded value.

saying these in an interview costs you the question

  • Calls BigInt a drop-in 64-bit integer with wraparound
  • Assumes JSON.stringify handles BigInt automatically
  • Picks a representation before asking about arithmetic
  • Treats string identifiers as numerically sortable
  • Keeps numbers without any boundary assertion

context