Skip to content

BigInt — integers with no fixed width, and no mixing with numbers

Level: 201 · for anyone whose integers outgrew 2^53

One line: 2n ** 64n is exact and -7n / 2n is -3n, because division truncates toward zero, but 1n + 1 throws a TypeError: arithmetic never mixes BigInt with Number, while comparison does and compares exact values, so 1n == 1 is true and 2n ** 53n + 1n == 2 ** 53 + 1 is false; a BigInt grows as needed up to V8's ceiling of about 2^30 bits.

Keywords: BigInt, BigInt.asIntN, BigInt.asUintN

A BigInt literal is an integer with an n after it. The first program puts some BigInt arithmetic beside the same arithmetic on Numbers.

bigint_arithmetic_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// The same arithmetic on BigInts and on Numbers, side by side. A BigInt prints
// through a template with its "n" put back; a thrown error prints as
// name: message.
const show = (compute) => {
  try {
    const value = compute();
    return typeof value === "bigint" ? `${value}n` : String(value);
  } catch (error) {
    return `${error.name}: ${error.message}`;
  }
};

const rows = [
  ["2n ** 64n", () => 2n ** 64n, "2 ** 64", () => 2 ** 64],
  ["2n ** 53n + 1n", () => 2n ** 53n + 1n, "2 ** 53 + 1", () => 2 ** 53 + 1],
  ["7n / 2n", () => 7n / 2n, "7 / 2", () => 7 / 2],
  ["-7n / 2n", () => -7n / 2n, "-7 / 2", () => -7 / 2],
  ["-7n % 2n", () => -7n % 2n, "-7 % 2", () => -7 % 2],
  ["1n / 0n", () => 1n / 0n, "1 / 0", () => 1 / 0],
];

console.log(`${"BigInt".padEnd(16)}${"result".padEnd(30)}${"Number".padEnd(13)}result`);
for (const [bigCode, big, numberCode, number] of rows) {
  console.log(`${bigCode.padEnd(16)}${show(big).padEnd(30)}${numberCode.padEnd(13)}${show(number)}`);
}

Verified output of bigint_arithmetic_js.js — regenerated by tools/run_examples.py, never hand-typed.

BigInt          result                        Number       result
2n ** 64n       18446744073709551616n         2 ** 64      18446744073709552000
2n ** 53n + 1n  9007199254740993n             2 ** 53 + 1  9007199254740992
7n / 2n         3n                            7 / 2        3.5
-7n / 2n        -3n                           -7 / 2       -3.5
-7n % 2n        -1n                           -7 % 2       -1
1n / 0n         RangeError: Division by zero  1 / 0        Infinity

Reading the output

  • 2n ** 64n prints all 20 digits of 2^64. 2 ** 64 prints 18446744073709552000, although a double holds a power of two exactly (BigInt(2 ** 64), in the conversions below, gets every digit back): Number's toString writes the fewest digits that identify the double, and pads the rest with zeros (ECMA-262 — Number::toString ↗).
  • 2n ** 53n + 1n is exact and 2 ** 53 + 1 is not. Past 2^53 a double cannot hold every integer, and 2^53 + 1 rounds to 2^53 (Safe integers). A BigInt has no fixed width to run out of: ECMA-262 says a BigInt may be any size (The BigInt Type ↗), and V8's ceiling is the subject of the last section.
  • / truncates toward zero. 7n / 2n is 3n and -7n / 2n is -3n: the fraction is dropped, and a floor would have given −4. ECMA-262 defines BigInt division as the quotient truncated (BigInt::divide ↗).
  • % takes the sign of the dividend, so -7n % 2n is -1n, as -7 % 2 is -1 for Numbers (BigInt::remainder ↗).
  • Dividing by zero throws a RangeError, where Number division gives Infinity.

Which operations throw when a BigInt meets a Number?

Eleven expressions put a BigInt beside a Number, or hand a BigInt to code that expects a Number.

bigint_mixing_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// A BigInt meets a Number, or code that expects a Number. A result prints as
// text (a BigInt with its "n"); a thrown error prints as name: message.
const show = (compute) => {
  try {
    const value = compute();
    return typeof value === "bigint" ? `${value}n` : String(value);
  } catch (error) {
    return `${error.name}: ${error.message}`;
  }
};

const cases = [
  ["1n + 1", () => 1n + 1],
  ["1n * 1.5", () => 1n * 1.5],
  ["Math.max(1n, 2n)", () => Math.max(1n, 2n)],
  ["+1n", () => +1n],
  ["-1n", () => -1n],
  ["1n == 1", () => 1n == 1],
  ["1n === 1", () => 1n === 1],
  ["2n > 1", () => 2n > 1],
  ["1n < 1.5", () => 1n < 1.5],
  ["Number(1n) + 1", () => Number(1n) + 1],
  ["1n + BigInt(1)", () => 1n + BigInt(1)],
];
for (const [code, compute] of cases) console.log(`${code.padEnd(18)}${show(compute)}`);

Verified output of bigint_mixing_js.js — regenerated by tools/run_examples.py, never hand-typed.

1n + 1            TypeError: Cannot mix BigInt and other types, use explicit conversions
1n * 1.5          TypeError: Cannot mix BigInt and other types, use explicit conversions
Math.max(1n, 2n)  TypeError: Cannot convert a BigInt value to a number
+1n               TypeError: Cannot convert a BigInt value to a number
-1n               -1n
1n == 1           true
1n === 1          false
2n > 1            true
1n < 1.5          true
Number(1n) + 1    2
1n + BigInt(1)    2n
  • Arithmetic refuses to mix. 1n + 1 and 1n * 1.5 throw: ECMA-262 converts both operands of +, * and the other arithmetic operators to numeric values, and throws a TypeError when one is a BigInt and the other a Number (ApplyStringOrNumericBinaryOperator ↗). V8's feature article gives the reason: any implicit conversion could lose information, since a BigInt cannot hold a fraction and a Number cannot hold every integer past 2^53 (V8 — BigInt ↗).
  • Code that expects a Number refuses a BigInt. Math.max(1n, 2n) throws although both arguments are BigInts, and so does unary +1n: both convert with ToNumber, which throws for a BigInt (ToNumber ↗, Math.max ↗). The V8 article says unary + was left out so that asm.js code can keep counting on +x to give a Number or throw. Unary - works and gives a BigInt.
  • Comparison mixes. 1n == 1, 2n > 1 and 1n < 1.5 all work, and give the answer the mathematics gives. The V8 article's reason: a comparison returns a boolean, so there is no precision to lose. 1n === 1 is false, because === never matches two values of different types.
  • Converting is how you mix on purpose. Number(1n) + 1 is 2 and 1n + BigInt(1) is 2n: you pick the type of the answer, and with it the limits you accept.

Is 2n ** 53n + 1n == 2 ** 53 + 1 true?

The comparison from the question, then each side of it alone, then the same comparison after converting the BigInt to a Number.

bigint_mixed_comparison_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// The comparison from the question, then each side of it on its own, then the
// same comparison after converting the BigInt to a Number first.
const show = (value) => (typeof value === "bigint" ? `${value}n` : String(value));

const rows = [
  ["2n ** 53n + 1n == 2 ** 53 + 1", 2n ** 53n + 1n == 2 ** 53 + 1],
  ["2n ** 53n + 1n", 2n ** 53n + 1n],
  ["2 ** 53 + 1", 2 ** 53 + 1],
  ["2n ** 53n + 1n > 2 ** 53 + 1", 2n ** 53n + 1n > 2 ** 53 + 1],
  ["2n ** 53n == 2 ** 53 + 1", 2n ** 53n == 2 ** 53 + 1],
  ["Number(2n ** 53n + 1n)", Number(2n ** 53n + 1n)],
  ["Number(2n ** 53n + 1n) == 2 ** 53 + 1", Number(2n ** 53n + 1n) == 2 ** 53 + 1],
];
for (const [code, value] of rows) console.log(`${code.padEnd(39)}${show(value)}`);

Verified output of bigint_mixed_comparison_js.js — regenerated by tools/run_examples.py, never hand-typed.

2n ** 53n + 1n == 2 ** 53 + 1          false
2n ** 53n + 1n                         9007199254740993n
2 ** 53 + 1                            9007199254740992
2n ** 53n + 1n > 2 ** 53 + 1           true
2n ** 53n == 2 ** 53 + 1               true
Number(2n ** 53n + 1n)                 9007199254740992
Number(2n ** 53n + 1n) == 2 ** 53 + 1  true

It is false, and the reason is on lines 2 and 3. The Number side is computed first, in doubles, and 2 ** 53 + 1 has already rounded to 9007199254740992 when the comparison starts. The comparison then converts neither side: between a BigInt and a Number, == and < compare the exact mathematical values (IsLooselyEqual ↗, IsLessThan ↗). 9007199254740993 is not 9007199254740992, so == is false and > is true, and 2n ** 53n equals the rounded Number exactly.

