console, error printer: render instanceof-Error objects as errors, not object dumps
What
A function-based Error subclass such as jsonwebtoken's JsonWebTokenError:
function JsonWebTokenError(msg) {
this.name = 'JsonWebTokenError';
this.message = msg;
Error.captureStackTrace(this, JsonWebTokenError);
}
JsonWebTokenError.prototype = Object.create(Error.prototype);
throw new JsonWebTokenError('boom');
is instanceof Error, but because the object is constructed without calling super() its JSC cell type is FinalObject, not ErrorInstance. Bun.inspect, console.error, and the uncaught-error printer only recognised ErrorInstance cells, so this value fell through to the generic object formatter:
JsonWebTokenError: boom
JsonWebTokenError {
name: "JsonWebTokenError",
message: "boom",
stack: "Error\n at ...",
toString: [Function: toString],
}
(the toString entry is Error.prototype.toString, picked up by the prototype-walking property enumerator used for generic object dumps). Node prints the error header and stack:
JsonWebTokenError: boom
at Object.<anonymous> (/tmp/x.js:8:7)
...
util.inspect already handled this correctly; Bun.inspect/console.* and the uncaught handler did not.
Cause
Tag::get_advanced in src/jsc/ConsoleObject.rs and print_error_instance_body in src/jsc/VirtualMachine.rs both gate error formatting on js_type() == JSType::ErrorInstance. A function-based subclass has js_type() == FinalObject, so the generic-object branch runs. The sibling Tag::get in the test-runner diff formatter (src/runtime/test_runner/pretty_format.rs) has the same gap.
Fix
Add JSValue::is_error_like(), which is true for a native ErrorInstance or an object whose [[Prototype]] chain contains Error.prototype (or an ErrorInstance). The chain is walked with getPrototypeDirect(), so Proxy getPrototypeOf traps and Symbol.hasInstance are not consulted; this is what Node's util.inspect does via primordials. Use it:
- in
Tag::get_advanced/Tag::getforObject/FinalObjectcells, routing them to the error formatter - in
print_error_instance_bodyat the three is-error decision points (top-level, nested-property, and non-enumerablecause)
Since toZigException only populates native stack frames for an ErrorInstance or a JSC::Exception wrapper, print_error_instance_body also gains a fallback: when the value is error-like but not an ErrorInstance and there are no native frames, print its own .stack string (minus the header line, which print_error_name_and_message already wrote). Without this the Bun.inspect/console.error path would print only the header.
A plain object with name/message/stack own properties but no Error in its prototype chain is unchanged (still an object dump), matching Node.
Why this is correct
Node's isError helper (lib/internal/util.js) is isNativeError(e) || e instanceof Error, evaluated through primordials so a user-installed Error[Symbol.hasInstance] is not observed. Verified against Node v26.3.0: a user Symbol.hasInstance is not called, and a plain object without Error.prototype in its chain stays an object dump.
Tests
Seven new tests in test/js/bun/util/inspect-error.test.js cover Bun.inspect (header + stack, extra own properties, error-like cause, Symbol.hasInstance not consulted, the negative plain-object case) plus spawned-process checks for console.error and an uncaught throw, each asserting an at ... frame line appears. Six fail on the current canary, all seven pass with this change.
Also updated that file's normalizeError helper to match debug-only internal frames rendered as at require (51:24) (no leading colon); the helper was written for the older (:1:21) format and has been stale against debug builds since #12791. Release builds never emit these frames, so CI was unaffected; the update keeps the two pre-existing minified-source tests green under bun bd test.
[review] gate passed · iteration 1 · 7 files touched
fails on main (without fix)
ASAN without fix: 9 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/inspect-error.test.js
bun test v1.4.0 (491fb8d32)
test/js/bun/util/inspect-error.test.js:
(pass) error.cause [6.45ms]
(pass) Error [3.70ms]
(pass) BuildMessage [120.91ms]
(pass) Error inside minified file (no color) [99.54ms]
(pass) Error inside minified file (color) [63.31ms]
(pass) Inserted originalLine and originalColumn do not appear in node:util.inspect [458.93ms]
(pass) observable properties > sourceURL is observable [5.98ms]
(pass) observable properties > line is observable [2.30ms]
(pass) observable properties > column is observable [1.93ms]
(pass) error.stack throwing an error doesn't lead to a crash [4.91ms]
216 |
217 | test("Bun.inspect renders it as an error with its .stack, not an object dump", () => {
218 | const err = makeErrorLike();
219 | expect(err instanceof Error).toBe(true);
220 | const out = Bun.inspect(err, { colors: false });
221 | expect(out).toContain("JsonWebTokenError: boom");
^
error: expect(received).toContain(expected)
Expected to contain: "Jso
... (truncated)
release without fix: 9 FAILED
bun test v1.4.0-canary.1 (1498d7b77)
test/js/bun/util/inspect-error.test.js:
(pass) error.cause [0.32ms]
(pass) Error [0.10ms]
(pass) BuildMessage [1.14ms]
(pass) Error inside minified file (no color) [2.13ms]
(pass) Error inside minified file (color) [0.42ms]
(pass) Inserted originalLine and originalColumn do not appear in node:util.inspect [5.79ms]
(pass) observable properties > sourceURL is observable [0.22ms]
(pass) observable properties > line is observable [0.06ms]
(pass) observable properties > column is observable [0.05ms]
(pass) error.stack throwing an error doesn't lead to a crash [0.09ms]
216 |
217 | test("Bun.inspect renders it as an error with its .stack, not an object dump", () => {
218 | const err = makeErrorLike();
219 | expect(err instanceof Error).toBe(true);
220 | const out = Bun.inspect(err, { colors: false });
221 | expect(out).toContain("JsonWebTokenError: boom");
^
error: expect(received).toContain(expected)
Expected to contain: "JsonWebTokenError: boom"
Received: "JsonWebTokenError {\n name: \"JsonWebTokenError\",\n message: \"boom\",\n originalLine: 157,\n originalColumn: 33,\n stack: \"Error
... (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/bun/util/inspect-error.test.js
bun test v1.4.0 (491fb8d32)
test/js/bun/util/inspect-error.test.js:
(pass) error.cause [6.81ms]
(pass) Error [3.94ms]
(pass) BuildMessage [15.20ms]
(pass) Error inside minified file (no color) [91.51ms]
(pass) Error inside minified file (color) [56.50ms]
(pass) Inserted originalLine and originalColumn do not appear in node:util.inspect [332.96ms]
(pass) observable properties > sourceURL is observable [6.12ms]
(pass) observable properties > line is observable [2.37ms]
(pass) observable properties > column is observable [2.06ms]
(pass) error.stack throwing an error doesn't lead to a crash [4.40ms]
(pass) Error-like object (instanceof Error, not a native ErrorInstance) > Bun.inspect renders it as an error with its .stack, not an object dump [10.47ms]
(pass) Error-like object (instanceof Error, not a native ErrorInstance) > Bun.inspect still lists extra own properties after the header [5.90ms]
(pass) Error-like object (instanceof Error, not a native ErrorInstance) > Error-like cause is rendered as
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
target linux-x64-gnu
build type Release
build dir ./build/release
revision 491fb8d324
features baseline
22 deps, 108 codegen, 1171 objects in 3337ms
ninja: Entering directory `/workspace/bun/build/release'
[1/1234] gen bindgenv2
[2/1234] gen ErrorCode+*.h
[3/1234] fetch zlib
[zlib] up to date
[4/1234] fetch picohttpparser
[picohttpparser] up to date
[5/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1234] gen .bind.ts → GeneratedBindings.cpp
[7/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[8/1234] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingBuffer.cpp
[9/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[10/1234] gen ProcessBindingFs.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingFs.lut.h
... (truncated)
diff hotspot
src/jsc/ConsoleObject.rs | 9 ++
src/jsc/JSValue.rs | 9 ++
src/jsc/VirtualMachine.rs | 43 ++++++--
src/jsc/bindings/bindings.cpp | 27 +++++
src/jsc/bindings/headers.h | 1 +
src/runtime/test_runner/pretty_format.rs | 4 +
test/js/bun/util/inspect-error.test.js | 168 ++++++++++++++++++++++++++++++-
7 files changed, 251 insertions(+), 10 deletions(-)
gate history · 2 passed · 0 rejected · iteration 1
evidence per changed file
file reads edits tests
src/jsc/ConsoleObject.rs 6 4 0
src/jsc/JSValue.rs 7 5 0
src/jsc/VirtualMachine.rs 16 12 0
src/jsc/bindings/bindings.cpp 3 4 0
src/jsc/bindings/headers.h 1 1 0
src/runtime/test_runner/pretty_format.rs 2 1 0
test/js/bun/util/inspect-error.test.js 6 17 0