bastianplsfix

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.

  1. A number is stored in binary, and a binary fraction can represent a value exactly only when its denominator is a power of two.
  2. 0.1 is one tenth, and ten is not a power of two, so the stored value is the nearest representable neighbor of 0.1, not 0.1 itself. The same goes for 0.2. Decimal has the identical limitation with one third: 0.3333 at any length is close, never exact.
  3. 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.

  1. 0b1111_0000 is binary for 240, 0o755 is octal for 493, and 0xff is hexadecimal for 255. The prefix names the base; the value is the same number type either way.
  2. 1_000_000 is exactly 1000000. The underscores exist for the reader and are not part of the number, which the next line proves: Number("1_000") is NaN, because nothing that parses text at runtime accepts the separators.
  3. (7).toString(2) renders seven in binary, "111". 7..toString(2) and 7.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?".

  1. 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.
  2. Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON is 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.1 * 3 as decimal dollars produces 3.3000000000000003, and an invoice with that total is wrong.
  2. 110 * 3 as whole cents produces exactly 330, because integers multiply without error.
  3. 330 / 100 converts to 3.3 only 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.

  1. A double spends 53 of its 64 bits on the significand, the part that holds the digits.
  2. Every integer up to 2 ** 53 - 1, which is Number.MAX_SAFE_INTEGER, fits in those bits exactly.
  3. Past the limit, the gaps between representable values grow larger than 1, so 2 ** 53 + 1 has no slot of its own and rounds back to 2 ** 53. Both expressions produce 9007199254740992.

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. 1 / 0 and -1 / 0 produce the two infinities, signed like the operands that made them.
  2. 0 / 0 has no sensible answer in either direction, so it produces NaN.
  3. Number.MAX_VALUE * 2 overflows the type, and the overflow is silent: the result is Infinity, 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.

  1. A remainder takes its sign from the dividend, the value being divided. -5 is negative, so -5 % 3 is -2.
  2. A modulo takes its sign from the divisor. Python's % is a modulo, so -5 % 3 is 1 there.
  3. The divisor's sign is ignored by a remainder, which the fixed step's third line shows: 5 % -3 is still 2.

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. (1.005).toFixed(2) is "1.00", and everybody calls it a rounding bug. It is not. The step 0.1 + 0.2 is not 0.3 established that most decimals are stored as their nearest representable neighbor, and 1.005's neighbor sits a hair below 1.005. toFixed rounded the number that is actually stored, and rounding that number down is correct. Blame the representation, not the rounding.
  2. (2.5).toFixed(0) is "3", so toFixed sends this half up, which is worth noticing before the next step shows Math.round doing something less symmetric.
  3. typeof says the result is a string. toFixed is 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.

  1. Math.round(2.5) is 3: up, which reads as ordinary rounding.
  2. 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.
  3. 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 uses Object.is because === 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]:

  1. Math.floor goes toward negative infinity, so -2.5 becomes -3.
  2. Math.ceil goes toward positive infinity, so -2.5 becomes -2.
  3. Math.round follows the previous step's rule and also lands on -2.
  4. Math.trunc drops 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.

  1. Number demands the whole string be a number, so "12px" is NaN, yet empty and whitespace-only strings convert to 0. Strict everywhere except the one place strictness matters most.
  2. parseInt is forgiving in the opposite direction: it reads digits from the front and discards the rest, so "12px" is 12, and a typo like "1,200" would silently become 1. It also guesses the base from a prefix, so "0x10" is 16, unless you pass the radix and make "0x10" in base ten stop at the x with 0. Always pass the radix.
  3. parseFloat reads a prefix too, so "1.5.5" is 1.5: the second dot ends the parse instead of failing it.
  4. The global isNaN converts its argument first and then asks, so it says true for "oak", which is not NaN. Number.isNaN asks whether the value is the NaN value, the question you mean, and the one the equality page used in NaN is 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.

  1. 2n ** 64n is a 20-digit integer held exactly, and BigInt(Number.MAX_SAFE_INTEGER) + 2n is 9007199254740993n, the precise value that integers are exact up to 2 ** 53 - 1 showed collapsing into its neighbor as a number. Exactness is the entire point of the type.
  2. Mixing throws at runtime, "Cannot mix BigInt and other types", so the checker's earlier refusal was a prediction, not an opinion.
  3. JSON.stringify throws on a bigint rather than guess a representation. A later step, a bigint crosses JSON as a tagged string, deals with that.
  4. In equality, bigint sits exactly where the equality page would predict: 10n == 10 is true, because == converts across types, and 10n === 10 is false, 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.

  1. 1n / 2n is 0n. 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.
  2. 7n / 2n is 3n, and -7n / 2n is -3n, so the dropping is toward zero, like Math.trunc from floor, ceil, round, and trunc disagree below zero.
  3. 7n % 2n is 1n, the remainder that division discarded.

Then conversion, which is stricter than anything Number does.

  1. Unary + refuses a bigint outright, because too much existing code relies on +x meaning "make this a number" for the conversion to be quietly redefined.
  2. BigInt(1.5) throws a RangeError rather than rounding the fraction away, and BigInt(null) throws a TypeError rather than producing zero, where the nothing-twice page's converted to a number, the two disagree showed Number(null) cheerfully answering 0. Refusing is the better default.
  3. BigInt("0xFF") reads a base prefix and produces 255n, and Number(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.

  1. The second argument to JSON.stringify is a replacer, called for every value on the way out. It turns each bigint into a tagged string, so 9007199254740993n travels as "$bigint:9007199254740993".
  2. The second argument to JSON.parse is a reviver, called for every value on the way back. It recognizes the tag, strips it, and rebuilds the bigint.
  3. The final assertion is the payoff: back.id equals order.id exactly, and the value chosen for the example is one past Number.MAX_SAFE_INTEGER, so an untagged trip through number would 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.

  1. Intl.NumberFormat with a currency style produces "$1,234.50": symbol, grouping comma, and the two decimal places currency demands.
  2. 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