Back to blog

Transpiling brick/math: the road to 100%

brick/math is the arbitrary precision arithmetic library for PHP: BigInteger, BigDecimal, BigRational, and BigNumber. It is a cornerstone dependency for anything that touches money, rational numbers, or integers beyond the 64-bit range. Roughly 4,000 lines of PHP with a test suite that exercises every edge of PHP's numeric behavior. After a long series of fixes across the transpiler, the runtime, and the math module, Pext now passes 100% of its tests.

This post walks through the six issues that stood between the library and full compatibility, each of which exposed a real gap between PHP and JavaScript that had to be closed at the right layer.

Keeping int and float distinct

PHP has two numeric types: int and float. JavaScript has one: number. The naive transpilation path collapses both PHP types to the single JS type and accepts whatever fallout comes with it. For brick/math, that fallout is catastrophic. The entire library is built around the distinction between integers and floats: a BigInteger constructed from 1.0 is not the same as one constructed from 1, and many operations branch on is_int versus is_float.

Pext's runtime tracks the PHP type alongside the value. Integers and floats both live in JavaScript numbers under the hood, but the wrapper carries the original PHP tag so that is_int, is_float, gettype, and every coercion rule behave correctly. Arithmetic operators preserve the tag when the operation preserves the type in PHP, and promote it when PHP would promote. The transpiler emits calls that respect this contract end-to-end.

Without this, brick/math's constructor validation fires on the wrong code path, numeric string parsing returns the wrong canonical form, and BigInteger::of(1.0) succeeds when it should throw. With it, the library stops caring that it is running on JavaScript at all.

Static variables in function bodies

PHP's static $var = ... inside a function is a variable whose value persists across calls but is scoped to the function. It is used in brick/math for memoization: compute an expensive value once, cache it in the static, return the cached value thereafter. JavaScript has no direct equivalent.

The original Pext transpiler had partial support for static declarations at the function level, but not inside closures and not with the correct initialization-once semantics in every case. The fix moves static variables into a per-function record managed by the runtime: first entry initializes, subsequent entries read from the same slot, and the slot survives for the lifetime of the running process. Closures that declare statics get their own slots tied to the function identity, not the closure instance.

Once this landed, brick/math's cached powers of ten and its internal lookup tables started behaving as designed, and a cluster of tests that were failing with subtle off-by-one errors passed immediately.

Overflow and underflow

PHP integers overflow into floats silently. Multiply two large ints, cross PHP_INT_MAX, and the result becomes a float with reduced precision. Underflow works similarly in the opposite direction. brick/math relies on this promotion in some paths and explicitly guards against it in others.

The runtime now implements this behavior correctly: arithmetic operations on the PHP int type check the result against the platform's integer range and promote to float when the boundary is crossed. PHP_INT_SIZE is set to 4 in the runtime bootstrap so that block-based algorithms (the kind brick/math uses internally for its native calculator) use JS-safe block sizes and never rely on 64-bit integer precision that JavaScript cannot deliver faithfully.

The result is that brick/math's boundary tests (values near PHP_INT_MAX, multiplications that just cross into float territory, additions that overflow and then get compared) all behave identically to native PHP.

preg_match flags

brick/math uses preg_match to parse numeric strings: decimal, exponential, rational, with optional signs and groupings. Some of those patterns use PREG_UNMATCHED_AS_NULL, a flag that tells PCRE to return null for capture groups that did not participate in the match, rather than an empty string. The library distinguishes between "group matched empty" and "group did not match at all," and the flag is the mechanism.

Pext's pext-pcre module already supported preg_match with the common flag set, but PREG_UNMATCHED_AS_NULL was among the less frequently used flags not yet wired up. The fix extended the module to recognize the flag, post-process match results to insert nulls for unmatched groups, and preserve the PHP-side semantics exactly.

With the flag in place, brick/math's number parser differentiates correctly between "1.5", "1.5e0", and "1.5e", and the constructor-level validation matches native PHP output byte for byte.

Scope resolution for non-static methods

A bug in the transpiler's scope resolution surfaced when brick/math called self::methodName() inside a non-static context where the method was also non-static. The generated JavaScript was routing the call through a static lookup path, which in some cases resolved to a different inherited method than PHP's self:: would have. The original $this context was being threaded correctly for dynamic calls but not for self-qualified calls in this particular subcase.

The fix is in the transpiler: self:: calls now preserve the late-binding intent for non-static methods, while continuing to treat static:: as actual late-static-binding. The difference is subtle but matters for classes that rely on self:: as an explicit opt-out of late binding, which is exactly what brick/math does in several of its abstract base classes.

Operator coercion

With the int/float distinction preserved, every arithmetic and comparison operator had to be revisited to handle the combinations correctly: int + int stays int unless it overflows, int + float promotes to float, int == float compares numerically, int === float is always false, string operands coerce according to PHP's rules and not JavaScript's, and so on. Each of these has edge cases. String-to-number coercion alone has enough quirks (leading whitespace, trailing garbage, hex and octal prefixes, the "0x" edge case that changed between PHP versions) to fill a test file.

Most of the work in this area was defensive: write a test for every cell of the operator matrix, diff against native PHP output, fix the runtime implementation until every cell passes. brick/math's own test suite exercises a meaningful subset of this matrix through its constructor paths and comparison methods, and the remaining gap was filled with targeted unit tests in the runtime's math and operator modules.

The payoff is that every arithmetic expression in the transpiled code now behaves the same as in PHP, regardless of which numeric types the operands have. brick/math was the pressure test; the fixes benefit every transpiled codebase that does arithmetic, which is all of them.

Where we are

brick/math now passes 100% of its test suite on Pext. Every BigInteger, BigDecimal, BigRational, and BigNumber operation behaves identically to native PHP, across the full range of numeric inputs the library accepts. The fixes that got it there are now baseline behavior in the runtime and the transpiler: any other PHP project that relies on the same primitives (int/float distinction, static variables, integer overflow, PCRE flags, scope resolution) benefits automatically.

The next target in the numeric-heavy corner of the ecosystem is bc-math, PHP's other main arbitrary precision library. Much of the groundwork laid for brick/math (int/float separation, overflow handling, operator coercion) carries over directly, which should shorten the distance to a full pass.

brick/math joins the open source showcase alongside the other libraries Pext has been validated against. If your codebase depends on it (directly or transitively), it is ready.