.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.
.lengthon a value whose declared type isstring[]/ aNamedtype, but whose runtime value carries no length, returns 0 where JavaScript returnsundefined— and returns 0 where JavaScript throws.Reachable on
maintoday from plain annotated TypeScript. Reproducer againstorigin/main@0321c6554(perry 0.5.1467):A nullish field value is worse: node throws
TypeError: Cannot read properties of null (reading 'length'); perry returns0and continues.Note the element read is correct in every row — only
.lengthis wrong.Where it is
The inline
.lengtharm (crates/perry-codegen/src/expr/property_get.rs, theproperty == "length" && (is_array_expr || is_string_expr || Named)arm) is properlyguarded: it checks the NaN-box tag, the handle band,
GC_TYPE_ARRAY/GC_TYPE_STRINGand 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:and its
GC_TYPE_OBJECTarm turns a missinglengthproperty into0.0too. Anullish 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.namesrecover
string[]from the receiver's declared type (worth −11.2% ongc-handoff/apps/interp.ts), but has to keep those locals OFF the.lengtharm(
FnCtx::declared_only_array_locals) precisely because of this, leaving ~3% on thetable. Fixing this unblocks that and removes the special case.
Suggested shape
Make the
.lengtharm's fallback produce the JavaScript answer rather than 0:nullish receiver throws the ordinary
TypeError; a value with nolengthyieldsundefined; a plain object's own/inheritedlengthis read by name (that partalready works). Either a new
js_value_length_property_f64used only from thiscodegen arm, or a corrected
js_value_length_f64— the latter is a wider behaviouralchange (0 →
undefined) and wants its own gap-suite sweep either way.test-files/test_gap_declared_field_type_refine_guarded.ts(added in #7854) alreadycovers the guarded half of this; it deliberately does not exercise the annotated
.lengthcase, because that case fails onmain.