DEV Community

Cover image for Porting decimal.js to Go: What Broke and How We Found It
Arpit Tripathi
Arpit Tripathi

Posted on

Porting decimal.js to Go: What Broke and How We Found It

Our first test run looked good. It was wrong.

We were porting decimal.js, MikeMcl's arbitrary-precision decimal library, from JavaScript to Go as part of the Hackathon Raptors challenge with @partnerships_raptors. The goal was simple: build the Go equivalent without changing the behaviour users depend on.

Then we found out some of those passing tests were not testing our Go implementation at all.

The bridge we had built between the original JavaScript test suite and the Go implementation was allowing unsupported operations to fall back to JavaScript. So a test could pass even when the corresponding Go method was missing or incomplete. Once we removed those fallbacks and forced every operation through Go, the real failures appeared.

That changed the project. We were no longer trying to translate decimal.js function by function. We had to prove that the Go implementation behaved like the original, including the awkward cases around rounding, exponent alignment, parsing, formatting, special values, and transcendental functions.

This post is about what broke, how we found the real causes, and what we would change if we did the port again.

The First Test Run Lied

Our first verification run looked convincing. We had reused the original decimal.js test suite instead of rewriting thousands of assertions in Go, and built a JavaScript bridge that translated constructor calls and method invocations into JSON-RPC requests for the Go CLI, then returned the result to the original test runner. The assertions themselves stayed unchanged while the implementation underneath them changed from JavaScript to Go. On paper, this was exactly what we wanted: the source implementation provided the expected behaviour, while our Go implementation had to reproduce it.

The architecture looked like this:

                decimal.js tests
                         │
                         ▼
                     bridge.js
                         │
                         │ JSON-RPC
                         ▼
                    decimal-cli
                         │
                         ▼
                    decimal.go
                         │
                         ▼
                  test assertion
Enter fullscreen mode Exit fullscreen mode

The problem was not the architecture. During the early stages, some missing or incomplete methods could fall back to the original JavaScript implementation. The test still received a valid result, so the assertion could pass. A green test therefore proved only that the final value was correct, not where that value came from. We were measuring the behaviour of the combined JavaScript-and-Go system, not the behaviour of the Go port.

Once we found the problem, we made the bridge strict. Every operation exercised by the original suite had to reach the Go implementation. A missing Go method, a serialization failure, or a different result had to produce a failure. There was no JavaScript fallback, mocked result, or silent substitution.

             STRICT VERIFICATION

                decimal.js test
                       │
                       ▼
                   bridge.js
                       │
                       ▼
                  decimal-cli
                       │
                       ▼
                   decimal.go
                     /   \
                    /     \
             implemented  missing
                  │          │
                  ▼          ▼
               result      FAILURE
                  │
                  ▼
             assertion
Enter fullscreen mode Exit fullscreen mode

After this change, the test suite became useful for a different reason. A failure pointed to a behavioural difference between the two implementations, while a passing assertion meant something only if the request had actually reached Go. The migration had become a verification problem as much as an implementation problem.

We Stopped Fixing Failures One by One

At first, we opened the failing assertion and tried to fix it. That worked for isolated cases, but the same internal mistake kept appearing across different modules. After a few rounds of this, we started grouping failures by the first place where the Go execution diverged from decimal.js. We reproduced representative inputs against both implementations and traced them back to the earliest meaningful difference. A hundred failures did not necessarily mean a hundred bugs. One rounding rule, parser condition, or series termination check could be responsible for a large group of assertions.

The Tiny Number That Changed the Last Digit

One of the longest debugging sessions came from Add and Sub. Our implementation tried to avoid unnecessary work when two operands had an enormous exponent difference. If one value was far outside the working precision window, we treated its contribution as irrelevant and discarded it before performing the operation. The reasoning looked sensible because the distant digits could not appear among the final significant digits, but we were confusing "does not appear in the result" with "does not affect the result."

// Simplified representation of the original optimization.
//
// If the exponent gap is far beyond the precision window,
// the distant operand appears unable to affect the result.
if exponentGap > precisionLimit {
    // discard the distant contribution
}
Enter fullscreen mode Exit fullscreen mode

One of the inputs that exposed the problem had an enormous exponent gap:

4.6011800481717419E-806104423910
+
-8.6437098622551564549067098493197906561E-280114
Enter fullscreen mode Exit fullscreen mode

The failure was subtle. We were not getting a completely incorrect magnitude, and the result still looked plausible at the requested precision. The problem appeared at the rounding boundary because, by the time final rounding executed, our implementation had already forgotten whether the discarded tail contained a non-zero value. decimal.js preserved enough information to distinguish between a genuinely zero tail and a truncated non-zero tail, while our implementation treated both situations as equivalent.

Rounding boundary showing how a discarded non-zero tail affects the final digit

We did not solve this by keeping every distant digit, because that would defeat the optimization. Instead, we changed the exponent-alignment logic so the calculation retained enough information to distinguish a zero tail from a non-zero tail before final rounding. The Go code did not need to reproduce the JavaScript source line for line, but it did need to preserve the same observable numerical behavior at the rounding boundary.

