# Exact USD arithmetic in JavaScript with BigInt

By Scopefolio Support. Scopefolio is our product. Prepared with AI assistance.

This standalone example parses USD decimal strings, adds integer cents, and formats the result. It is a software tutorial, not financial or accounting advice, and it does not describe Scopefolio’s implementation.

A small total makes the goal concrete. Save the functions below as `money.mjs`, then put this in `example.mjs`:

```javascript
import { parseUsd, formatUsd } from './money.mjs';

const total = parseUsd('19.99') + parseUsd('0.10') + parseUsd('0.20');
console.log(total.toString());
console.log(formatUsd(total));
```

The output is `2029`, followed by `USD 20.29`. The amount stays exact from input to display, including when its cent count exceeds JavaScript’s safe-integer range.

## Choose an input contract first

JavaScript’s `Number` uses binary floating point. Decimal fractions can lose precision: `0.1 + 0.2` evaluates to `0.30000000000000004`. [ECMAScript defines the Number representation](https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-ecmascript-language-types-number-type).

Integer cents are useful, but a `Number` still has a limit. Its maximum safe integer is `9007199254740991`; larger integers are not all distinguishable. [The specification documents that boundary](https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-number.max_safe_integer). This example uses `BigInt` so integer addition stays exact beyond it.

The parser accepts an optional minus sign, ASCII digits, a decimal point, and exactly two fractional digits. Examples are `"0.10"`, `"19.99"`, and `"-2.50"`. It rejects whitespace, grouping separators, exponent notation, leading zeros, and extra decimal places. Inputs must already be strings: converting a rounded number to a string cannot recover its original value.

This strict grammar suits an API boundary. A user-facing form may need a separate normalization step with clear validation messages. Keeping that step separate lets the calculation layer receive one predictable format.

## Parse once, then calculate in cents

```javascript
export function parseUsd(text) {
  if (typeof text !== 'string') throw new TypeError('Expected a string');
  if (text.length > 40) throw new RangeError('Amount is too long');
  const m = /^(-?)(0|[1-9][0-9]*)\.([0-9]{2})$/.exec(text);
  if (!m) throw new SyntaxError('Expected USD decimal');
  const cents = BigInt(m[2]) * 100n + BigInt(m[3]);
  return m[1] ? -cents : cents;
}

export function formatUsd(cents) {
  if (typeof cents !== 'bigint') throw new TypeError('Expected bigint');
  const sign = cents < 0n ? '-' : '';
  const magnitude = cents < 0n ? -cents : cents;
  const whole = magnitude / 100n;
  const fraction = (magnitude % 100n).toString().padStart(2, '0');
  return `USD ${sign}${whole}.${fraction}`;
}
```

The parser converts the whole and fractional digit groups separately. `"19.99"` becomes `19n * 100n + 99n`, or `1999n`. It never calls `parseFloat`, multiplies a decimal `Number`, or guesses a rounding rule. The 40-character limit is an example input-size guard, not a USD rule; choose bounds appropriate to your application.

Use bigint literals such as `100n` consistently in arithmetic. [BigInt represents integers and defines exact addition](https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-numeric-types-bigint). It cannot represent a fractional cent. The parser therefore rejects `"1.005"` rather than silently choosing whether it means 100 or 101 cents.

The formatter separates the sign before dividing, then pads the remainder to two digits. That keeps `-1n` readable as `USD -0.01`. An input of `"-0.00"` becomes `0n` and displays as `USD 0.00`.

## Test the boundaries

Save this as `money.test.mjs` alongside the other files:

```javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseUsd, formatUsd } from './money.mjs';

test('adds exact cents', () => {
  const total = parseUsd('19.99') + parseUsd('0.10') + parseUsd('0.20');
  assert.equal(total, 2029n);
  assert.equal(formatUsd(total), 'USD 20.29');
});

test('preserves cents beyond the Number safe-integer limit', () => {
  const cents = parseUsd('90071992547409.93');
  assert.equal(cents, 9007199254740993n);
  assert.equal(formatUsd(cents), 'USD 90071992547409.93');
});

test('handles negatives and normalizes negative zero', () => {
  assert.equal(formatUsd(parseUsd('-0.01')), 'USD -0.01');
  assert.equal(formatUsd(parseUsd('-0.00')), 'USD 0.00');
});

test('rejects malformed input and wrong types', () => {
  for (const value of ['', '1', '1.0', '01.00', '+1.00', ' 1.00',
    '1.00\n', '1,000.00', '1e2', '1.001']) {
    assert.throws(() => parseUsd(value), SyntaxError);
  }
  assert.throws(() => parseUsd(1), TypeError);
  assert.throws(() => parseUsd('9'.repeat(41)), RangeError);
  assert.throws(() => formatUsd(100), TypeError);
});

test('round-trips every cent from -100.00 through 100.00', () => {
  for (let cents = -10000n; cents <= 10000n; cents++) {
    assert.equal(parseUsd(formatUsd(cents).slice(4)), cents);
  }
});
```

Run `node example.mjs` and `node --test money.test.mjs`. These commands use [Node’s built-in test runner](https://nodejs.org/api/test.html#running-tests-from-the-command-line), so no package installation is needed.

The example and this exact suite were executed on Node v26.11.1. All five tests passed, with zero failures or skips. The final test checked 20,001 round trips. This verifies the listed behavior in that runtime; it does not establish that every money-related requirement is covered.

The large-value case matters because ordinary-looking examples can pass even when a later conversion loses precision. The invalid-input cases also document decisions: `"1.0"` is rejected intentionally, and a trailing newline is not silently ignored.

## Keep currency, display, and rounding explicit

`formatUsd` produces a fixed ASCII representation without locale-specific separators. For localized display, current `Intl.NumberFormat` semantics can preserve decimal-string input without first converting it to `Number`. [ECMA-402 specifies that conversion](https://tc39.es/ecma402/#sec-tointlmathematicalvalue). Test the behavior in your target runtime, and avoid introducing a lossy `Number(cents)` conversion just to format a result.

At an API or database boundary, preserve both the amount and its unit. For example, a record might store a currency code of `USD` and a decimal-string cent count of `"2029"`. Define a matching validation rule when reading that record back; it is a different format from the dollar input accepted here. Never infer the unit from the number alone: 2029 cents and 2029 dollars are very different values.

The factor of 100 belongs to this USD contract. Other currencies can use different fractional-digit counts; [ECMA-402 treats currency digits separately](https://tc39.es/ecma402/#sec-currencydigits). A multi-currency system should carry a currency code with each amount and reject addition across currencies unless an explicit conversion has occurred.

Finally, percentages and allocation need their own rules. [BigInt division truncates toward zero](https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-numeric-types-bigint-divide), so splitting 100 cents three ways produces a remainder. Decide how to round and distribute that remainder before implementing taxes, discounts, or splits.

For this limited workflow, the invariant is simple: validated USD strings enter, bigint cents are added, and formatting preserves every cent. Keep that invariant visible in both the API and the tests.
