DEV Community

Cover image for I Ported decimal.js to Go in 72 Hours — and Found 2 Real Bugs in the Original
Sundram Kumar Tiwari
Sundram Kumar Tiwari

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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()
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
    }
}
Enter fullscreen mode Exit fullscreen mode

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())
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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<b<1:   ln(b) < 0  →  -∞ / negative = +∞   ✗ decimal.js returns -∞
Enter fullscreen mode Exit fullscreen mode

So new Decimal(0).log(0.5) should return +Infinity. Both decimal.js and decimal-go return -Infinity.

The root cause is a short-circuit in decimal.js's P.log:

if (arg.s < 0 || !d || !d[0] || arg.eq(1)) {
  return new Ctor(d && !d[0] ? -1 / 0 : ...);  // always -Infinity for zero
}
Enter fullscreen mode Exit fullscreen mode

The base is never consulted when the argument is zero.

The practical impact: any computation using probabilities as logarithm bases (information theory, entropy calculations) where the argument can reach zero will silently get the wrong sign.

I pinned the parity behavior in a regression test and documented when we'd fix it: if upstream ships a correction, we follow.


Bug #5: toFraction() — An Infinite Loop Under rounding: 3

The continued-fraction expansion in toFraction() exits when the denominator grows past maxD. Under ROUND_FLOOR (rounding mode 3), an exact remainder is -0 (IEEE-correct: x−x → −0 under round-toward-negative). On the next iteration that -0 divisor makes the quotient -Infinity instead of +Infinity, so the new denominator is -Infinity, and -Infinity > 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(...)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 targets — only [DecimalError] panics permitted
Encoding encoding.go JSON, text, database/sql integration

Statement coverage: 96%. Race detector: clean. staticcheck: clean.


Benchmarks — The Honest Version

Mul at precision 20: Go takes 332 ns. JavaScript takes 1,039 ns. Go is 3.1× faster.

Div at precision 1000: Go takes 20,416 ns. JavaScript takes 52,159 ns. Go is 2.6× faster.

Go's advantage widens as precision grows. This makes sense — Go's tight inner loops on int32 slices beat V8's JIT-compiled float-backed array operations as the digit counts increase.

But I have two slower paths and I'm not hiding them:

  • New(string) parsing: 1.45× slower in Go. V8's JIT is remarkably good at string parsing. Go pays per-parse allocation overhead.
  • Full parse → op → format round trips: 1.72× slower. If your workload re-parses strings constantly, factor this in.

The operation kernel is faster. The string I/O boundary is slower. Both are documented in bench/results.txt with the methodology.


The One Extra Thing — WASM Playground

I compiled the entire library to WebAssembly and it runs in the browser at decimal-go.github.io/playground/.

No server. No install. No Node. Type plus(0.1, 0.2) and see 0.3. Type sqrt(2) and see 20 significant digits. Click the toFixed(pi, 30) example chip (or paste the long constant it expands to) and see thirty decimal places of π.

The WASM binary rebuilds automatically on every push to main, so what you're running in the browser is always the current library.


The Decision I'd Take Back

The 39 API aliases in parity.go.

I wrote a Go method called DividedBy that just calls Div. And NaturalLogarithm that calls Ln. And SquareRoot that calls Sqrt. Thirty-nine of them.

The idea was that JavaScript code could be copied to Go and compile with minimal changes. Practically useful. But in retrospect it's a lot of API surface to maintain forever for a convenience that most Go users will never need.

I'd still do the cross-validation harness. I'd still do all four fuzz targets. I'd still find and document the bugs. But the aliases? A migration guide and a search-and-replace script would have been enough.


Where It Lives

go get github.com/iSundram/decimal-go@v0.1.0
Enter fullscreen mode Exit fullscreen mode
import "github.com/iSundram/decimal-go"

x := decimal.New("0.1").Plus("0.2")
fmt.Println(x) // 0.3
Enter fullscreen mode Exit fullscreen mode

The source, the full test suite, xvalidate/, the WASM playground, all the benchmarks, and the bug reports are at github.com/iSundram/decimal-go.


Built for Port Mortem 2026 — Track F (JavaScript → Go). 72-hour hackathon. Solo entry.

#HackathonRaptors #PortMortem2026

Top comments (0)