Converting first gives a different answer. Number(2n ** 53n + 1n) rounds to the same double, and then the two sides are equal. So a mixed comparison is exact, but it cannot undo rounding that happened while the Number was being computed.

What BigInt() accepts, and what JSON.stringify refuses

BigInt() on strings and on Numbers, then JSON.stringify on an order that holds a BigInt, with and without a replacer.

bigint_conversions_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// BigInt() on strings and on Numbers, then JSON.stringify on an order that
// holds a BigInt, with and without a replacer. A thrown error prints as
// name: message.
const show = (compute) => {
  try {
    const value = compute();
    return typeof value === "bigint" ? `${value}n` : String(value);
  } catch (error) {
    return `${error.name}: ${error.message}`;
  }
};

const order = { total: 2n ** 64n };
// A replacer that writes every BigInt as a decimal string.
const asText = (key, value) => (typeof value === "bigint" ? String(value) : value);

const cases = [
  ['BigInt("0x1f")', () => BigInt("0x1f")],
  ['BigInt("9007199254740993")', () => BigInt("9007199254740993")],
  ["BigInt(2 ** 53 + 1)", () => BigInt(2 ** 53 + 1)],
  ["BigInt(2 ** 64)", () => BigInt(2 ** 64)],
  ["BigInt(1.5)", () => BigInt(1.5)],
  ['BigInt("1.5")', () => BigInt("1.5")],
  ['BigInt("")', () => BigInt("")],
  ["JSON.stringify(order)", () => JSON.stringify(order)],
  ["JSON.stringify(order, asText)", () => JSON.stringify(order, asText)],
];
for (const [code, compute] of cases) console.log(`${code.padEnd(31)}${show(compute)}`);

Verified output of bigint_conversions_js.js — regenerated by tools/run_examples.py, never hand-typed.

BigInt("0x1f")                 31n
BigInt("9007199254740993")     9007199254740993n
BigInt(2 ** 53 + 1)            9007199254740992n
BigInt(2 ** 64)                18446744073709551616n
BigInt(1.5)                    RangeError: The number 1.5 cannot be converted to a BigInt because it is not an integer
BigInt("1.5")                  SyntaxError: Cannot convert 1.5 to a BigInt
BigInt("")                     0n
JSON.stringify(order)          TypeError: Do not know how to serialize a BigInt
JSON.stringify(order, asText)  {"total":"18446744073709551616"}
  • A string keeps every digit. BigInt("0x1f") reads the hex prefix and gives 31n, and BigInt("9007199254740993") is exact. ECMA-262 accepts a decimal integer with an optional sign, or a 0x, 0o or 0b literal, optionally with white space around it (StringToBigInt ↗).
  • A Number has already rounded. BigInt(2 ** 53 + 1) is 9007199254740992n: the double was rounded before BigInt saw it, and BigInt converts the double it gets exactly. That is also why BigInt(2 ** 64) has all 20 digits: 2^64 is a double. To get a big integer exactly, parse it from a string (Parsing numbers).
  • What is not an integer is refused. BigInt(1.5) throws a RangeError: a Number must be integral (NumberToBigInt ↗). BigInt("1.5") throws a SyntaxError, because 1.5 is not an integer literal.
  • BigInt("") is 0n, not an error: in the same grammar, a string of nothing but optional white space has the value 0 (StringToBigInt ↗).
  • JSON.stringify throws on a BigInt anywhere in its input (SerializeJSONProperty ↗). A replacer that turns each BigInt into a string writes all the digits, and BigInt() reads them back. The same step looks for a toJSON method on a BigInt first, which is another way through, but one that changes JSON.stringify for all code in the program. JSON has the rest of what JSON.stringify refuses.

asIntN and asUintN put a width back

A BigInt never wraps around by itself. BigInt.asIntN(bits, x) and BigInt.asUintN(bits, x) make it wrap, as a fixed-width integer would; the last two rows store values into typed arrays, which have a fixed width of their own.

bigint_fixed_width_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// BigInt.asIntN(bits, x) keeps the low `bits` bits of x and reads them as a
// signed integer; asUintN reads them as unsigned. The last two rows store the
// same values into typed arrays, which have a fixed width of their own.
const rows = [
  ["BigInt.asIntN(8, 127n)", BigInt.asIntN(8, 127n)],
  ["BigInt.asIntN(8, 128n)", BigInt.asIntN(8, 128n)],
  ["BigInt.asIntN(8, 255n)", BigInt.asIntN(8, 255n)],
  ["BigInt.asUintN(8, 256n)", BigInt.asUintN(8, 256n)],
  ["BigInt.asUintN(8, -1n)", BigInt.asUintN(8, -1n)],
  ["BigInt.asUintN(8, 255n << 2n)", BigInt.asUintN(8, 255n << 2n)],
  ["BigInt.asUintN(64, -1n)", BigInt.asUintN(64, -1n)],
  ["BigInt.asIntN(64, 2n ** 63n)", BigInt.asIntN(64, 2n ** 63n)],
  ["Int8Array.of(255)[0]", Int8Array.of(255)[0]],
  ["BigInt64Array.of(2n ** 63n)[0]", BigInt64Array.of(2n ** 63n)[0]],
];
for (const [code, value] of rows) {
  const text = typeof value === "bigint" ? `${value}n` : String(value);
  console.log(`${code.padEnd(32)}${text}`);
}

Verified output of bigint_fixed_width_js.js — regenerated by tools/run_examples.py, never hand-typed.

BigInt.asIntN(8, 127n)          127n
BigInt.asIntN(8, 128n)          -128n
BigInt.asIntN(8, 255n)          -1n
BigInt.asUintN(8, 256n)         0n
BigInt.asUintN(8, -1n)          255n
BigInt.asUintN(8, 255n << 2n)   252n
BigInt.asUintN(64, -1n)         18446744073709551615n
BigInt.asIntN(64, 2n ** 63n)    -9223372036854775808n
Int8Array.of(255)[0]            -1
BigInt64Array.of(2n ** 63n)[0]  -9223372036854775808n

asUintN(bits, x) keeps x modulo 2^bits, and asIntN then reads a result with the top bit set as negative, subtracting 2^bits (ECMA-262 — BigInt.asIntN and BigInt.asUintN ↗). So 127 fits in a signed byte, 128 wraps to -128n, 255 is -1n, 256 is 0n unsigned, and -1n is 255n unsigned. 255n << 2n computes 1020 exactly, and asUintN(8, ...) keeps its low eight bits, 252n: what storing into a byte does. At 64 bits the same rule gives 2^64 − 1 and −2^63, and a typed array agrees: Int8Array.of(255)[0] is -1, as asIntN(8, 255n) is -1n, and BigInt64Array stores 2^63 as −2^63. With these two functions, exact int64 or uint64 arithmetic (a 64-bit hash, say) is BigInt arithmetic followed by one call.

No fixed width does not mean no ceiling

ECMA-262 sets no limit on the size of a BigInt. V8 does. The program builds a BigInt just under 2^30 bits, then asks for one just over.

bigint_ceiling_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// V8 caps the size of a BigInt. `1n << k` has k + 1 bits, so this builds one
// of 2 ** 30 - 64 bits (about 128 MiB of digits) and then asks for one of
// 2 ** 30 + 1 bits. Only the sizes print: a number this long is never printed.
const row = (code, result) => console.log(`${code.padEnd(34)}${result}`);
const k = 2n ** 30n - 65n;

const widest = 1n << k;
row("widest = 1n << (2n ** 30n - 65n)", "built");
row("widest >> (2n ** 30n - 65n)", `${widest >> k}n, so widest has ${k + 1n} bits`);

try {
  const tooWide = 1n << (2n ** 30n);
  row("1n << (2n ** 30n)", `built, ${tooWide > 0n}`);
} catch (error) {
  row("1n << (2n ** 30n)", `${error.name}: ${error.message}`);
}

Verified output of bigint_ceiling_js.js — regenerated by tools/run_examples.py, never hand-typed.

widest = 1n << (2n ** 30n - 65n)  built
widest >> (2n ** 30n - 65n)       1n, so widest has 1073741760 bits
1n << (2n ** 30n)                 RangeError: Maximum BigInt size exceeded

widest was built with 1073741760 bits, about 128 MiB of digits, and shifting it right by one less than that leaves exactly 1n. 1n << (2n ** 30n) would need 2^30 + 1 bits, and V8 throws RangeError: Maximum BigInt size exceeded. So V8's ceiling lies between 2^30 − 64 and 2^30 bits: a little over a billion.

