Skip to content

node errors: render -0 and constructor-less objects in determineSpecificType like node - #38642

Open
robobun wants to merge 1 commit into
mainfrom
farm/ba2a510d/determine-specific-type-neg-zero-object
Open

node errors: render -0 and constructor-less objects in determineSpecificType like node#38642
robobun wants to merge 1 commit into
mainfrom
farm/ba2a510d/determine-specific-type-neg-zero-object

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

determineSpecificType() in src/jsc/bindings/ErrorCode.cpp renders the Received ... part of every ERR_INVALID_ARG_TYPE message (C++ validators, the JS builtins' $ERR_INVALID_ARG_TYPE, Rust's determine_specific_type, and the but received ... suffix of the ERR_INVALID_THIS that native classes throw). Two arms of it diverge from node's lib/internal/errors.js determineSpecificType():

  • Number arm (ErrorCode.cpp:389): process.chdir(-0) says Received type number (0); node says type number (-0). StringBuilder::append(double) drops the sign like Number#toString does, and node special-cases it.
  • Object arm (ErrorCode.cpp:492): bun only checks that value.constructor is truthy and then prints .constructor.name, node checks 'name' in value.constructor and otherwise falls back to util.inspect(value, { depth: -1 }). Measured on bun 1.4.0 vs node v26.3.0 through process.chdir(v):
    • { constructor: {} }: bun an instance of undefined, node [Object]
    • { constructor: null }: bun {}, node [Object] (bun's fallback was the native formatter at unlimited depth)
    • Object.assign(Object.create(null), { a: 1 }): bun [Object: null prototype] { a: 1 }, node [Object: null prototype]
    • { constructor: 1 }: bun an instance of undefined, node throws TypeError: Cannot use 'in' operator to search for 'name' in 1 (the in on a primitive propagates out of the message builder)

Fix

  • Number arm: -0 renders as type number (-0).
  • Object arm moves into appendSpecificTypeOfObject(), a line by line port of node's arm: getIfPropertyExists(name) is the 'name' in ctor check plus the read (it goes through the same [[HasProperty]] walk, so Proxy has traps and inherited names behave as in node); a truthy primitive constructor throws a TypeError with the text node prints for it; everything else calls util.inspect(value, { depth: -1 }) the way the BroadcastChannel / URLSearchParams / web streams inspect code already calls it from C++. Bun's util.inspect is a port of node's, so the collapsed renderings ([Object], [Foo], [Map], [Object: null prototype], custom inspect functions called with depth -1) come out identical; the fallback runs only for values whose constructor is missing or nameless, so the common an instance of X path is unchanged apart from using getIfPropertyExists.
  • The helper has its own ThrowScope because it throws; determineSpecificType() keeps its TopExceptionScope, so its contract towards callers (exception left pending, callers check) is unchanged. The same shape already happens today when a constructor getter throws, which is why every caller, including createInvalidThisError, already copes with it.
  • Why this is the right behavior: it is what node does, verbatim (permalinks in the code comments), and bun's ported node tests compare these messages as text.
  • Verified with test/js/node/errors/invalid-arg-type-received.test.ts: 4 entry points (C++ process.chdir, JS EventEmitter#on, Rust SocketAddress.parse, generated-class CryptoHasher#update invalid this) x 18 renderings, the 5 primitive-constructor throws, and propagation of exceptions thrown from the constructor getter, a has trap, the name getter, and a custom inspect function. All 12 tests fail on bun 1.4.0 (only the intended rows differ, see below) and pass on this build, also with BUN_JSC_validateExceptionChecks=1.
  • The renderings and thrown messages asserted by the test were replayed against node v26.3.0 with the test's own data (script output below): 0 mismatches across the three ERR_INVALID_ARG_TYPE entry points.
  • Independent of node errors: render a callable's name in determineSpecificType like node #38473, which fixes the callable arm of the same function; the hunks do not overlap and node errors: render a callable's name in determineSpecificType like node #38473's object-branch test passes on this build.

Background

  • determineSpecificType (node lib/internal/errors.js): turns the offending value into the Received ... text of ERR_INVALID_ARG_TYPE: primitives as type number (5), objects as an instance of <constructor name>, and anything without a usable constructor name through util.inspect with depth: -1, which prints a value as just its bracketed constructor name instead of its contents.
  • 'name' in x: [[HasProperty]], i.e. it looks up the prototype chain and asks a Proxy's has trap, and it throws a TypeError when x is a primitive. JSC's JSObject::getIfPropertyExists does that lookup and returns the value, or an empty JSValue when the property does not exist.
  • Exception scopes: JSC code that throws needs a ThrowScope; a TopExceptionScope (what determineSpecificType uses) can only observe exceptions that callees threw. Under BUN_JSC_validateExceptionChecks=1 (CI's ASAN lanes) every call that may throw has to be followed by a check, hence the RETURN_IF_EXCEPTION after the new helper.
  • utilInspectFunction() on the global object lazily loads node:util and returns its inspect, the same function users call; defaultGlobalObject() maps a non-Bun global (a node:vm context) to the Bun global that owns it.
  • Pre-existing and unchanged by this PR: if the exception left pending while rendering is a primitive (for example a constructor getter that does throw 7), ErrorCodeCache::createError asserts in debug builds on the createInvalidThisError path. It reproduces on main without this change and is being fixed separately.
Rows that differ on bun 1.4.0 (process.chdir entry point; the other three entry points differ identically)
expected                                                   bun 1.4.0
type number (-0)                                           type number (0)
[Object]          ({ constructor: {} })                    an instance of undefined
[Object]          ({ constructor: null })                  {}
[Object]          ({ constructor: "" })                    {}
[Object]          (has trap hides name)                    an instance of Hidden
[Foo]             (Foo instance, own constructor: {})      an instance of undefined
[Map]             (Map, constructor: null)                 Map(1) { 1: 2 }
[Object: null prototype]   (null proto with keys)          [Object: null prototype] { a: 1 }
custom depth=-1   (inspect.custom, constructor: null)      custom depth=65535
Cannot use 'in' operator to search for 'name' in 1 ...     ERR_INVALID_ARG_TYPE "an instance of undefined" (x5 primitives)
has trap throws -> trap's error propagates                 ERR_INVALID_ARG_TYPE "an instance of undefined"
Node v26.3.0 replay of the test's data (excerpt, 0 mismatches over 81 checks)
ok   [process.chdir] negative zero => "type number (-0)"
ok   [process.chdir] zero => "type number (0)"
ok   [process.chdir] constructor without a name => "[Object]"
ok   [process.chdir] constructor is null => "[Object]"
ok   [process.chdir] constructor is a falsy primitive => "[Object]"
ok   [process.chdir] constructor whose has trap hides name => "[Object]"
ok   [process.chdir] class instance shadowing its constructor with a nameless one => "[Foo]"
ok   [process.chdir] Map with a null constructor => "[Map]"
ok   [process.chdir] empty null-prototype object => "[Object: null prototype] {}"
ok   [process.chdir] null-prototype object with properties => "[Object: null prototype]"
ok   [process.chdir] custom inspect function receives depth -1 => "custom depth=-1"
ok   [process.chdir] primitive constructor number => "Cannot use 'in' operator to search for 'name' in 1"
ok   [process.chdir] primitive constructor string => "Cannot use 'in' operator to search for 'name' in abc"
ok   [process.chdir] primitive constructor boolean => "Cannot use 'in' operator to search for 'name' in true"
ok   [process.chdir] primitive constructor symbol => "Cannot use 'in' operator to search for 'name' in Symbol(desc)"
ok   [process.chdir] primitive constructor bigint => "Cannot use 'in' operator to search for 'name' in 1"
ok   [process.chdir] constructor getter throws => same error propagated
ok   [process.chdir] has trap throws => same error propagated
ok   [process.chdir] name getter throws => same error propagated
ok   [process.chdir] custom inspect throws => same error propagated
(same for EventEmitter#on and SocketAddress.parse)
v26.3.0 mismatches: 0

…ficType like node

The number arm now prints "type number (-0)" for negative zero instead of
dropping the sign. The object arm is ported as written in node: it only
says "an instance of X" when the constructor has a name property, throws
the `in` operator's TypeError when the constructor is a truthy primitive,
and otherwise renders the value with util.inspect(value, { depth: -1 })
instead of the native formatter at full depth.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9c70389b-802c-47a1-a5a9-cc74c3c76686

📥 Commits

Reviewing files that changed from the base of the PR and between 97a4363 and e7d9953.

📒 Files selected for processing (2)
  • src/jsc/bindings/ErrorCode.cpp
  • test/js/node/errors/invalid-arg-type-received.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.0 with process.chdir(-0) (prints type number (0)) and process.chdir({ constructor: {} }) (prints an instance of undefined); node v26.3.0 prints type number (-0) and [Object]. Fix and test are in this PR; test/js/node/errors/invalid-arg-type-received.test.ts fails on 1.4.0 (12/12) and passes on this branch, including under BUN_JSC_validateExceptionChecks=1. Based on d4ccab4 because main's HEAD at the time (2f5c180) does not build (#38628 fixes that).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — focused Node compat fix for determineSpecificType with thorough test coverage.

What was reviewed:

  • Exception-scope discipline in appendSpecificTypeOfObject: every fallible call (get, getIfPropertyExists, toString, view, utilInspectFunction, profiledCall) is followed by RETURN_IF_EXCEPTION; toBoolean correctly needs no check.
  • The util.inspect invocation matches the established pattern in JSURLSearchParams.cpp / WebStreamsInspectCustom.cpp (defaultGlobalObjectutilInspectFunctionMarkedArgumentBufferprofiledCall).
  • The removed fallthrough to JSValueToStringSafe for objects with a falsy constructor is now handled inside the helper via the depth: -1 inspect fallback — matches node and covered by the null-prototype / constructor: null test rows.
  • -0 detection via d == 0 && std::signbit(d) is placed after the NaN/±Infinity checks so +0 still falls through to the numeric formatter.
Extended reasoning...

Overview

This PR fixes two divergences between Bun's determineSpecificType() (in src/jsc/bindings/ErrorCode.cpp, which renders the Received ... suffix of ERR_INVALID_ARG_TYPE and ERR_INVALID_THIS) and Node's reference implementation in lib/internal/errors.js:

  1. The number arm now special-cases -0 to render as type number (-0) instead of type number (0).
  2. The object arm is extracted into a static helper appendSpecificTypeOfObject() that faithfully ports node's logic: check 'name' in value.constructor via getIfPropertyExists, throw the V8-worded in-operator TypeError when the constructor is a truthy primitive, and fall back to util.inspect(value, { depth: -1 }) for constructor-less / nameless-constructor objects.

A comprehensive new test file exercises 4 entry points (C++ validator, JS builtin $ERR_INVALID_ARG_TYPE, Rust determine_specific_type, and generated-class createInvalidThisError) against 18 rendering cases, 5 primitive-constructor throws, and 4 exception-propagation scenarios, all cross-checked against node v26.3.0.

Security risks

None. This is error-message formatting only; it does not touch auth, filesystem, network, or any privileged path. The new call into util.inspect runs user-observable code (custom inspect symbols, Proxy traps, getters), but that is exactly node's behavior and any exception is propagated cleanly rather than swallowed.

Level of scrutiny

Moderate. The change is in C++ JSC bindings with ThrowScope discipline, which is the most-blocked review category in this repo. However:

  • The helper declares its own DECLARE_THROW_SCOPE (correct, since it calls throwTypeError), while the caller keeps its existing DECLARE_TOP_EXCEPTION_SCOPE and checks with RETURN_IF_EXCEPTION immediately after — the contract towards existing callers is unchanged.
  • Every call that can enter JS or throw (object->get, getIfPropertyExists, toWTFString, toString, view, utilInspectFunction, profiledCall) is followed by RETURN_IF_EXCEPTION. toBoolean and constructEmptyObject are correctly not checked (matching neighboring code and JSC semantics).
  • The util.inspect call pattern is copied verbatim from existing sites (JSURLSearchParams.cpp:246, WebStreamsInspectCustom.cpp:57, JSBroadcastChannel.cpp:216).
  • The PR description states the change was verified under BUN_JSC_validateExceptionChecks=1.

Other factors

  • The old code fell through to JSValueToStringSafe (the native single-line formatter at unlimited depth) when constructor was falsy; the new code always returns from within the helper via the depth: -1 inspect fallback. This is an intentional behavior change that brings Bun in line with node, and the test's [Object: null prototype] and [Map] rows cover it.
  • The -0 check is ordered correctly: it comes after the NaN and ±Infinity checks (which use != and == on the double) and only fires when d == 0 with the sign bit set, so +0 and all other numbers still hit the general builder.append(d) path — confirmed by the zero and negative fraction test rows.
  • Test quality is high: exact-message assertions (not toContain), covers the negative contract (unchanged renderings for plain objects, arrays, anonymous classes, inherited names), exercises Proxy has traps and getter/inspect exceptions, and uses describe.each over the four entry points so a divergence in any one path fails independently.
  • The PR notes a pre-existing debug-only assert in ErrorCodeCache::createError when a primitive is thrown during rendering on the createInvalidThisError path; the test deliberately throws RangeError objects rather than primitives so it does not trip that unrelated issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant