Repository navigation
Dynamic (n as any).toString() ignores the exponential threshold: 2.2e-308 prints ~308 decimal digits (static lowering is correct) #9713
Description
Found while validating #9698. Not caused by it — see "Why this is not #9698" below.
Repro
const a: number = 2.2e-308;
// Static lowering — all correct.
console.log(a.toString()); // 2.2e-308
console.log(String(a)); // 2.2e-308
console.log(`${a}`); // 2.2e-308
console.log(a); // 2.2e-308
// Dynamic dispatch — wrong.
function dyn(x: any, m: string): any { return x[m](); }
console.log(dyn(a, "toString"));| output | |
|---|---|
| node 26 | 2.2e-308 for all five |
| perry | 2.2e-308 for the four static forms; the dynamic one prints 0.000…00022 — the full ~308-digit decimal expansion |
The four static forms and the dynamic one disagree in the same program, so
this is a dispatch-path divergence, not a formatter that is simply wrong.
Where it is not
string::format::js_format_f64 implements the ECMA-262 NumberToString
threshold correctly — scientific notation when |n| >= 1e21 or |n| < 1e-6,
with a comment explaining exactly why (Number.EPSILON otherwise prints a
16-digit 0.000…0002…). js_number_to_string calls it, and the radix-10 arm
of value::to_string::number_to_radix_string returns js_number_to_string(n)
directly. So whatever the dynamic tower does for this receiver, it is not
reaching that formatter — the divergence is upstream of it.
I did not localise it further, and I am deliberately not guessing: the
primitive_methods toString arms I read are all jsval.is_pointer()-gated,
so a plain-number receiver does not take them, and I ran out of verified ground
before finding the arm that answers.
A measured localisation clue
Two subnormals, same zero-argument dynamic .toString(), different results —
and they reach Number.prototype by different routes:
| value | route through js_native_call_method |
result |
|---|---|---|
1e-310 |
the #9698 entry arm (dispatch_unvouched_bare_as_number → call_primitive_builtin_prototype_method(Number, …)) |
1e-310 — correct |
2.2e-308 |
the ordinary tower | 0.000…00022 — wrong |
1e-310 is bare-shaped (bits >> 48 == 0), 2.2e-308 is not
(bits >> 48 == 15), which is the only reason they diverge in routing. So the
route that goes straight to call_primitive_builtin_prototype_method formats
correctly, and the ordinary tower's route does not. That should narrow the
search considerably.
Scope — not determined
I verified 2.2e-308 only. Untested, and worth checking as part of a fix,
is whether the same dynamic path also mishandles the other side of the
threshold and the ordinary-magnitude exponential cases:
1e21,1e-7,Number.MAX_VALUE,Number.MIN_VALUE,Number.EPSILON- the same values via
String(x)/`${x}`/+ ""reached dynamically - non-10 radices (
dyn(x, "toString", 16)on the same values)
dyn(255, "toString", 16) → "ff" and dyn(3.14159, "toFixed", 2) → "3.14"
are correct, so the argument-carrying dynamic forms are at least not
uniformly broken.
Reproducing
The environment I measured in has been torn down. To rebuild:
cargo build --release -p perry
cargo build --release -p perry-runtime-static -p perry-stdlib-static
./target/release/perry compile repro.ts -o repro && ./repro
node --experimental-strip-types repro.ts
Severity: low on its own — a number stringifies to a mathematically correct but
non-conforming form. It matters where the text is compared or parsed (JSON-ish
output, cache keys, snapshot tests, log diffing), and it is a parity divergence
that only appears under type erasure, which is exactly the shape minified
bundles produce.
Activity
proggeramlug commentedon Sep 4, 2026
Taking this. I'll reproduce the dynamic
(n as any).toString()divergence against current main, sweep the untested cases the report lists (the 1e21 / 1e-7 boundaries, MAX_VALUE / MIN_VALUE / EPSILON,String(x)/ template /+ ""reached dynamically, and non-10 radices), localise the dispatch arm that bypassesjs_format_f64, and open a PR with a gap regression test.fix(runtime): dynamic Number toString uses NumberToString, not Rust D…