The exact figure is V8's, not the language's, and V8's source calls it "somewhat arbitrarily chosen" (V8 — src/objects/bigint.h ↗). On a 64-bit machine it was exactly 2^30 bits until a change in March 2026 lowered it to 2^30 − 64 (V8 commit 3f5013f ↗), which is why the program measures two sizes that give the same answer on both sides of that change.

In TypeScript, bigint and number do not mix either

The file never runs. tsc checks it and refuses two lines:

bigint_mixing_tserror.ts in full — pasted here by tools/run_examples.py from the file CI runs.

// The mixing rules with types written down. tsc refuses two lines: the product,
// which throws a TypeError at run time, and the ==, which JavaScript answers
// with true. It accepts the < and the explicit conversion on the last line.
function lineTotal(unitPrice: bigint, quantity: number): bigint {
  return unitPrice * quantity;
}

const unitPrice: bigint = 1999n;
console.log(unitPrice == 1999);
console.log(unitPrice < 2000, lineTotal(unitPrice, 3), unitPrice * BigInt(3));

What tsc says about bigint_mixing_tserror.ts — regenerated by tools/run_examples.py, never hand-typed.

bigint_mixing_tserror.ts:5:10 - error TS2365: Operator '*' cannot be applied to types 'bigint' and 'number'.

5   return unitPrice * quantity;
           ~~~~~~~~~~~~~~~~~~~~

bigint_mixing_tserror.ts:9:13 - error TS2367: This comparison appears to be unintentional because the types 'bigint' and 'number' have no overlap.

9 console.log(unitPrice == 1999);
              ~~~~~~~~~~~~~~~~~


Found 2 errors in the same file, starting at: bigint_mixing_tserror.ts:5

TS2365 stops unitPrice * quantity, the kind of product that throws a TypeError at run time, before anything runs. TS2367 stops unitPrice == 1999, which JavaScript would answer with true: tsc treats bigint and number as types with no overlap, so it takes the comparison for a mistake. The last line passes: tsc lets < compare a bigint with a number, and BigInt(3) makes both sides of the product bigint. bigint is one of the primitive types the handbook lists (TypeScript handbook — less common primitives ↗).

What to do

  • Use a BigInt for an integer that can pass 2^53: a 64-bit ID from a database, nanoseconds, a count of cents at scale.
  • Parse big integers from strings, BigInt("9007199254740993"), never through a Number, which rounds first.
  • Convert on purpose where the two types meet: BigInt(n) for a Number that is an integer, Number(big) when the nearest double is good enough.
  • Remember that / truncates toward zero, and that dividing by 0n throws.
  • Compare freely with <, > and ==, which compare exact values, but never expect === to match a BigInt with a Number.
  • Send a BigInt through JSON as a string, with a replacer, and read it back with BigInt().
  • Use BigInt.asIntN and BigInt.asUintN when you need int64 or uint64 wrap-around.
  • In TypeScript, write bigint: tsc stops a mix before it can throw, and refuses == against a number as well.

In other languages

Practice

Kata: the mean of three timestamps

Write mean(values), which takes an array of BigInts and returns their mean as a BigInt, rounded toward zero. Call it on three timestamps in nanoseconds, [1790000000000000000n, 1790000000000000001n, 1790000000000000005n], and on [-7n, 0n]; then work out the first mean again with the timestamps converted to Numbers, so that the program prints exactly this:

Verified output of bigint_kata_js.js — regenerated by tools/run_examples.py, never hand-typed.

mean of the three timestamps: 1790000000000000002n
mean of -7n and 0n: -3n
the same mean in Numbers: 1790000000000000000

Two Numbers hide in the obvious solution, and either one throws a TypeError.

Solution

bigint_kata_js.js in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata solution: the mean of BigInt timestamps, rounded toward zero. Two
// Numbers hide in the obvious version -- reduce's starting 0 and
// values.length -- and either one would throw a TypeError, so both are BigInts.
function mean(values) {
  const total = values.reduce((sum, value) => sum + value, 0n);
  return total / BigInt(values.length);
}

const timestamps = [1790000000000000000n, 1790000000000000001n, 1790000000000000005n];
console.log(`mean of the three timestamps: ${mean(timestamps)}n`);
console.log(`mean of -7n and 0n: ${mean([-7n, 0n])}n`);

const asNumbers = timestamps.map(Number);
const numberMean = asNumbers.reduce((sum, value) => sum + value, 0) / asNumbers.length;
console.log(`the same mean in Numbers: ${numberMean}`);

Sources

See also