139,907 Calls Later: What It Actually Takes to Port a Numerical Library from JavaScript to Rust
Porting a library between languages is mostly a typing exercise until the output has to be exactly the same.
For most code, "the same" has slack in it. A web handler that returns the right JSON with the fields in a different order is fine. A parser that produces an equivalent AST with different node names is fine. But when you port an arbitrary-precision arithmetic library, there is no slack. "5e-8".toFixed(2) is either "0.00" or it is wrong. There is no third answer, no "close enough," no reviewer who will accept 0.01 because the algorithm is morally correct.
I spent the back end of the Code Resurrection window rebuilding MikeMcl/bignumber.js — about 2,520 lines of JavaScript — in Rust. The interesting part of that work was not writing Rust. It was the fourteen separate occasions where I was confident the port was correct, and ~140,000 captured calls from the original library's own test suite disagreed with me.
This is the write-up of what broke, how I found it, and the one decision I'd take back.
The project I chose
Code Resurrection 2026 frames porting as digital archaeology: you don't just move code across a language boundary, you excavate what the original actually promised and carry those promises forward. The framing matters more than it sounds like it does, and I'll come back to why.
I picked bignumber.js off the JavaScript → Go/Rust track for a reason that turned out to be the whole story: it is a library whose entire value proposition is that its output is exact. Most candidate projects let you argue about fidelity. This one doesn't. Either my dividedBy returns the same digits as the original's dividedBy for every input, or my port is a different library that happens to have the same method names.
That gave me something rare in a porting project: an unambiguous, mechanically checkable definition of "done."
What bignumber.js is for
The canonical demo:
0.1 + 0.2
// 0.30000000000000004
This isn't a JavaScript bug; it's IEEE 754. A binary floating-point f64 has 53 bits of mantissa and stores values as a binary fraction. 0.1 and 0.2 are not representable in binary any more than 1/3 is representable in decimal — you get the nearest f64, and the error surfaces when you add them.
For graphics or physics, that's invisible. For money, tax, or anything that reconciles against a ledger, it's a defect. bignumber.js exists to give JavaScript a number type that stores decimal digits as decimal digits:
new BigNumber(0.1).plus(0.2).toString()
// "0.3"
The library covers arbitrary-precision decimal arithmetic, nine rounding modes, configurable precision and exponential-notation thresholds, non-decimal bases, and a formatting layer. It's ~2,520 lines, MIT-licensed, and has been load-bearing infrastructure in the JS ecosystem for over a decade.
Which raises the obvious question for a port: what, exactly, is the specification?
The archaeology: the source code was the smaller half of the artefact
Here is the thing I did not expect, and it reframed the whole project.
bignumber.js ships ~2,520 lines of implementation. Its test suite is 47,713 lines containing 65,727 assertions across 34 files. The tests are nineteen times larger than the thing they test.
That ratio is not an accident, and it is not over-testing. It is the actual shape of the specification. The implementation tells you how the library computes; the test suite tells you what it promises — and for a numerical library, the promises live almost entirely in the edges:
- What does
toFixeddo when the rounding position falls before the first stored digit? - What is the sign of a zero, and which methods preserve it versus drop it?
- What happens to
HALF_EVENwhen the tie-break digit is the last one and rounding up grows the digit count? - Does
Math.pow(1, NaN)return1orNaN?
None of those is answered by reading round(). All of them are answered by the tests.
My original plan was to hand-translate each of the 34 test files into Rust #[test]s. Once I actually measured the suite — 47,713 lines — that plan was dead. Not just infeasible in the time I had, but lower value than the alternative, and this is the decision I'm most glad I made:
Instead of re-stating the original's tests in Rust, I ran the original's tests for real and recorded everything they did.
The harness (fuzz/capture_tester.js) monkey-patches BigNumber.prototype purely to observe — it doesn't alter behaviour — and captures, for every single call the real suite makes:
{ method, receiver, args, result, activeConfig }
That produced 139,907 real calls. Those get replayed against the Rust port by src/bin/replay_captured.rs and diffed.
The activeConfig field is there because of a genuine archaeological finding. BigNumber.config() is a global mutable state in JavaScript — it lives in variables closed over by the clone() factory. That means config leaks across test files loaded later in the same process. A call in toFixed.js might execute under a DECIMAL_PLACES set by a file that ran twenty minutes earlier. If you replay those calls under the default config, you get hundreds of false divergences, and you'll spend a day chasing bugs that don't exist. Capturing config per-call was not a nicety; it was the difference between a usable signal and noise.
One honest footnote, because it belongs in the record: instrumenting the prototype costs 12 assertions inside the JS suite itself — 65,715 pass under capture versus 65,727 unmodified. It's almost certainly a wrapper interaction with squareRoot's internal t.times(t).eq(x) self-comparison, not a defect in bignumber.js. I documented it rather than quietly rounding it away.
A port is not a translation
The gap between JavaScript and Rust that mattered here was almost never syntax. It was semantics, and the semantics hid in four places.
Null is three different things. JS stores a BigNumber as {s, c, e} — sign, coefficient limbs, exponent — and uses null in each field to encode special values. s: null means NaN. c: null with a non-null s means ±Infinity. Rust has no null, so the port models this as:
pub struct BigNumber {
/// `Some(1)` positive, `Some(-1)` negative, `None` = NaN.
s: Option<i8>,
/// Coefficient limbs, base 1e14, most-significant first. `None` = ±Infinity or NaN.
c: Option<Vec<i64>>,
/// Base-10 exponent of the most significant digit.
e: Option<i64>,
}
I deliberately did not collapse this into the enum every Rust reviewer's instinct reaches for:
enum BigNumber { Finite { .. }, Infinite(Sign), NaN } // the tempting version
That enum is better Rust. It is also a rewrite, not a port. Nearly every original algorithm — round, div, normalise, compare — branches on the specific null/non-null combinations of s/c/e, in an order that depends on exactly this shape. Re-deriving equivalent branching against a cleaner enum means re-proving each algorithm's correctness from scratch instead of porting it. With a hard deadline and a correctness bar of "byte-identical," that trade was not closed.
Global mutable config doesn't survive the crossing. JS's BigNumber.config() mutates closure-captured module state shared by every instance from that constructor. Rust has no natural equivalent, and faking one with a static mut or a lazy global would import the original's biggest weakness — the original is not thread-safe; you need two separate BigNumber.clone() factories to get independent config in one process. So the port threads config explicitly:
x.plus(&y, &cfg) // instead of x.plus(&y) reading a global
More idiomatic, trivially thread-safe, and a disclosed divergence: BigNumber.config()/.set() are consequently not ported as such. That's in DECISIONS.md, not buried.
JavaScript's Math.pow is not IEEE 754. This one costs real time. exponentiatedBy has a float-fallback path for NaN/±Infinity/±1/±0 operands, and Rust's f64::powf disagrees with JS in exactly two places:
-
Math.pow(x, NaN)is alwaysNaNin JS. Rust'spowfspecial-cases base1.0and returns1.0even for a NaN exponent. -
Math.pow(±1, ±Infinity)isNaNper ECMA-262'sNumber::exponentiate— a deliberate JS-specific deviation from C99pow, which defines it as1.
Without a wrapper, BigNumber(1).exponentiatedBy(NaN) returned 1 instead of NaN — and worse, it propagated silently through the modular form, where the wrong 1 fed into .modulo(m) and produced 0 instead of a NaN. The fix is nine lines, and every one of them is a language-semantics fact, not an arithmetic one:
fn js_math_pow(base: f64, exponent: f64) -> f64 {
if exponent.is_nan() {
return f64::NAN;
}
if (base == 1.0 || base == -1.0) && exponent.is_infinite() {
return f64::NAN;
}
base.powf(exponent)
}
Errors move from throw-sites to type signatures. JS throws Error('Exponent not an integer: ' + n) from anywhere. In Rust, that has to become Result<BigNumber, BigNumberError>, which changes the signature, which changes every caller. Two of my fourteen bugs were missing error paths — places where JS validated, and I hadn't, so instead of an error, the port returned a wrong answer or hung.
What the number actually is
BigNumber
├── s : sign Some(1) | Some(-1) | None (NaN)
├── c : coefficient Vec<i64> of base-1e14 limbs, most-significant first
│ None = ±Infinity or NaN
└── e : exponent base-10 exponent of the most significant digit
Base 1e14 is the original's choice, and I kept it: it's the largest power of ten whose products stay inside JS's 2^53 safe-integer range when split via a 1e7 half-base during multiplication. Carrying that constant across meant carrying the exact same overflow boundaries, which meant carrying the exact same results.
Two internals I did rewrite, both consciously:
round() operates on a flat digit string, not limb indices. The original tracks, which base-1e14 limb the rounding digit falls in plus its offset within that limb (ni, i, j, d in the JS source). It's correct, and it's fast. My direct transliteration produced a genuine borrow-checker conflict and several off-by-one bugs before I scrapped it. The Rust version flattens the coefficient to a digit string, makes the rounding-mode decision and carries there, then rebuilds limbs aligned to the resulting exponent. Same observable semantics — verified against Node ground truth across all nine rounding modes, including HALF_EVEN's odd/even tie-break and carry-driven digit-count growth — different mechanism.
Division is schoolbook long division on digit strings. JS's div() works directly on base-1e14 limbs with a divisor-normalization trick. Mine extracts digit strings and does ordinary digit-by-digit long division. This was a correctness-over-performance call, and it has a price tag I'll put on the table later.
Fourteen bugs
The replay harness found them. Here are the ones that taught me something.
The exponent-alignment bug in division (49 / 13). Equal digit lengths, the dividend's leading digits exceeding the divisor's. Caught before it could propagate into dividedBy, modulo, squareRoot, and dividedToIntegerBy — which is the entire argument for building and verifying the primitives first. I built the core representation and the two highest-risk primitives (round() and decimal long division) against real Node ground truth before writing a single higher-level method. That ordering caught three bugs in the core before they could multiply.
toFraction() and a JavaScript aliasing detail. The original's continued-fraction algorithm contains this:
d1 = n0 = new BigNumber(ONE);
d1 and n0 are the same object. Later, mutating n0.c[0] also zeroes d1. In JS, that's just how object references work, and the algorithm quietly depends on it. In Rust, let d1 = n0.clone() gives you two independent values, and the algorithm silently produces the wrong fraction. The fix was explicitly modelling the shared state — but finding it meant reading the original closely enough to notice that an assignment chain was load-bearing.
toFormat()'s digit grouping captured groupSize after a swap instead of before, producing Indian-style grouping where standard was correct, and vice versa. A one-line ordering bug that no amount of staring at my own code would have surfaced. The replay surfaced in seconds.
And then the one that ate the most hours.
round()'s handling of sd < 1 — the case where the rounding position falls before the value's leading digit, in implicit-zero territory that isn't in the stored coefficient at all.
My code treated "the rounding digit" as the value's own leading digit for any sd <= 0. That looks right. It reads right. It is wrong. JS's digit-position formula reduces to "the leading digit" at exactly sd == 0; for sd < 0 it evaluates to rd = 0.
The observable consequence:
"5e-8".toFixed(2)
ground truth: "0.00"
my port: "0.01"
Because my version saw the rounding digit as 5 and rounded up, when JS sees 0 and stays at zero.
I did not fix this by guessing. I traced JS's round(x, sd, rm, r) line by line against the specific failing inputs, and the actual insight was structural: JS only consults the sd < 1 branch for the final write-out, never for computing the rounding digit. I had the branch in the right function and at the wrong point in the control flow. The comment I left in src/parse.rs is the one I'd want a future maintainer to read first:
// Compute rd (the rounding digit) and r (whether any nonzero digit exists
// after it) unconditionally, BEFORE any special-casing on `sd < 1`, matching
// JS's structure where the `sd < 1 || !xc[0]` branch is only consulted for the
// final write-out, not for computing rd/r. Getting this order backwards was a
// real bug: treating "sd < 1" as an immediate special case for rd computation
// made e.g. "5e-8".toFixed(2) treat rd as 5 (round up) instead of 0.
There was a sibling bug in the same function, and it's a nice illustration of how representation choices leak. After a value round-trips through base-1e14 limbs, coeff_to_string strips trailing zeros — so digit_count can legitimately end up less than the sd a caller asks for. Dividing at DECIMAL_PLACES=47 produced a 70-digit quotient that round-tripped to 67 stored digits and was then rounded to sd=68. My .take(68) on a 67-character string silently returned 67 characters, so a round-up carry landed on the last real digit instead of an implicit zero at position 68 — quietly dropping the correct final digit. Rust's iterators will happily give you fewer items than you asked for and not say a word about it.
Two of the "divergences" were bugs in my own harness, which is its own lesson. JS's String(-0) is "0" — and bignumber.js's toString() deliberately drops the sign for zero (there's an explicit comment in the source saying so); only valueOf() preserves it. My capture script used String() on every value, destroying the sign of every negative zero before the Rust side ever saw one. Fixing the capture then exposed the mirror-image asymmetry: my replay tool used to_string(), which drops the sign the same way, briefly reintroducing ~181 spurious divergences. A differential harness is a program, and it has bugs, and yours will look exactly like port bugs. Re-run the whole replay after every harness change.
What the fixed test suite couldn't catch
139,907 calls is a lot, but it's a fixed set — every input a group of humans thought to write down. So I built a second harness: a random differential fuzzer. fuzz/generate_inputs.js emits random op,a,b triples biased toward edge cases (zero, negative zero, extreme exponents, 15–40 digit multi-limb values, values sitting exactly on rounding boundaries), which get piped into both fuzz_cli (Rust) and node_driver.js (pinned original), and the outputs get diff'd.
It immediately found two bugs that the entire real test suite had never once exercised:
exponentiatedByhung forever on a non-integer exponent. Input:pow,056.56912,-339563.81514905096. JS validates unconditionally —if (n.c && !n.isInteger()) throw Error('Exponent not an integer: ' + n)— before entering its exponentiation-by-squaring loop. I had no such check, so the integer-halving loop's termination condition was never satisfied. Not a wrong answer. An infinite loop.toPrecision/toExponential/toFixedhad no argument range validation at all.toprec,561616,0succeeded silently where JS raises"Argument out of range: 0". JS'sintCheck(sd, 1, MAX)calls are unconditional — not gated behind STRICT mode, which is what I'd assumed.
Both fixes changed function signatures to return Result. Both got regression tests.
Then I re-ran everything from a clean build. The replay held at 139,907/139,907, and two fresh fuzz batches — 60,000 and 500,000 cases — produced zero diff lines. Not "zero failures on the operations where I'd found bugs." Zero differing output bytes across every operation the generator emits.
Final state: 139,907/139,907 replayed calls matched exactly. 0 panics, 0 divergences. 560,000 random fuzz cases, 0 diffs. 69 unit tests, each checked against captured Node output rather than hand-written expectations. 0 unsafe blocks.
The performance section, told honestly
Rust is faster. Except where it isn't, and that part matters more.
| Operation | Node p50 | Rust p50 | |
|---|---|---|---|
plus |
7.5 µs | 6.0 µs | comparable |
minus |
5.6 µs | 5.4 µs | comparable |
multipliedBy |
5.4 µs | 7.0 µs | comparable |
toFixed |
5.8 µs | 6.2 µs | comparable |
dividedBy |
20.7 µs | 65.9 µs | ~2.8x slower |
squareRoot |
49.7 µs | 239.7 µs | ~4.8x slower |
| startup | 15.58 ms | 0.038 ms | ~413x faster |
| peak RSS | 64.1 MB | 6.6 MB | ~9.6x lower |
Startup and memory go to Rust by a wide margin, and that number deserves no applause — it's a native binary versus a JIT runtime, it's structural, and quoting it as a win about my code would be dishonest.
The number that is about my code is dividedBy, and it's a loss. My port's division is ~2.8x slower than the JavaScript original, and squareRoot is ~4.8x slower because its Newton-Raphson iteration calls division repeatedly.
That is the direct, predictable cost of choosing schoolbook digit-string long division over the original's normalised base-1e14 algorithm. I made that call deliberately: the digit-string version is dramatically easier to verify against ground truth, and it was verified — that's how the 49/13 exponent-alignment bug got caught before it contaminated four dependent methods. Given a hard deadline and a correctness bar of byte-identical output, I'd rather ship a slow-and-provably-right division than a fast one I couldn't fully verify.
But I'm not going to dress a regression up as a feature. It's in the README, in DECISIONS.md, and in the benchmark methodology alongside its caveats (single run, no warm-up discard, machine not isolated from other loads). A benchmark table that only contains your wins isn't a benchmark; it's marketing.
The decision I'd take back: this one. Not the choice itself — under that deadline it was right — but the sequencing. I should have shipped the digit-string version to establish correctness, then used the now-verified implementation as an oracle to port the original's limb-based algorithm behind it, with the slow version as the differential reference. That's a few hours of work, and it would have closed the only real regression in the port. I ran out of the window before I ran out of plan.
What I'd tell someone starting a port tomorrow
Find the specification before you find the source. For bignumber.js, it was 47,713 lines of tests, not 2,520 lines of implementation. Read the tests first. They encode the promises; the source only encodes one way of keeping them.
Build a differential harness before you build features. Every one of my fourteen bugs came from the harness. Zero came from reading my own code. You cannot review your way to byte-identical output on a numerical library — I was confident and wrong fourteen separate times, and confidence was never the signal.
Verify the primitives before you build on them. Core representation and the two highest-risk primitives went in first, checked against real Node output, before any higher-level method existed. Three bugs died there instead of reproducing into forty methods.
Then fuzz, because your fixed test set is somebody's imagination. 139,907 real calls did not contain a non-integer exponent. Random generation found it in the first 60,000 cases.
Distrust your harness as much as your port. Two of my "divergences" were the observer, not the observed.
Write down what you didn't do. toString(base) — decimal → base-b string conversion — is not ported. The reverse direction is, because the two-argument comparison methods need it. Nothing in the replay exercised the forward direction, so it stays a documented, deliberate scope cut with an unimplemented!() marking the spot. BigNumber.config() as global state, isBigNumber, and toBigInt are also out, each with a reason. A port with three honest gaps is more useful than one with three gaps you have to discover yourself.
Porting is not translation. Translation preserves what the code says. A port has to preserve what the code does — including the parts nobody wrote down, the parts that only exist because Math.pow disagrees with C99, the parts that depend on two variable names pointing at one object, and the parts that only show up on the eight-thousandth call.
The original library was the specification. The tests were in archaeology. The 139,907 calls were the proof.
Port: github.com/PrinceXDev/port-mortem-bignumber-rust — every architectural divergence and all fourteen bugs, with root causes, are in DECISIONS.md.
Original: MikeMcl/bignumber.js (MIT) — a library whose test suite taught me more about numerical correctness than its implementation did, which is the highest compliment I know how to pay a codebase.
Top comments (0)