Skip to content

A dynamically-keyed property read on a primitive string returns undefined (const k="charAt"; "abc"[k]), while the literal key and the call form work #9815

Description

@proggeramlug

Summary

Reading a method off a primitive string with a dynamically-keyed member
expression returns undefined. The same read with a literal key works, and
calling through the dynamic key works — so only the value read is wrong, which
is what makes it easy to miss and nasty in practice.

const k = "charAt";
const s = "abcde";

typeof s[k]            // perry: "undefined"   node: "function"
s[k](1)                // perry: "b"           node: "b"        (call form is fine)
typeof s["charAt"]     // (literal key — not affected, see below)
typeof String.prototype[k]  // perry: "function"  node: "function"
String.prototype[k].call(s, 1) // perry: "b"      node: "b"

Measured on a compiled binary from 1d63fa91f (runtime-only branch), against
node on the same source. 46-case probe, one differing line — this one.

Why it matters more than the one-liner suggests

Every "take the method, then call it" shape breaks, and that is the ordinary way
bundled and generic code dispatches:

for (const name of ["trim", "toUpperCase"]) {
  const fn = s[name];        // undefined in perry
  fn.call(s);                // TypeError: fn.call is not a function
}
if (typeof s[name] === "function") { /* never taken */ }
const m = s[name]?.bind(s);  // silently undefined

I hit it writing a probe that enumerated String.prototype and called each
method as recv[k].apply(recv, args): all 79 methods threw TypeError in
perry where node returned a value, purely because recv[k] read as undefined.
That is the realistic blast radius — any reflective loop over a primitive's
methods.

Where it is not

  • String.prototype[k] (dynamic key, object receiver) is fine.
  • s[k](...) (dynamic key, call position) is fine — the call goes through
    js_native_call_method, which resolves the name on the prototype.
  • So the defect is specific to a property GET on a primitive receiver with a
    non-literal key
    returning without consulting the primitive's prototype.

What I have not established

Whether the literal-key value read (typeof s["charAt"], no call) is affected,
whether number/boolean/bigint receivers behave the same, and whether a
user-assigned String.prototype.foo is affected as well. My probe covered the
dynamic-key case only; those three are the first things to add to a fixture.

Falsifier for whoever takes it

typeof s[k] must be "function" and s[k] === String.prototype[k] must be
true for a primitive s and a variable k — and the fix must not slow the
call form, which already resolves correctly through a different path. If a
change makes the read work but routes the call through the read, check the
primitive-method dispatch cost before landing: that path is the subject of
#9810.

Provenance

Found while measuring #9800 (built-in callee receives the primitive, not a
ToObject wrapper). It is not that change: the divergence is identical with
#9800's env gate on and off, so it predates it and is independent of it.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

Activity

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