Building a money library is not one decision. It is about half a dozen, and "don't use floats" is only the first. Every interesting way to lose money lives in the others.

Everyone can recite that 0.1 + 0.2 is 0.30000000000000004. Far fewer notice that the most expensive rounding bug in financial history was not a floating-point bug at all. In 1982 the Vancouver Stock Exchange launched an index at exactly 1000.000 and then watched it sink to 524.811 over twenty-two months while the market underneath it rose. There was no crash. Each of roughly three thousand daily recomputations kept three decimals by truncating instead of rounding, always shaving in the same direction, and the drift compounded. When they finally switched the rounding rule over a weekend, the index snapped from 524.811 to 1098.892 overnight. (The TU Munchen write-up has the numbers.)

Notice what that bug was not. It was not 0.1 + 0.2. Whoever built that index had almost certainly "handled floats." They still built a slow-motion disaster, because avoiding floats is one decision and correctness needs several. This is a walk through the decisions I made building abakojs: what each one buys, what it costs, and where the established libraries reasonably chose otherwise. It is not the way to build a money library. It is one defensible way, and the decisions outlive the package.

Decision 1: what is a number, physically

Before any arithmetic you must choose how a value is stored, and this choice alone decides which bugs are even possible.

A raw IEEE 754 double is out: 0.1 is not 0.1 in binary, the same way one third is not finite in decimal, so the error is baked in before you compute anything. The subtler wrong answer is a double with rounding bolted on the outside, which is roughly what a float-based helper like currency.js does: keep the value in floating point, round at the edges. That is fine for a straightforward cart total and quietly accumulates error the moment you chain many operations or need sub-cent precision, because the thing being rounded was never exact to begin with.

The representation that makes the Vancouver class of bug impossible is fixed-point over integers. abakojs stores every value as { coef: bigint, scale }, meaning value === coef / 10^scale, and runs every operation in BigInt. There is no float anywhere in the computation path, so nothing can drift.

// value === coef / 10n ** BigInt(scale). All arithmetic is BigInt.
// 12.34  ->  { coef: 1234n, scale: 2 }

The cost is worth stating plainly: fixed-point decimal is not arbitrary-precision anything. You get exact addition, multiplication, and division-with-a-rounding-rule, and you give up transcendental functions and unbounded precision. That is exactly the trade decimal.js and big.js decline to make, being general arbitrary-precision engines, heavier and the right call if you need real precision beyond money. Money does not need that generality; it needs exactness in a narrow lane, and narrowing the lane is what keeps the library small.

Decision 2: where does dirty become clean

Here is the decision most "just use integer cents" advice skips, and it is the one I am proudest of getting right.

Your inputs arrive as number, already corrupted. So you need a single, defensible rule for turning a dirty float into an exact value, and the obvious rule reintroduces the bug it was meant to fix:

0.29 * 100;             // 28.999999999999996   the multiply already lied
Math.round(0.29 * 100); // 29 this time, luck, not a method

