Numbers
JavaScript has one number type. Every number you write, 1, -3, 0.5, 1e21, is a 64-bit IEEE 754 floating-point value, the type other languages call double. There is no separate integer type. That single fact explains why 0.1 + 0.2 is not 0.3, why integers stop being exact around nine quadrillion, and why bigint had to be added later. This page earns each of those, and ends with the tools for the moments when the one type is not enough.
Create programs/numbers.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import {
assert,
assertAlmostEquals,
assertEquals,
assertFalse,
assertThrows,
} from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
0.1 + 0.2 is not 0.3
Start with the most famous line in JavaScript arithmetic. Predict the sum, then save:
Deno.test("0.1 + 0.2 is not 0.3", () => {
assertEquals(0.1 + 0.2, 0.3);
});
Check programs/numbers.test.ts
running 1 test from ./programs/numbers.test.ts
0.1 + 0.2 is not 0.3 ... FAILED (8ms)
ERRORS
0.1 + 0.2 is not 0.3 => ./programs/numbers.test.ts:10:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- 0.30000000000000004
+ 0.3
FAILURES
0.1 + 0.2 is not 0.3 => ./programs/numbers.test.ts:10:11
FAILED | 0 passed | 1 failed (10ms)
error: Test failed
The diff shows the actual sum: 0.30000000000000004. This is not a bug, and it is not JavaScript's fault. Here is the chain.
- A
numberis stored in binary, and a binary fraction can represent a value exactly only when its denominator is a power of two. 0.1is one tenth, and ten is not a power of two, so the stored value is the nearest representable neighbor of0.1, not0.1itself. The same goes for0.2. Decimal has the identical limitation with one third:0.3333at any length is close, never exact.- Adding two almost-right values produces an almost-right sum, and printing it exposes the error in the sixteenth digit.
Every language that uses IEEE 754 doubles gives this exact answer, including Python, Java, C, and Go. What JavaScript did differently was make that type the only one, so you meet the behavior sooner. Record the true sum, and alongside it the case that stays exact:
Deno.test("0.1 + 0.2 is not 0.3", () => {
assertEquals(0.1 + 0.2, 0.30000000000000004);
assertFalse(0.1 + 0.2 === 0.3);
assertEquals(0.5 + 0.25, 0.75);
});
Check programs/numbers.test.ts
running 1 test from ./programs/numbers.test.ts
0.1 + 0.2 is not 0.3 ... ok (0ms)
ok | 1 passed | 0 failed (1ms)
0.5 + 0.25 is exactly 0.75, because halves and quarters have power-of-two denominators. The rule tells you in advance which decimals are exact, and one tenth is not one of them.
a literal may name its base
A number can be written down in four bases, and long ones can be grouped for whoever reads the source. One of the lines below also walks into a syntax pitfall on purpose; type it exactly and save:
Deno.test("a literal may name its base", () => {
assertEquals(0b1111_0000, 240);
assertEquals(0o755, 493);
assertEquals(0xff, 255);
assertEquals(1_000_000, 1000000);
assert(Number.isNaN(Number("1_000")));
assertEquals(7.toString(2), "111");
});
error: SyntaxError: Identifier cannot follow number
|
22 | assertEquals(7.toString(2), "111");
| ~
at file:///programs/numbers.test.ts:22:20
The file did not even type-check; it failed to parse. The parser read 7. as the start of a decimal number, expected digits, and found the letter t instead. Wrap the literal in parentheses so the dot unambiguously means property access:
Deno.test("a literal may name its base", () => {
assertEquals(0b1111_0000, 240);
assertEquals(0o755, 493);
assertEquals(0xff, 255);
assertEquals(1_000_000, 1000000);
assert(Number.isNaN(Number("1_000")));
assertEquals((7).toString(2), "111");
});
a literal may name its base ... ok (0ms)
Walk the literals.
0b1111_0000is binary for240,0o755is octal for493, and0xffis hexadecimal for255. The prefix names the base; the value is the samenumbertype either way.1_000_000is exactly1000000. The underscores exist for the reader and are not part of the number, which the next line proves:Number("1_000")isNaN, because nothing that parses text at runtime accepts the separators.(7).toString(2)renders seven in binary,"111".7..toString(2)and7.0.toString(2)also parse, because the first dot completes the number, but the parenthesized form is the one to write.
computed decimals are compared with a tolerance
If arithmetic accumulates tiny errors, exact equality is the wrong question to ask about a computed decimal. Two right questions exist, and @std/assert ships a helper for the first: assertAlmostEquals passes when two numbers are within a small tolerance of each other.
Deno.test("computed decimals are compared with a tolerance", () => {
assertAlmostEquals(0.1 + 0.2, 0.3);
assert(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON);
});
computed decimals are compared with a tolerance ... ok (0ms)
Both lines ask "close enough?" instead of "identical?".
assertAlmostEquals(0.1 + 0.2, 0.3)is the test-file spelling, and the one to reach for in this course whenever a decimal was computed rather than written.Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILONis the ordinary-code spelling: take the difference, compare it against a tolerance you choose.
Number.EPSILON is the gap between 1 and the next representable number, which makes it a reasonable tolerance near 1 and a poor one near a million, where the gaps between representable numbers are much larger. It is a floor for tolerances, not a universal constant to paste in.
money is held in cents
The floating-point error stops being a curiosity the moment the numbers are prices. Predict the first line:
Deno.test("money is held in cents", () => {
assertEquals(1.1 * 3, 3.3000000000000003);
assertEquals(110 * 3, 330);
assertEquals(330 / 100, 3.3);
});
money is held in cents ... ok (0ms)
The three lines are the whole discipline.
1.1 * 3as decimal dollars produces3.3000000000000003, and an invoice with that total is wrong.110 * 3as whole cents produces exactly330, because integers multiply without error.330 / 100converts to3.3only at the end, when the value becomes text for a human.
Hold whole cents in a number, do every calculation in cents, and divide by a hundred only to display. Integers stay exact up to the limit in the next step, which in cents is roughly ninety trillion dollars, so the arithmetic runs out of business reasons long before it runs out of precision. For interest, tax, or currency conversion, reach for a decimal library rather than inventing rounding rules.
integers are exact up to 2 ** 53 - 1
"Integers multiply without error" has a boundary, and it is worth meeting head on. Predict the second line: are 2 ** 53 and 2 ** 53 + 1 different numbers?
Deno.test("integers are exact up to 2 ** 53 - 1", () => {
assertEquals(Number.MAX_SAFE_INTEGER, 9007199254740991);
assertEquals(2 ** 53 === 2 ** 53 + 1, false);
assert(Number.isSafeInteger(2 ** 53 - 1));
assertFalse(Number.isSafeInteger(2 ** 53));
});
Check programs/numbers.test.ts
running 5 tests from ./programs/numbers.test.ts
0.1 + 0.2 is not 0.3 ... ok (0ms)
a literal may name its base ... ok (0ms)
computed decimals are compared with a tolerance ... ok (0ms)
money is held in cents ... ok (0ms)
integers are exact up to 2 ** 53 - 1 ... FAILED (9ms)
ERRORS
integers are exact up to 2 ** 53 - 1 => ./programs/numbers.test.ts:36:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- true
+ false
FAILURES
integers are exact up to 2 ** 53 - 1 => ./programs/numbers.test.ts:36:11
FAILED | 4 passed | 1 failed (11ms)
error: Test failed
2 ** 53 === 2 ** 53 + 1 is true. Two different integers compare as equal, because the language cannot tell them apart. Here is why.
- A double spends 53 of its 64 bits on the significand, the part that holds the digits.
- Every integer up to
2 ** 53 - 1, which isNumber.MAX_SAFE_INTEGER, fits in those bits exactly. - Past the limit, the gaps between representable values grow larger than 1, so
2 ** 53 + 1has no slot of its own and rounds back to2 ** 53. Both expressions produce9007199254740992.
Correct the prediction to true:
Deno.test("integers are exact up to 2 ** 53 - 1", () => {
assertEquals(Number.MAX_SAFE_INTEGER, 9007199254740991);
assertEquals(2 ** 53 === 2 ** 53 + 1, true);
assert(Number.isSafeInteger(2 ** 53 - 1));
assertFalse(Number.isSafeInteger(2 ** 53));
});
This is not academic: database identifiers, Twitter-style snowflake IDs, and nanosecond timestamps all exceed the limit, and they arrive from JSON as numbers that have already lost their last digits before your code runs a single line. When a system hands you a large identifier, keep it as a string or as the type we meet in bigint keeps exactness, and keeps to itself, and never round-trip it through number. Number.isSafeInteger is the boundary test, and the last two lines place it precisely: 2 ** 53 - 1 is safe, 2 ** 53 is not.
integers are exact up to 2 ** 53 - 1 ... ok (0ms)
arithmetic never throws
Some languages raise an error on division by zero or overflow. JavaScript raises nothing, ever, for any arithmetic on numbers:
Deno.test("arithmetic never throws", () => {
assertEquals(1 / 0, Infinity);
assertEquals(-1 / 0, -Infinity);
assert(Number.isNaN(0 / 0));
assertEquals(Number.MAX_VALUE * 2, Infinity);
});
arithmetic never throws ... ok (0ms)
Four operations that would stop other programs, four ordinary values instead.
1 / 0and-1 / 0produce the two infinities, signed like the operands that made them.0 / 0has no sensible answer in either direction, so it producesNaN.Number.MAX_VALUE * 2overflows the type, and the overflow is silent: the result isInfinity, not an error.
The consequence is that a bad number surfaces far from where it was created. Infinity and NaN flow through later calculations as ordinary values, and NaN poisons everything it touches while refusing to equal even itself, as the equality page pinned down in NaN is never equal to anything, including itself. The defense is to validate numbers at the edges of the program, where they arrive, rather than hunting a NaN backward from where it finally became visible.
% is a remainder, not a modulo
The % operator looks borrowed from other languages, and on negative input it is not the operator those languages have. If you know Python, predict -5 % 3, then save:
Deno.test("% is a remainder, not a modulo", () => {
assertEquals(5 % 3, 2);
assertEquals(-5 % 3, 1);
});
Check programs/numbers.test.ts
running 7 tests from ./programs/numbers.test.ts
0.1 + 0.2 is not 0.3 ... ok (0ms)
a literal may name its base ... ok (0ms)
computed decimals are compared with a tolerance ... ok (0ms)
money is held in cents ... ok (0ms)
integers are exact up to 2 ** 53 - 1 ... ok (0ms)
arithmetic never throws ... ok (0ms)
% is a remainder, not a modulo ... FAILED (9ms)
ERRORS
% is a remainder, not a modulo => ./programs/numbers.test.ts:50:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- -2
+ 1
FAILURES
% is a remainder, not a modulo => ./programs/numbers.test.ts:50:11
FAILED | 6 passed | 1 failed (10ms)
error: Test failed
-5 % 3 is -2 here and 1 in Python, and the difference has a name.
- A remainder takes its sign from the dividend, the value being divided.
-5is negative, so-5 % 3is-2. - A modulo takes its sign from the divisor. Python's
%is a modulo, so-5 % 3is1there. - The divisor's sign is ignored by a remainder, which the fixed step's third line shows:
5 % -3is still2.
Code ported between the two languages breaks quietly, and only on negative input. Anything that wraps an index, cycles through a list, or normalizes a negative angle wants the modulo, and the idiom is one line. Correct the prediction and add it:
Deno.test("% is a remainder, not a modulo", () => {
assertEquals(5 % 3, 2);
assertEquals(-5 % 3, -2);
assertEquals(5 % -3, 2);
const mod = (a: number, n: number) => ((a % n) + n) % n;
assertEquals(mod(-5, 3), 1);
assertEquals(mod(5, 3), 2);
});
% is a remainder, not a modulo ... ok (0ms)
((a % n) + n) % n takes the possibly-negative remainder, shifts it up by one divisor, and takes the remainder again, which folds every result into the 0 to n - 1 range a modulo promises.
toFixed rounds the stored value, into a string
Rounding for display has two surprises of its own:
Deno.test("toFixed rounds the stored value, into a string", () => {
assertEquals((1.005).toFixed(2), "1.00");
assertEquals((2.5).toFixed(0), "3");
assertEquals(typeof (1.5).toFixed(1), "string");
});
toFixed rounds the stored value, into a string ... ok (0ms)
Take them in order.
(1.005).toFixed(2)is"1.00", and everybody calls it a rounding bug. It is not. The step0.1 + 0.2is not0.3established that most decimals are stored as their nearest representable neighbor, and1.005's neighbor sits a hair below1.005.toFixedrounded the number that is actually stored, and rounding that number down is correct. Blame the representation, not the rounding.(2.5).toFixed(0)is"3", sotoFixedsends this half up, which is worth noticing before the next step showsMath.rounddoing something less symmetric.typeofsays the result is astring.toFixedis a formatting method, and using it mid-calculation silently converts a number into text.
Math.round sends halves toward positive infinity
Math.round has its own rule for halves, and it is not "away from zero". Predict Math.round(-2.5):
Deno.test("Math.round sends halves toward positive infinity", () => {
assertEquals(Math.round(2.5), 3);
assertEquals(Math.round(-2.5), -2);
assert(Object.is(Math.round(-0.5), -0));
});
Math.round sends halves toward positive infinity ... ok (0ms)
Both halves moved in the same direction.
Math.round(2.5)is3: up, which reads as ordinary rounding.Math.round(-2.5)is-2, not-3: also up, because the rule is "halves go toward positive infinity", and for a negative number, up means toward zero.Math.round(-0.5)goes up to zero, and the zero it produces is-0, the signed zero from the equality page's===calls the two zeros the same value. The assertion usesObject.isbecause===cannot see the sign.
floor, ceil, round, and trunc disagree below zero
Four functions reach an integer from a fraction, and they agree so often on positive numbers that the differences hide. Line them up on both sides of zero:
Deno.test("floor, ceil, round, and trunc disagree below zero", () => {
const all = (x: number) => [
Math.floor(x),
Math.ceil(x),
Math.round(x),
Math.trunc(x),
];
assertEquals(all(2.5), [2, 3, 3, 2]);
assertEquals(all(-2.5), [-3, -2, -2, -2]);
});
floor, ceil, round, and trunc disagree below zero ... ok (0ms)
Reading each list as [floor, ceil, round, trunc]:
Math.floorgoes toward negative infinity, so-2.5becomes-3.Math.ceilgoes toward positive infinity, so-2.5becomes-2.Math.roundfollows the previous step's rule and also lands on-2.Math.truncdrops the fraction and keeps the integer part:-2.
On the positive side, floor and trunc produced the same 2, and that agreement is the trap. Code that uses floor to "cut off the decimals" works perfectly until the first negative value arrives, and then -2.5 becomes -3 instead of -2. Use trunc when you mean "discard the fraction" and floor when you mean "round down"; they are different questions that happen to share answers above zero.
three parsers, three sets of rules
Numbers arrive as text, from forms, files, and URLs, and JavaScript offers three ways to read them, each with its own rules. Predict what Number does with an empty string, then save:
Deno.test("three parsers, three sets of rules", () => {
assertEquals(Number(""), NaN);
});
Check programs/numbers.test.ts
running 11 tests from ./programs/numbers.test.ts
0.1 + 0.2 is not 0.3 ... ok (0ms)
...
three parsers, three sets of rules ... FAILED (8ms)
ERRORS
three parsers, three sets of rules => ./programs/numbers.test.ts:83:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- 0
+ NaN
FAILURES
three parsers, three sets of rules => ./programs/numbers.test.ts:83:11
FAILED | 10 passed | 1 failed (11ms)
error: Test failed
Number("") is 0. The strictest parser in the language, the one that rejects "12px" outright, reads an empty string as zero, and a blank form field becomes a valid price. Correct the prediction and lay out all three parsers:
Deno.test("three parsers, three sets of rules", () => {
assertEquals(Number(""), 0);
assertEquals(Number(" "), 0);
assert(Number.isNaN(Number("12px")));
assertEquals(parseInt("12px"), 12);
assertEquals(parseInt("0x10"), 16);
assertEquals(parseInt("0x10", 10), 0);
assertEquals(parseFloat("1.5.5"), 1.5);
assert(isNaN("oak" as unknown as number));
assertFalse(Number.isNaN("oak"));
});
three parsers, three sets of rules ... ok (0ms)
Walk the rules.
Numberdemands the whole string be a number, so"12px"isNaN, yet empty and whitespace-only strings convert to0. Strict everywhere except the one place strictness matters most.parseIntis forgiving in the opposite direction: it reads digits from the front and discards the rest, so"12px"is12, and a typo like"1,200"would silently become1. It also guesses the base from a prefix, so"0x10"is16, unless you pass the radix and make"0x10"in base ten stop at thexwith0. Always pass the radix.parseFloatreads a prefix too, so"1.5.5"is1.5: the second dot ends the parse instead of failing it.- The global
isNaNconverts its argument first and then asks, so it saystruefor"oak", which is notNaN.Number.isNaNasks whether the value is theNaNvalue, the question you mean, and the one the equality page used inNaNis never equal to anything, including itself. The global one is kept for compatibility and should never be written.
For anything typed by a user, prefer Number plus an explicit check over parseInt's silent truncation.
bigint keeps exactness, and keeps to itself
The truthiness page's falsy list included 0n, the zero of a second numeric type. bigint is an arbitrary-precision integer, written with an n suffix: no size limit, no fractions. Try mixing one with an ordinary number and save:
Deno.test("bigint keeps exactness, and keeps to itself", () => {
assertEquals(typeof 10n, "bigint");
assertEquals(10n + 1n, 11n);
assertEquals(2n ** 64n, 18446744073709551616n);
assertEquals(BigInt(Number.MAX_SAFE_INTEGER) + 2n, 9007199254740993n);
const mixed = 10n + 1;
});
Check programs/numbers.test.ts
TS2365 [ERROR]: Operator '+' cannot be applied to types '10n' and '1'.
const mixed = 10n + 1;
~~~~~~~
at file:///programs/numbers.test.ts:103:19
error: Type checking failed.
info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.
TypeScript refuses the mix before the program runs. JavaScript stands behind the refusal with its own TypeError at runtime, which a cast can prove, the same move the values-and-references page used in readonly exists only at compile time to see past the checker. Replace the mixed line with the full set of boundaries:
Deno.test("bigint keeps exactness, and keeps to itself", () => {
assertEquals(typeof 10n, "bigint");
assertEquals(10n + 1n, 11n);
assertEquals(2n ** 64n, 18446744073709551616n);
assertEquals(BigInt(Number.MAX_SAFE_INTEGER) + 2n, 9007199254740993n);
assertThrows(
() => (10n as unknown as number) + 1,
TypeError,
"Cannot mix BigInt and other types",
);
assertThrows(() => JSON.stringify({ big: 10n }), TypeError);
assertEquals((10n as unknown) == 10, true);
assertEquals((10n as unknown) === (10 as unknown), false);
});
bigint keeps exactness, and keeps to itself ... ok (0ms)
Walk what the step established.
2n ** 64nis a 20-digit integer held exactly, andBigInt(Number.MAX_SAFE_INTEGER) + 2nis9007199254740993n, the precise value that integers are exact up to2 ** 53 - 1showed collapsing into its neighbor as anumber. Exactness is the entire point of the type.- Mixing throws at runtime,
"Cannot mix BigInt and other types", so the checker's earlier refusal was a prediction, not an opinion. JSON.stringifythrows on abigintrather than guess a representation. A later step, abigintcrosses JSON as a tagged string, deals with that.- In equality,
bigintsits exactly where the equality page would predict:10n == 10istrue, because==converts across types, and10n === 10isfalse, because the types differ and===stops there.
Use bigint for large exact integers, identifiers, and cryptography. Math functions do not accept it, and it cannot hold a fraction, which the next step makes concrete.
bigint division has no fractions to give
Predict 1n / 2n, then save:
Deno.test("bigint division has no fractions to give", () => {
assertEquals(1n / 2n, 0n);
assertEquals(7n / 2n, 3n);
assertEquals(-7n / 2n, -3n);
assertEquals(7n % 2n, 1n);
assertThrows(() => +(1n as unknown as number), TypeError);
assertThrows(() => BigInt(1.5), RangeError);
assertThrows(() => BigInt(null as unknown as number), TypeError);
assertEquals(BigInt("0xFF"), 255n);
assertEquals(Number(10n), 10);
});
bigint division has no fractions to give ... ok (0ms)
Division first.
1n / 2nis0n. Not an error, and not a rounding choice: an integer type has no way to hold a half, so division keeps the integer part and drops the rest.7n / 2nis3n, and-7n / 2nis-3n, so the dropping is toward zero, likeMath.truncfromfloor,ceil,round, andtruncdisagree below zero.7n % 2nis1n, the remainder that division discarded.
Then conversion, which is stricter than anything Number does.
- Unary
+refuses abigintoutright, because too much existing code relies on+xmeaning "make this a number" for the conversion to be quietly redefined. BigInt(1.5)throws aRangeErrorrather than rounding the fraction away, andBigInt(null)throws aTypeErrorrather than producing zero, where the nothing-twice page's converted to a number, the two disagree showedNumber(null)cheerfully answering0. Refusing is the better default.BigInt("0xFF")reads a base prefix and produces255n, andNumber(10n)converts the other way without complaint, for values small enough to survive it.
This is why bigint is not a general fix for floating-point arithmetic: it trades fractions away entirely to get exactness. 1n / 2n being 0n is the price on the tag.
a bigint crosses JSON as a tagged string
JSON.stringify throws on a bigint, JSON has no notation to extend, and identifiers past the safe range are precisely the values that need to cross anyway. The answer is to spell the value as a string on the wire, with a tag, using the hook each direction provides:
Deno.test("a bigint crosses JSON as a tagged string", () => {
const BIGINT_TAG = "$bigint:";
const order = { id: 9007199254740993n, name: "x" };
const text = JSON.stringify(
order,
(_key, value) => typeof value === "bigint" ? BIGINT_TAG + value : value,
);
assertEquals(text, '{"id":"$bigint:9007199254740993","name":"x"}');
const back = JSON.parse(
text,
(_key, value) =>
typeof value === "string" && value.startsWith(BIGINT_TAG)
? BigInt(value.slice(BIGINT_TAG.length))
: value,
);
assertEquals(back.id, order.id);
});
a bigint crosses JSON as a tagged string ... ok (1ms)
Follow the round trip.
- The second argument to
JSON.stringifyis a replacer, called for every value on the way out. It turns eachbigintinto a tagged string, so9007199254740993ntravels as"$bigint:9007199254740993". - The second argument to
JSON.parseis a reviver, called for every value on the way back. It recognizes the tag, strips it, and rebuilds thebigint. - The final assertion is the payoff:
back.idequalsorder.idexactly, and the value chosen for the example is one pastNumber.MAX_SAFE_INTEGER, so an untagged trip throughnumberwould have lost its last digit.
The tag is not decoration. Without it, a reviver cannot tell an identifier that happens to be digits from a string that was always meant to be a string, and it would rebuild the wrong one.
formatting is a separate job
Every step so far kept numbers as numbers. The last one is about the moment a number becomes text for a person, which is a locale problem, not an arithmetic problem:
Deno.test("formatting is a separate job", () => {
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
assertEquals(money.format(1234.5), "$1,234.50");
assertEquals((1234.5).toLocaleString("de-DE"), "1.234,5");
});
formatting is a separate job ... ok (6ms)
Two renderings of the same value.
Intl.NumberFormatwith a currency style produces"$1,234.50": symbol, grouping comma, and the two decimal places currency demands.toLocaleString("de-DE")produces"1.234,5", because German swaps the roles of dot and comma. The same number, opposite punctuation.
Grouping, decimal marks, and currency symbols are conventions that vary by audience, and Intl.NumberFormat knows them so you do not hand-roll them. Reach for it whenever a number is displayed to a person, keep toFixed for quick output nobody is counting money in, and remember from a literal may name its base that the underscores in source code are a third thing entirely: separators for whoever reads the program.
In practice
- Store money as whole cents and convert only for display.
- Compare computed decimals with a tolerance, using
assertAlmostEqualsin tests. - Check
Number.isSafeIntegerfor large external integers, and keep large identifiers as strings orbigint. - Validate external numbers for
NaNandInfinity. Parse withNumberplus an explicit check, or pass a radix toparseInt. - Use
Number.isNaN, not the globalisNaN, andMath.truncwhen you mean to discard the fraction. - Use
bigintbeyond the safe integer range, encode it as a tagged string for JSON, and format human-readable numbers withIntl.NumberFormat.