<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arpit Tripathi</title>
    <description>The latest articles on DEV Community by Arpit Tripathi (@nexpectarpit).</description>
    <link>https://dev.to/nexpectarpit</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3700700%2F556a17f0-6e9d-4bb1-b67a-b29874ffc4ef.png</url>
      <title>DEV Community: Arpit Tripathi</title>
      <link>https://dev.to/nexpectarpit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nexpectarpit"/>
    <language>en</language>
    <item>
      <title>Porting decimal.js to Go: What Broke and How We Found It</title>
      <dc:creator>Arpit Tripathi</dc:creator>
      <pubDate>Sat, 08 Aug 2026 22:05:41 +0000</pubDate>
      <link>https://dev.to/nexpectarpit/porting-decimaljs-to-go-what-broke-and-how-we-found-it-4nim</link>
      <guid>https://dev.to/nexpectarpit/porting-decimaljs-to-go-what-broke-and-how-we-found-it-4nim</guid>
      <description>&lt;p&gt;&lt;strong&gt;Our first test run looked good. It was wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We were porting &lt;a href="https://github.com/MikeMcl/decimal.js" rel="noopener noreferrer"&gt;decimal.js&lt;/a&gt;, MikeMcl's arbitrary-precision decimal library, from JavaScript to Go as part of the Hackathon Raptors challenge with &lt;a class="mentioned-user" href="https://dev.to/partnerships_raptors"&gt;@partnerships_raptors&lt;/a&gt;. The goal was simple: build the Go equivalent without changing the behaviour users depend on.&lt;/p&gt;

&lt;p&gt;Then we found out some of those passing tests were not testing our Go implementation at all.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That changed the project. We were no longer trying to translate &lt;code&gt;decimal.js&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;This post is about &lt;strong&gt;what broke, how we found the real causes, and what we would change if we did the port again&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Test Run Lied
&lt;/h2&gt;

&lt;p&gt;Our first verification run looked convincing. We had reused the original &lt;code&gt;decimal.js&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The architecture looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                decimal.js tests
                         │
                         ▼
                     bridge.js
                         │
                         │ JSON-RPC
                         ▼
                    decimal-cli
                         │
                         ▼
                    decimal.go
                         │
                         ▼
                  test assertion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;             STRICT VERIFICATION

                decimal.js test
                       │
                       ▼
                   bridge.js
                       │
                       ▼
                  decimal-cli
                       │
                       ▼
                   decimal.go
                     /   \
                    /     \
             implemented  missing
                  │          │
                  ▼          ▼
               result      FAILURE
                  │
                  ▼
             assertion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  We Stopped Fixing Failures One by One
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;decimal.js&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Tiny Number That Changed the Last Digit
&lt;/h3&gt;

&lt;p&gt;One of the longest debugging sessions came from &lt;code&gt;Add&lt;/code&gt; and &lt;code&gt;Sub&lt;/code&gt;. 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 &lt;strong&gt;"does not appear in the result"&lt;/strong&gt; with &lt;strong&gt;"does not affect the result."&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Simplified representation of the original optimization.&lt;/span&gt;
&lt;span class="c"&gt;//&lt;/span&gt;
&lt;span class="c"&gt;// If the exponent gap is far beyond the precision window,&lt;/span&gt;
&lt;span class="c"&gt;// the distant operand appears unable to affect the result.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exponentGap&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;precisionLimit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// discard the distant contribution&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of the inputs that exposed the problem had an enormous exponent gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4.6011800481717419E-806104423910
+
-8.6437098622551564549067098493197906561E-280114
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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. &lt;code&gt;decimal.js&lt;/code&gt; preserved enough information to distinguish between a genuinely zero tail and a truncated non-zero tail, while our implementation treated both situations as equivalent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F424rhbur7g19c1ntvpih.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F424rhbur7g19c1ntvpih.png" alt="Rounding boundary showing how a discarded non-zero tail affects the final digit" width="799" height="275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The hard part of arbitrary-precision arithmetic is often deciding what information you are allowed to throw away.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When the Formula Was Right, but the Algorithm Was Wrong
&lt;/h2&gt;

&lt;p&gt;The arithmetic bug was only one side of the problem. The next failures came from functions such as &lt;code&gt;Ln&lt;/code&gt;, &lt;code&gt;Exp&lt;/code&gt;, &lt;code&gt;Sin&lt;/code&gt;, &lt;code&gt;Cos&lt;/code&gt;, and &lt;code&gt;Atan&lt;/code&gt;, 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 &lt;code&gt;decimal.js&lt;/code&gt; implementation uses argument reduction, working-precision control, and specific termination conditions, so the Go implementation had to reproduce those numerical decisions too.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Precision + 10&lt;/code&gt; through &lt;code&gt;Precision + 30&lt;/code&gt;, while keeping the rest of the implementation unchanged. The result was decisive: the failure count stayed at &lt;strong&gt;777 in every configuration&lt;/strong&gt;. The hypothesis that the remaining failures were simply caused by insufficient guard digits was wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Working precision       Remaining failures

Precision + 10                 777
Precision + 12                 777
Precision + 15                 777
Precision + 20                 777
Precision + 25                 777
Precision + 30                 777
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4pzhomgo67cghqy6zw2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4pzhomgo67cghqy6zw2.png" alt="Argument reduction before evaluating a Taylor series" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For example, &lt;code&gt;Sin(10)&lt;/code&gt; 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 &lt;code&gt;Atan&lt;/code&gt;, where values with &lt;code&gt;|x| &amp;gt; 1&lt;/code&gt; were transformed using the reciprocal identity before the series calculation. The change was in the numerical strategy, not the formula itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Simplified idea behind the argument reduction.&lt;/span&gt;
&lt;span class="c"&gt;//&lt;/span&gt;
&lt;span class="c"&gt;// Large x is transformed into a smaller range before&lt;/span&gt;
&lt;span class="c"&gt;// evaluating the Taylor series.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;halfPi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Atan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Div&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;))))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The important part was not translating the mathematical expression. The Go implementation also had to preserve the numerical strategy used to evaluate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How We Knew a Fix Was Actually a Fix
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;decimal.js&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The verification process also forced us to separate &lt;strong&gt;implementation failures from harness failures&lt;/strong&gt;. 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;           Original decimal.js tests
                        │
                        ▼
                  Strict bridge
                        │
                        ▼
                Go implementation
                        │
                        ▼
                 Result / failure
                        │
                        ▼
                Original assertion
                        │
                        ▼
                 Regression test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Decision We'd Take Back
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Porting &lt;code&gt;decimal.js&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;If you want to explore the project yourself, check out the &lt;a href="https://github.com/nexpectArpit/decimal.go" rel="noopener noreferrer"&gt;Go implementation on GitHub&lt;/a&gt;. You can also compare it with the original &lt;a href="https://github.com/MikeMcl/decimal.js" rel="noopener noreferrer"&gt;decimal.js&lt;/a&gt; repository, which served as the behavioural reference throughout the migration.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
