I Ported decimal.js to Go in 72 Hours - and Found 2 Real Bugs in the Original
The pitch sounded simple: take a well-known open-source library, port it to a new language, prove it behaves identically. Port Mortem 2026. 72 hours. I worked alone. At kickoff, the organizers released a curated pool of 100 eligible open-source repositories for participants to choose from. Most people went for something manageable. I scrolled the list until I found the one that scared me the most. This is the story of what I built, what broke me, how I proved correctness, and the bugs I didn't expect to find. What I Picked From the List - And Why It Was Probably Stupid decimal.js is a JavaScript library for arbitrary-precision decimal arithmetic. Not a toy. Not a utility. A full numeric engine: - Arbitrary precision up to 1 billion significant digits - 9 rounding modes - Full arithmetic: add, subtract, multiply, divide, modulo, power - Transcendental functions: exp ,ln ,log ,log2 ,log10 - Full trigonometry: sin ,cos ,tan ,asin ,acos ,atan ,atan2 ,sinh ,cosh ,tanh ,asinh ,acosh ,atanh - NaN, ±Infinity, signed zero (-0), hex/binary/octal input - The whole thing ships with zero dependencies The target: Go. Track F (JavaScript → Go). Out of every repo in the recommended pool - compression libraries, slug generators, cron parsers, JSON parsers - this was the one with full trig, full transcendentals, 9 rounding modes, and arbitrary precision. I knew it would be hard. ops.go alone ended up 1,415 lines of dense numeric algorithms. But the thing that made it genuinely interesting wasn't the math - it was the philosophy question I kept bumping into: When the original has a bug, do you fix it or port it? The Philosophy: Faithfulness Over Correctness Most people porting a library think their job is to produce correct output. I think that's wrong. My job was to produce identical output - including the wrong ones. Here's why: if you're a JavaScript developer who has decimal.js running in production for three years, your code is already handling its edge cases. Your tests are written against its behavior. Your financial calculations depend on its specific rounding at boundaries. If I "fix" a bug while porting, your code breaks when you switch to my library. The port becomes untrustworthy. So I made a decision early: behavioral parity is the product. Every quirk gets preserved. Every weird edge case gets matched. Every bug gets inherited - and documented. This turned out to matter more than I expected, because I found five - three bugs in my own Go port, where JavaScript's permissive semantics silently tolerated mistakes that Go refused to, and two genuine upstream bugs in decimal.js itself, discovered only through differential fuzzing. Proving Parity - 1,518 Lines, Byte for Byte Before I get to the bugs, let me explain how I know I actually have parity. I built xvalidate/ - a cross-validation harness that runs a shared corpus through both the Go port and the live decimal.js library side-by-side: # bash xvalidate/compare.sh # Runs both Go and Node.js on the same inputs, sorts, diffs # Empty diff = PASS The corpus covers: signs, zeros (0 , -0 ), small and large exponents crossing the toExpNeg /toExpPos formatting boundaries, full integer precision beyond Number.MAX_SAFE_INTEGER , subnormals, NaN , ±Infinity . 66 inputs × 23 operations = 1,518 result lines. All 1,518 are byte-for-byte identical between Go and decimal.js . But cross-validation alone isn't enough. I also ported all 61 decimal.js test modules as white-box Go tests - every assertion kept, same order, same values. That's where the real bugs surfaced. Bug #1: pow10 - int64 Overflow in finalise() While porting the rounding internals, I hit a case where the ported test suite produced garbage digits - and in some configurations, an out-of-bounds access that could panic or wedge the test run. The problem was in finalise() , the function that rounds a result to the configured precision. When the rounding digit lives deep inside a base-1e7 word, it calls w / pow10(k) to extract it. The original decimal.js computes this in JS numbers, where the intermediate result is always safe. In Go with int64 , specific values caused the computation to overflow - producing garbage digits, or worse, hitting an index that didn't exist. The fix: divPow10 now clamps the exponent before division. But the important part is what I did after fixing it: // regression_test.go func TestRegressionPow10Overflow(t *testing.T) { for _, v := range []string{ "1.0000000999999994", "999999999999999.00000005", "0.0000009999999999999", "12345678901234567.00000005", } { // Must not panic, must not produce garbage digits got := New(v).ToDP(6).ValueOf() ... } } That test is now permanent. If anyone ever refactors finalise() , the overflow cannot silently come back. Bug #2: Pow - Slice Out of Bounds Raising a negative base to an integer exponent requires checking whether the exponent is odd or even. The original JS code does this: // decimal.js source y.d[e] & 1 // read the last digit word In JavaScript, reading past the end of an array returns undefined , and undefined & 1 === 0 . Silently. No error. In Go, the same index goes out of bounds and panics. I added a word() helper that returns 0 for out-of-range indices - matching JavaScript's implicit behavior - and locked the fix in: func TestRegressionPowNegIndex(t *testing.T) { if r := New("-2").Pow(5); r.ValueOf() != "-32" { t.Fatalf("(-2)^5 = %s, want -32", r.ValueOf()) } } Bug #3: 1^±Infinity - When My Port Disagreed With the Reference The ECMAScript spec (§15.8.2.13) is clear: 1^Infinity should be NaN . Go's math.Pow(1, math.Inf(1)) returns 1 . So does... the reference? No - this is the one where I got it backwards at first. Let me be precise about what actually happened. decimal.js itself returns NaN for 1^±Infinity and (-1)^±Infinity - correct, spec-compliant. My port's Pow wraps Go's math.Pow , which returns 1 for these inputs, so my first version diverged from the reference. The bug was in my port, not in the original. The fix wraps math.Pow so that a base of ±1 with an infinite exponent produces NaN , matching both decimal.js and ECMAScript: func TestRegressionPowOneInf(t *testing.T) { for _, b := range []string{"1", "-1"} { for _, e := range []string{"Infinity", "-Infinity"} { r := New(b).Pow(New(e)) if !r.IsNaN() { t.Fatalf("%s ^ %s = %s, want NaN", b, e, r.ValueOf()) } } } } This is the honest version of the story: Go's math.Pow returns 1 , the reference and the port both return NaN , and the regression test locks the parity in. Bug #4: log(0, base) - The One Differential Fuzzing Found This is the most interesting one, and the only one found by fuzzing rather than porting. I ran the port against mpmath (Python's arbitrary-precision math library) at 200+ digits of precision. Most results matched. One didn't. decimal.js returns -Infinity for log(0, base) regardless of what base is. But the correct answer depends on the base: log_b(0) = ln(0) / ln(b) If b > 1: ln(b) > 0 → -∞ / positive = -∞ ✓ decimal.js is correct If 0 maxD is false - the loop doesn't break. From there the values collapse to NaN , and NaN > maxD is always false too. The loop never terminates. Both decimal.js and decimal-go hang identically. Only rounding: 3 triggers it. All other modes produce +0 as the exact remainder, which terminates the loop cleanly. It's not a crash - it's a denial of service. I documented it in DECISIONS.md §12 and chose not to fix it. Parity is the contract. The Edge Case That Actually Ate Six Hours None of the above were the hardest thing. The hardest thing was concurrency. JavaScript is single-threaded. decimal.js stores three mutable flags - external , inexact , and quadrant - as module-level globals. In Node.js, this works fine: only one computation runs at a time. In Go, multiple goroutines can call operations concurrently. Module-level mutable state is a real data race. Here's what go test -race found when I first ran it: WARNING: DATA RACE Write at 0x... by goroutine 47: decimal.(*Constructor).divide(...) Read at 0x... by goroutine 51: decimal.(*Constructor).ln(...) The fix required moving external , inexact , and quadrant out of package-level variables and onto the Constructor struct - the per-clone state that mirrors decimal.js's own guidance to give each concurrent context its own constructor (Decimal.clone() creates an isolated configuration). But I had to actually redesign the internal call graph to thread the constructor through every nested operation. Then I wrote stress_test.go to prove it: // 64 goroutines, each with its own cloned constructor, // running transcendental ops concurrently. // go test -race must report clean. Zero races. Race detector clean. The JS library's docs tell you to give each concurrent context its own constructor (Decimal.clone() creates one with isolated configuration) precisely because the module-level flags exist. My port makes that model structural: the flags live on the Constructor , so one clone per goroutine is race-clean - verified by 64 concurrent cloned constructors under -race - whereas decimal.js's module globals would race if you tried the same thing in a worker pool. The Test Inventory (Because Coverage Claims Are Cheap) I'm tired of ports that say "100% test pass" when they ran 12 tests. Here's the actual inventory: | Layer | Where | What it proves | |---|---|---| | Ported test suite | 61 decimal.js test modules, ported to Go | Every decimal.js assertion, same order | | Cross-validation | xvalidate/ | 1,518 byte-for-byte identical lines vs live decimal.js | | Property tests | property_test.go | Round-trip, commutativity, inverse ops, sqrt/cbrt/exp-ln inverses, cmp antisymmetry, modulo range | | Stress + race | stress_test.go | Precision 400-2048, int64/uint64 edges, NaN/∞, 64-way race-clean | | Regression | regression_test.go | 4 porting bugs + 1 differential fuzzing bug locked in | | Input matrix | input_test.go | Every input type including *big.Int , hex/bin/oct, ±0, NaN, ∞ | | Fuzzing | fuzz_test.go | 4
Comments
No comments yet. Start the discussion.