Skip to content

runtime: Number/Boolean/Symbol/BigInt prototype methods don't brand-check this (reflective call returns [object Object] instead of TypeError) (#3662) #4100

Closed

Description

@proggeramlug

Part of the #3662 cluster (builtins must throw the spec error on an incompatible this).

The primitive-wrapper prototype methods do not perform the spec this brand
check when invoked reflectively on an incompatible receiver. Instead the call
falls back to Object.prototype and returns "[object Object]" rather than
throwing a TypeError.

Affected (each should throw TypeError on a non-matching receiver):

  • Number.prototype.valueOf, Number.prototype.toLocaleString
  • Boolean.prototype.toString, Boolean.prototype.valueOf
  • Symbol.prototype.toString, Symbol.prototype.valueOf
  • BigInt.prototype.toString, BigInt.prototype.valueOf

(Number.prototype.toFixed/toString/toExponential/toPrecision already throw via
the computed-key dispatch.)

Repro

Number.prototype.valueOf.call({});      // Perry: "[object Object]"  Node: TypeError
Boolean.prototype.toString.call({});    // Perry: "[object Object]"  Node: TypeError
Symbol.prototype.toString.call({});     // Perry: "[object Object]"  Node: TypeError
BigInt.prototype.valueOf.call({});      // Perry: "[object Object]"  Node: TypeError

Related wrong-value bug

Even on a valid receiver, the reflective toString returns the wrong value
(the generic Object.prototype.toString is used):

Symbol.prototype.toString.call(Symbol("x")); // Perry: "[object Symbol]"  Node: "Symbol(x)"
BigInt.prototype.toString.call(5n, 2);       // Perry: "[object BigInt]"  Node: "101"

Suggested fix

Install dedicated brand-checking thunks on Number/Boolean/Symbol/BigInt
.prototype (mirror object/collection_proto_thunks.rs): read IMPLICIT_THIS,
verify it is the matching primitive (or its boxed wrapper), throw TypeError
otherwise, and re-dispatch to the existing per-type logic via
js_native_call_method — which also fixes the wrong-value reflective toString.

Activity

  1. andrewtdiz commented on Jun 2, 2026

    @andrewtdiz
    Contributor

    I am taking this with #4099 and #4102 in one runtime reflective-builtins PR cut. Planned scope: fix reflective collection set receiver validation, primitive wrapper prototype brand/value dispatch, and dynamic instanceof / constructor @@hasInstance recognition for Array/Object/Date without taking the separate function-source or typed-array-view work.

  2. proggeramlug commented on Jun 2, 2026

    @proggeramlug
    ContributorAuthor

    Investigation note (saves the next implementer a dead-end):

    The obvious fix — install brand-checking thunks on Number.prototype /
    Boolean.prototype in global_this::populate_builtin_prototype_methods (the
    exact recipe that works for the collection prototypes in
    collection_proto_thunks.rs) — does not work here.

    Confirmed empirically:

    • populate_builtin_prototype_methods("Number"/"Boolean", proto) is called
      (lazily, when the globalThis builtin singleton is first built), and the
      thunks install onto that singleton prototype object.
    • But reflective Number.prototype.valueOf.call({}) still returns
      "[object Object]" and never reaches the installed thunk — even after the
      singleton has been populated.

    So Number.prototype.valueOf does not read its valueOf from the
    singleton-populated prototype the way Map.prototype.set does. It resolves the
    method via a different path (it ends up at the shared
    global_this_builtin_noop_thunk, whose reflective .call re-dispatches by
    name onto the receiver — js_native_call_method({}, "valueOf") → falls
    through to Object.prototype.valueOf → [object Object]).

    The real fix needs interception at the actual reflective-dispatch site, e.g.
    try_dispatch_value_called_proto_method / the value-called-proto handling in
    native_call_method.rs, with enough context to know the method came off
    Number/Boolean/Symbol/BigInt.prototype (the noop currently loses that
    origin), or making Number.prototype resolve to the populated singleton
    prototype. This is a deeper change than the collection recipe, hence deferred.

    Scope reminder for this issue: Number.prototype.{valueOf,toLocaleString,
    toFixed,toString,toExponential,toPrecision}, Boolean.prototype.{toString,
    valueOf}, Symbol.prototype.{toString,valueOf}, BigInt.prototype.{toString,
    valueOf} on an incompatible receiver must throw TypeError; the same fix also
    corrects reflective Symbol/BigInt toString returning [object Symbol] /
    [object BigInt] (should be Symbol(x) / the radix string).

  3. andrewtdiz commented on Jun 2, 2026

    @andrewtdiz
    Contributor

    Draft PR opened for this: #4115

  4. proggeramlug commented on Jun 2, 2026

    @proggeramlug
    ContributorAuthor

    Reopening — #4112 fixed most of this (computed-key access + Symbol/BigInt + the wrong-value reflective toString), but a residual remains for the direct / extracted member-access form of Number.prototype.* and Boolean.prototype.*. Verified on main (d990b021e):

    // computed access — correctly throws (fixed by #4112)
    (Number.prototype as any)["valueOf"].call({})   // TypeError ✓
    
    // direct member access — still NOT brand-checked
    Number.prototype.valueOf.call({})               // Perry: "[object Object]"   Node: TypeError
    Number.prototype.toString.call({})              // Perry: "[object Object]"   Node: TypeError
    Boolean.prototype.valueOf.call({})              // Perry: "[object Object]"   Node: TypeError
    
    // extraction has the same gap
    const v = Number.prototype.valueOf; v.call({})  // Perry: "[object Object]"   Node: TypeError

    Symbol/BigInt direct access does throw (no codegen fast-path), so this is specific to Number/Boolean. The direct Number.prototype.<m> read is lowered by codegen to a path that resolves the method without going through the installed brand-check thunk (it ends up at the Object.prototype fallback), whereas the computed ["<m>"] read does a generic property get that hits the thunk. The fix is to make the direct-member Number.prototype.<m> / Boolean.prototype.<m> value read resolve to the same installed thunk (or route the reflective dispatch through the brand check regardless of access form).

    (Same direct-vs-computed split was visible in the original triage; #4112's primitive_proto_thunks only intercepts the computed/generic path.)

  5. proggeramlug commented on Jun 2, 2026

    @proggeramlug
    ContributorAuthor

    Correction to the root cause above — the HIR for the direct and computed property reads is identical, so it's not a property-read divergence. The real trigger is the statically-typed .call lowering:

    Number.prototype.valueOf.call({})              // Perry: "[object Object]"  Node: TypeError
    (Number.prototype as any).valueOf.call({})     // throws ✓  (as-any on receiver)
    (Number.prototype.valueOf as any).call({})     // throws ✓  (as-any on method)
    Number.prototype.valueOf.call({} as any)       // "[object Object]"  (as-any on arg — no effect)

    When Number.prototype.valueOf is statically typed (TS knows it as () => number), codegen lowers the .call(...) through a typed/optimized dispatch path that resolves the method without going through the installed primitive_proto_thunks brand-check. Erasing the type with as any on the receiver or the method drops it to the dynamic js_native_call_method path, which hits the thunk and throws correctly.

    So #4112's thunks are correct; the remaining gap is that the typed-.call codegen fast-path for Number/Boolean prototype methods bypasses them. This is a very niche reflective pattern (TS would normally reject the receiver-type mismatch anyway), so low priority — but tracking it for completeness.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions