Skip to content

perry-codegen: s[0] on any-typed param holding a string returns length instead of first char #514

Closed

Description

@proggeramlug

Summary

s[0] (and s?.[0]) on a parameter typed any that holds a string at runtime returns the string's length as a subnormal float instead of the first character. With an explicit string type annotation the same code works correctly.

This is a hono blocker (#421): hono's mergePath = (base, sub, ...rest) => … has untyped params, so every base?.[0] === "/" ? "" : "/" check inside its template literal misfires. Result: mergePath('/', '/api') returns ////api instead of /api, every registered route's path field is a mangled string.

Repro

// perry compile test.ts -o test && ./test
function fn(s: any) {
  console.log('any s[0]:', s[0]);    // expected: '/'  actual: 2e-323 (= 4 as subnormal, the length of '/foo')
}
fn('/foo');

const f2 = (s: string) => {
  console.log('typed s[0]:', s[0]);  // expected: '/'  actual: '/'   ✓ works
};
f2('/foo');

Output:

any s[0]: 2e-323
typed s[0]: /

2e-323 ≈ 1.97626e-323 = 4 as a subnormal double bit pattern. For a 1-char string '/', the same path returns 5e-324 ≈ 1.

Why

The HIR lowers s[0] to Expr::IndexGet { object: LocalGet(s), index: 0 }. Codegen's IndexGet dispatch in crates/perry-codegen/src/expr.rs walks the receiver type:

  • string → js_string_char_at(s, 0) ✓
  • any → falls through to the array fast-path which reads length (or some array slot 0) on the NaN-boxed string pointer interpreted as an ArrayHeader.

The receiver-class-resolution is_string_expr doesn't fire on a generic any parameter. Without a static guarantee, codegen emits the inline array-IndexGet instructions, which read the length field of what it thinks is an ArrayHeader — but the pointer is actually a StringHeader, and the offsets happen to land on the byte-length field.

Likely fix

crates/perry-codegen/src/expr.rs::IndexGet — when the receiver is any-typed (or static type can't be proven array vs string), emit the runtime dispatch path that NaN-tag-checks the value:

  • STRING_TAG/SHORT_STRING_TAG → js_string_char_at
  • POINTER_TAG + GC type ARRAY → array fast-path
  • pointer to non-array → treat as object with numeric key (existing fallback)

A simpler intermediate fix: in IndexGet's any-typed arm, route through js_index_get_dynamic(value, idx_value) runtime helper that does the tag-check at runtime. There's already similar dispatch in js_native_call_method for .map/.filter/.forEach on any-typed receivers — same shape.

Why it matters

Blocks #421 (hono compile end-to-end) past route registration. Probably also affects #420 (drizzle) which uses similar untyped helper functions. Any TS code without strict types + string indexing trips this path.

Activity

  1. added 2 commits that reference this issue on May 6, 2026
  2. added a commit that references this issue on May 19, 2026
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