The hard part of arbitrary-precision arithmetic is often deciding what information you are allowed to throw away.

When the Formula Was Right, but the Algorithm Was Wrong

The arithmetic bug was only one side of the problem. The next failures came from functions such as Ln, Exp, Sin, Cos, and Atan, where translating the formula was not enough. Our first implementations used straightforward Taylor-series calculations, and small inputs produced the expected values. Problems appeared as inputs moved away from the range where those series behaved well. The original decimal.js implementation uses argument reduction, working-precision control, and specific termination conditions, so the Go implementation had to reproduce those numerical decisions too.

The first major clue came from the scale of the failures. Increasing the working precision looked like the obvious fix, so we tested it systematically rather than guessing. We changed the working-precision margin from Precision + 10 through Precision + 30, while keeping the rest of the implementation unchanged. The result was decisive: the failure count stayed at 777 in every configuration. The hypothesis that the remaining failures were simply caused by insufficient guard digits was wrong.

Working precision       Remaining failures

Precision + 10                 777
Precision + 12                 777
Precision + 15                 777
Precision + 20                 777
Precision + 25                 777
Precision + 30                 777
Enter fullscreen mode Exit fullscreen mode

The experiment ruled out insufficient working precision as the main cause. The problem was how the extra precision was being used. Our Taylor-series loops were terminating based on the requested precision, while the original implementation retained additional information before deciding that a series had converged. Several functions also needed argument reduction before evaluating the series. Without it, errors accumulated across groups of tests for inputs far from zero.

Argument reduction before evaluating a Taylor series

For example, Sin(10) is a simple mathematical expression, but evaluating its Taylor series directly means summing terms for an argument well outside the range where the series behaves efficiently. We reduced the argument to a smaller interval before evaluating the series. The same principle appeared in Atan, where values with |x| > 1 were transformed using the reciprocal identity before the series calculation. The change was in the numerical strategy, not the formula itself.

// Simplified idea behind the argument reduction.
//
// Large x is transformed into a smaller range before
// evaluating the Taylor series.
if abs(x) > 1 {
    return halfPi.Sub(Atan(one.Div(abs(x))))
}
Enter fullscreen mode Exit fullscreen mode

The same approach fixed Ln and Exp: Ln needed argument reduction, while Exp needed range reduction followed by repeated squaring. After those changes, values such as ln(1000) and exp(50) behaved correctly instead of exposing the limitations of the unreduced series.

The important part was not translating the mathematical expression. The Go implementation also had to preserve the numerical strategy used to evaluate it.

How We Knew a Fix Was Actually a Fix

Fixing a failure was only half the job. We also needed evidence that the fix had not changed behaviour somewhere else. Our primary check was the original decimal.js test suite, kept untouched and executed through the strict bridge, so every assertion still described behaviour from the source project while the implementation underneath it was Go. We complemented it with native Go tests for repaired code paths and regression tests for edge cases we had already broken once. We reran the relevant module, checked the broader suite, and kept the original failing input as a permanent regression case.

The verification process also forced us to separate implementation failures from harness failures. A failed JSON-RPC request, a missing method, or a bridge serialization problem was not evidence of incorrect decimal arithmetic. Likewise, a passing assertion was not evidence of a correct Go implementation if the bridge had allowed JavaScript to supply the result. The final test was therefore simple: the request had to reach Go, Go had to calculate the result, and the original assertion had to accept it. Only then did a passing test mean what we thought it meant.

           Original decimal.js tests
                        │
                        ▼
                  Strict bridge
                        │
                        ▼
                Go implementation
                        │
                        ▼
                 Result / failure
                        │
                        ▼
                Original assertion
                        │
                        ▼
                 Regression test
Enter fullscreen mode Exit fullscreen mode

The Decision We'd Take Back

If we started the migration again, the first thing we would change would be the verification bridge. Allowing the JavaScript implementation to act as a fallback looked convenient while the Go port was incomplete, but it created false confidence precisely where we needed the strongest evidence. A missing Go method should have produced a hard failure from day one. That would have exposed the real implementation gap earlier and saved us from interpreting green tests as evidence of progress.

The same rule applies to any mature codebase being ported. The reference implementation should define the expected behaviour, but it should never produce the result being tested. Once we enforced that boundary, the failures became useful evidence instead of numbers to reduce.

Conclusion

Porting decimal.js to Go showed us where a seemingly correct port could still fail: rounding boundaries, discarded information, numerical reduction, and the verification bridge itself. The hardest part was not getting the Go code to compile or making individual functions pass. It was building enough evidence to know when the Go implementation was actually behaving like the original.

If you want to explore the project yourself, check out the Go implementation on GitHub. You can also compare it with the original decimal.js repository, which served as the behavioural reference throughout the migration.

and if you're working on a similar language port, the first thing worth building is the verification boundary. It determines whether every later green test actually means what you think it means.

Top comments (0)