Skip to content

.length on a declared-array/Named receiver whose runtime value has no length returns 0, not undefined — and does not throw on nullish #7853

Description

@proggeramlug

.length on a value whose declared type is string[] / a Named type, but whose runtime value carries no length, returns 0 where JavaScript returns undefined — and returns 0 where JavaScript throws.

Reachable on main today from plain annotated TypeScript. Reproducer against origin/main @ 0321c6554 (perry 0.5.1467):

type Bag = { items: string[]; label: string };
function mk(v: any): Bag { return { items: v, label: "a" }; }
function read(b: Bag): string {
  const items: string[] = b.items;   // EXPLICIT annotation — no inference involved
  return "len=" + items.length + "|0=" + items[0];
}
console.log(read(mk(["a","b"])));
console.log(read(mk(42)));
console.log(read(mk("hi")));
console.log(read(mk({ length: 7, 0: "z" })));
node   len=2|0=a   len=undefined|0=undefined   len=2|0=h   len=7|0=z
perry  len=2|0=a   len=0|0=undefined           len=2|0=h   len=7|0=z
                       ^^^^^^^

A nullish field value is worse: node throws TypeError: Cannot read properties of null (reading 'length'); perry returns 0 and continues.

Note the element read is correct in every row — only .length is wrong.

Where it is

The inline .length arm (crates/perry-codegen/src/expr/property_get.rs, the
property == "length" && (is_array_expr || is_string_expr || Named) arm) is properly
guarded: it checks the NaN-box tag, the handle band, GC_TYPE_ARRAY/GC_TYPE_STRING
and the forwarded flag before touching the header. The fallback is what is wrong.
js_value_length_f64 (crates/perry-runtime/src/value/dynamic_object.rs) ends in:

// BigInts, Promises, Errors, Maps: no `.length`.
// Return 0 to match Perry's existing fallback for missing fields
// (JS would produce `undefined`, but the generic PropertyGet slow
// path already degrades to 0 here).
_ => return 0.0,

and its GC_TYPE_OBJECT arm turns a missing length property into 0.0 too. A
nullish receiver never reaches a throw at all.

The arm is entered on a static type, and Perry enforces no declared type at runtime
— so this is the #7846 family: a claim consumed by a path whose fallback is not the
JavaScript answer.

Why it matters beyond correctness

It is currently blocking a measured optimization. #7854 lets const names = e.names
recover string[] from the receiver's declared type (worth −11.2% on
gc-handoff/apps/interp.ts), but has to keep those locals OFF the .length arm
(FnCtx::declared_only_array_locals) precisely because of this, leaving ~3% on the
table. Fixing this unblocks that and removes the special case.

Suggested shape

Make the .length arm's fallback produce the JavaScript answer rather than 0:
nullish receiver throws the ordinary TypeError; a value with no length yields
undefined; a plain object's own/inherited length is read by name (that part
already works). Either a new js_value_length_property_f64 used only from this
codegen arm, or a corrected js_value_length_f64 — the latter is a wider behavioural
change (0 → undefined) and wants its own gap-suite sweep either way.

test-files/test_gap_declared_field_type_refine_guarded.ts (added in #7854) already
covers the guarded half of this; it deliberately does not exercise the annotated
.length case, because that case fails on main.

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