Fix console.log %i/%d/%f number coercion to match Node
What
Fixes #31777. console.log('%i %i', [1, 2, 3, 4, 5]) printed NaN %i; Node and Deno print 1 %i.
$ bun -e "console.log('%i %i', [1, 2, 3, 4, 5])"
NaN %i # before
1 %i # after (matches node + deno)
Root cause
Bun has two format-specifier implementations. util.format (src/js/internal/util/inspect.js) was already correct, but the native console.log/console.error path in src/jsc/ConsoleObject.rs diverged:
%dand%iwere merged into one tag, and both coerced the argument withNumber()instead of Node'sparseInt/parseFloat.parseInt/parseFloatconvert the argument to a string first, soparseInt([1,2,3,4,5])isparseInt("1,2,3,4,5")→1, whereasNumber([1,2,3,4,5])isNaN.- Merging
%dwith%ialso made%dtruncate likeparseInt—console.log('%d', 3.9)printed3instead of3.9. - A
bigintargument threwConversion from 'BigInt' to 'number' is not allowedinstead of printing123n(%i/%d) or its numeric value (%f).
Node's documented semantics (and what inspect.js already does):
| spec | coercion |
|---|---|
%i | parseInt(value) (bigint → "<n>n", symbol → NaN) |
%d | Number(value) (bigint → "<n>n", symbol → NaN) |
%f | parseFloat(value) (symbol → NaN) |
Fix
- Split
%dfrom%iinConsoleObject.rs. - Run the real global
parseInt/parseFloaton the argument via two small FFI helpers (Bun__JSValue__parseInt/Bun__JSValue__parseFloatinbindings.cpp, wrapped asJSValue::parse_int/parse_float). These callglobalObject->parseIntFunction()/parseFloatFunction(), so ToString coercion, leading-whitespace,0xprefixes and partial parses all match Node exactly. - Handle
bigintfor%i/%d(print<digits>n, no color, matchingformatBigIntNoColor). - Format the resulting number like
String(value)—-0,±Infinityande-notation all render as Node does.
The native console.log path now agrees with util.format and with Node/Deno.
Verification
- New
describe("format specifier number coercion (#31777)")block intest/js/web/console/console-log.test.tsasserts Node-compatible output for%i/%d/%fwith arrays, numeric-prefix strings, bigints, symbols,±Infinity,-0and large numbers. - Updated
console-log.jsfixture +console-log.expected.txt: several expected lines encoded the old buggy output (%dtruncating,%iof an array printingNaN) and now match Node. - Confirmed the test fails on the unpatched binary and passes with the fix. Existing
util.test.js, node console tests andutil-format/internal-inspectsuites still pass.
[review] gate passed · iteration 9 · 6 files touched
fails on main (without fix)
ASAN without fix: 12 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/console/console-log.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (95e3b1eba)
test/js/web/console/console-log.test.ts:
uh oh
276 | console.log("%i", "0xff"); // 255
277 | console.log("%d", [3.9]); // 3.9 (Number, not parseInt-truncated)
278 | console.log("%d", "42abc"); // NaN
279 | console.log("%f", "3.14xyz"); // 3.14
280 | console.log("%f", [1.5, 2, 3]); // 1.5
281 | console.log("%i %d %f", 123n, 456n, 789n); // 123n 456n 789
^
TypeError: Conversion from 'BigInt' to 'number' is not allowed.
at /workspace/bun/test/js/web/console/console-log.js:281:9
Bun v1.4.0-debug+95e3b1eba (Linux x64)
Length of output: 3777
Length of expected: 3904
Exit code: 1
30 | console.error(err);
31 | console.log("Length of output:", out.length);
32 | console.log("Length of expected:",
... (truncated)
release without fix: 12 FAILED
bun test v1.4.0-canary.1 (1498d7b77)
test/js/web/console/console-log.test.ts:
uh oh
276 | console.log("%i", "0xff"); // 255
277 | console.log("%d", [3.9]); // 3.9 (Number, not parseInt-truncated)
278 | console.log("%d", "42abc"); // NaN
279 | console.log("%f", "3.14xyz"); // 3.14
280 | console.log("%f", [1.5, 2, 3]); // 1.5
281 | console.log("%i %d %f", 123n, 456n, 789n); // 123n 456n 789
^
TypeError: Conversion from 'BigInt' to 'number' is not allowed.
at /workspace/bun/test/js/web/console/console-log.js:281:9
Bun v1.4.0-canary.1+1498d7b77 (Linux x64)
Length of output: 3777
Length of expected: 3904
Exit code: 1
30 | console.error(err);
31 | console.log("Length of output:", out.length);
32 | console.log("Length of expected:", expected.length);
33 | console.log("Exit code:", exitCode);
34 |
35 | expect(out).toBe(expected);
^
error: expect(received).toBe(expected)
@@ -210,42 +210,41 @@
]
]
custom inspect
| 0 | 0 | 132 | -42 | 4 | -8 |
| NaN | NaN | NaN | 0 | NaN | NaN |
- | 0 | 0.2 | 132.51 | -42.52 | -4.127888538432189e+22 | 8.5e-12 |
- | NaN | Infinity | -Infinity | -0 | NaN | NaN |
- 5e-12
+ | 0 | 0
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/console/console-log.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (95e3b1eba)
test/js/web/console/console-log.test.ts:
(pass) should log to console correctly [1378.65ms]
(pass) long arrays get cutoff [5.50ms]
(pass) console.group [546.80ms]
(pass) console.log with SharedArrayBuffer [7.09ms]
(pass) format specifier number coercion (#31777) > console.log("%i %i", [1, 2, 3, 4, 5]) [470.55ms]
(pass) format specifier number coercion (#31777) > console.log("%i", []) [455.62ms]
(pass) format specifier number coercion (#31777) > console.log("%i", 3.9) [446.84ms]
(pass) format specifier number coercion (#31777) > console.log("%i", "42abc") [571.54ms]
(pass) format specifier number coercion (#31777) > console.log("%i", "0xff") [694.33ms]
(pass) format specifier number coercion (#31777) > console.log("
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
target linux-x64-gnu
build type Release
build dir ./build/release
revision 95e3b1eba8
features (none)
22 deps, 105 codegen, 1168 objects in 838ms
ninja: Entering directory `/workspace/bun/build/release'
[1/1231] gen ErrorCode+*.h
[2/1231] gen bindgenv2
[3/1231] fetch zlib
[zlib] up to date
[4/1231] fetch tinycc
[tinycc] up to date
[5/1230] fetch picohttpparser
[picohttpparser] up to date
[6/1230] gen .bind.ts → GeneratedBindings.cpp
[7/1230] subst deps/zlib/zlib.h
[8/1230] subst deps/zlib/zconf.h
[9/1181] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[10/1181] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[11/1181] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingCo
... (truncated)
diff hotspot
src/jsc/ConsoleObject.rs | 240 +++++++++++++--------------
src/jsc/JSValue.rs | 17 ++
src/jsc/bindings/bindings.cpp | 40 +++++
test/js/web/console/console-log.expected.txt | 32 ++--
test/js/web/console/console-log.js | 12 ++
test/js/web/console/console-log.test.ts | 54 +++++-
6 files changed, 262 insertions(+), 133 deletions(-)
gate history · 1 passed · 0 rejected · iteration 9
evidence per changed file
file reads edits tests
src/jsc/ConsoleObject.rs 12 13 22
src/jsc/JSValue.rs 7 4 22
src/jsc/bindings/bindings.cpp 4 5 22
test/js/web/console/console-log.expected.txt 4 4 28
test/js/web/console/console-log.js 4 1 25
test/js/web/console/console-log.test.ts 2 3 22
root cause · written by the author bot
The bug was in how Bun's console formatter handled %i and %d specifiers when given a non-primitive argument such as an array: it coerced the whole object through a numeric conversion, yielding NaN, whereas Node.js applies parseInt semantics to the argument's string representation, so [1, 2, 3, 4, 5] stringifies to 1,2,3,4,5 and parses to 1. The fix routes these specifiers through dedicated parseInt and parseFloat bindings that replicate Node's coercion behavior, so %i and %d now produce 1 for that input and %f likewise matches Node's parseFloat handling.