The rule abakojs uses instead leans on a genuinely deep property of the language. Number.prototype.toString is specified to return the shortest decimal string that round-trips back to the same double. So String(0.29) is "0.29", not "0.28999...". Computing that shortest decimal correctly is not folklore; it took the field years (Steele and White's Dragon4 in 1990, then Grisu, Ryu, Dragonbox making it fast), and every runtime now does it for you.

That gives you a boundary, and the design principle is: establish exactness at exactly one place and never reach back across it.

// The anti-drift boundary is PARSING. Convert once, through toString.
str = input.toString();   // 0.29 -> "0.29", never "0.2899999..."
// Parse that string exactly into { coef, scale }. The raw float is never touched again.

Inside the boundary, 0.1 + 0.2 is 1 + 2 over a common scale and the answer is exactly 0.3. The honest limit of the boundary is also a design fact you should publish, not hide: a double can only faithfully represent so much, roughly up to 2^53 minor units (about ninety trillion dollars at cents). Past that the shortest-decimal trick has nothing true to recover, so the escape hatch is to accept strings and parse them at full precision. Stating that ceiling out loud is part of the job.

Decision 3: rounding is a policy, not a constant

Eventually you divide, and dividing forces you to drop digits. The entire Vancouver disaster was a rounding-rule decision made once and hardcoded, so treat rounding as configurable policy from the start.

All the bias lives in one place: how you break a tie. Everything else about rounding is symmetric and cancels out.

E[error]half-up  =  +12Pr[tie]ULP,E[error]half-even  =  0.\mathbb{E}[\text{error}]_{\text{half-up}} \;=\; +\tfrac{1}{2}\,\Pr[\text{tie}]\,\text{ULP}, \qquad \mathbb{E}[\text{error}]_{\text{half-even}} \;=\; 0.

Round half up sends every tie the same way, so on money (where splitting bills and taking percentages manufacture exact half-cents all day) it drifts. Round half to even sends half the ties up and half down, and the expected bias is zero. That is why IEEE 754 makes half-even the default and why tax codes keep legislating it. So abakojs defaults to half-even, and, because different jurisdictions legally mandate different modes, exposes all seven as a first-class setting rather than a hardcoded constant.

Implementing half-even in integer space is a small exercise worth seeing, because it shows there is no float anywhere in the idea. After an integer division you hold a quotient and a remainder, and you bump the quotient by comparing twice the remainder against the divisor:

// round half to even, entirely in integers: q = quotient, r = remainder
if (2n * r !== divisor) return 2n * r > divisor; // not a tie: the nearer side wins
return q % 2n === 1n;                             // exact tie: bump only if q is odd

That last line is the literal meaning of "to even": on a tie, round toward the even digit; everything else is a magnitude comparison.

The anti-pattern is my own: abakojs v2 hardcoded round-away-from-zero (ceil of the magnitude), shipping a README that warned floats are dangerous for money while itself reproducing the exact systematic-overcharge bias it warned about. The lesson generalizes: when a domain has legal, plural, non-default rules, baking one in as a constant is a latent bug.

Decision 4: some operations must conserve, so build them in

Now the decision that separates a money library from a generic decimal library. Split ten cents three ways. Each exact share is 0.0333..., which rounds to 0.03, and three of those is 0.09. A penny evaporated. Round the other way and you mint one from nothing.

A general decimal engine will happily give you exact division and then let you do this to yourself, because rounding each part independently is not its problem. It should be the money library's problem. The property you actually need is conservation: the parts sum to exactly the whole. So conservation should be a primitive, not something every caller re-derives under deadline and gets subtly wrong.

The construction is the largest-remainder method: give each part the floor of its exact share in integer units, then hand the leftover units out one at a time, starting from the first.

// floor each ideal share, then distribute the remainder units. Sum is always exact.
const floors = weights.map((w) => (mag * w) / denom);
let residuo = Number(mag - floors.reduce((s, f) => s + f, 0n));
const counts = floors.map((f) => (residuo-- > 0 ? f + 1n : f));

That is the whole idea behind allocate, and it is worth seeing it hold on a number that is not cute:

allocate(1000000.05, [50, 30, 20]);
// [500000.03, 300000.01, 200000.01]   sums to exactly 1000000.05

Ship that as a primitive and a caller writes one line. Leave it out and every caller writes the algorithm, because the tiny-library default is actively worse here: currency.js only splits into equal parts (.distribute(n)), so a weighted 50/30/20 split makes you reimplement largest-remainder yourself at the call site:

// the same split with no weighted-allocation primitive (real, working currency.js)
const cents = currency(1000000.05).intValue;         // 100000005
const weights = [50, 30, 20];
const floors = weights.map((w) => Math.floor((cents * w) / 100));
let rem = cents - floors.reduce((a, b) => a + b, 0); // leftover units
const parts = floors.map((f) => (rem-- > 0 ? f + 1 : f));
return parts.map((c) => c / 100);                    // [500000.03, 300000.01, 200000.01]

That is correct code, and it is also the exact algorithm from the paragraph above retyped into application code, where one Math.floor slip is a production bug that leaks or invents money. Doing it once inside a library, where it can be tested to death, is the whole argument for making conservation a primitive.

If that method feels familiar it is because it is the same one used to hand out legislative seats by population, and it inherits the same paradoxes (the Alabama paradox, where adding a seat can cost a state a seat). Splitting a dinner bill and apportioning a parliament are arithmetically one problem, and both want conservation. The cost of the decision is that "who gets the leftover unit" is itself a choice; front-loading is deterministic and total-preserving, which I value over statistical fairness for money that has to reconcile.

Decision 5: does the caller hold a value or an object

The last decision is the one users feel every single line, and it is pure API design with no correct answer, only a chosen one.

The wrapper approach gives every value a type: new Decimal(x), dinero({ amount, currency }). You gain currency awareness, immutability, and protection against mixing dollars with euros; you pay with ceremony (new Decimal(a).plus(b).toNumber()) and with a type that has to be threaded through your whole codebase. Dinero.js makes this trade beautifully and is the right choice when you want a real money object with locales and formatting.

abakojs makes the opposite trade on purpose: plain numbers in, plain numbers out, because the caller almost always has plain numbers, from a form, an API, a database, and wants an answer rather than a type system.

value(2.675);        // 2.68     (2.675).toFixed(2) returns "2.67", faithfully reporting a corrupted 2.67499...
sum(0.1, 0.2, 0.3);  // 0.6
netFromGross(121, 21);  // 100    21% VAT stripped so net + tax === gross, exactly

Set that last line next to the same net-and-tax without a money library, where every intermediate is a wrapped object you build and later unwrap:

// decimal.js: also correct, but you hold a Decimal the whole way and toNumber() out
const gross = new Decimal(121);
const net = gross.div(new Decimal(1.21)).toDecimalPlaces(2);
const tax = gross.minus(net).toDecimalPlaces(2);
return { net: net.toNumber(), tax: tax.toNumber() };   // vs netFromGross(121, 21) / taxFromGross(121, 21)

Both return { net: 100, tax: 21 }. The difference is not correctness, it is that decimal.js makes you construct a Decimal, thread it through, and remember to toNumber() on the way out, while abakojs takes the plain numbers you already have from a form or a row and gives plain numbers back.

The cost of that choice is real and I will name it: no currency type to stop you adding dollars to euros, and the value is re-parsed on each call. The benefit is that it drops into existing code with zero friction, and the gap widens exactly where correctness is hardest, the weighted allocate from Decision 4 that currency.js cannot express at all.

Decision 6: earn the trust, do not assert it

A money library asks to be believed, so the final decision is how you earn it, because "I wrote careful code" is not an argument. Two techniques do the real work, and they are as much design decisions as the representation is.

The first is a differential oracle. abakojs runs thousands of random cases per operation through both its own engine and decimal.js set to the same rounding mode, and asserts they agree. decimal.js is a mature, independently written engine, so agreement across a wide random sample is strong evidence that any disagreement is my bug, not theirs. You are not proving correctness from first principles, you are borrowing someone else's hard-won correctness as a reference.

The second is property testing: rather than pin fixed input-output pairs, you assert the invariants that hold for every input. The parts of a split always sum to the whole. Parsing a number and printing it round-trips. A symmetric sweep of ties nets to zero bias. Those are exactly the guarantees from Decisions 2 through 4 turned into executable checks, so a test fails precisely when a design promise breaks. The invariants you designed around become the invariants you test.

That is the decision hiding inside "a money library may never be confidently wrong": confidence is not a byproduct of care, it is manufactured on purpose, from an oracle and a set of invariants.

What survives the package

Strip away the npm name and a money library is these six decisions:

  1. Represent as fixed-point integers, so drift is impossible rather than merely unlikely.
  2. Establish exactness at one parse boundary (the shortest-decimal string) and never cross back.
  3. Treat rounding as configurable policy, defaulting to the unbiased mode.
  4. Ship conservation as primitives, because per-part rounding is the bug people actually write.
  5. Choose value-versus-object deliberately, and state what the choice costs.
  6. Earn trust from an oracle and from invariants, because confidence is built, not assumed.

abakojs is my set of answers. Where your needs pull against one of them, the alternatives I named are sitting right there, and they are good. The Vancouver Stock Exchange did not lack a library. It lacked Decision 3, three thousand times a day, for twenty-two months.