Skip to content

Function-static properties assigned via IIFE pattern (((fn) => { fn.method = ...; })(fn)) don't work — both same-module and cross-module #618

Description

@proggeramlug

Repro

Same-module

function sql(strings: any, ...params: any[]) {
  return { kind: 'sql', s: strings };
}

((sql2: any) => {
  sql2.identifier = function (v: any) {
    return { kind: 'name', v };
  };
})(sql);

console.log('sql.identifier:', (sql as any).identifier);
console.log('sql.identifier("foo"):', (sql as any).identifier("foo"));

Bun (correct):

sql.identifier: [Function]
sql.identifier("foo"): { kind: "name", v: "foo" }

Perry (bug):

sql.identifier: undefined
sql.identifier("foo"): [object Object]

The property read (sql.identifier) returns undefined, but the call (sql.identifier("foo")) somehow produces an object that prints as [object Object]. Both perry behaviors are wrong, and they're inconsistent with each other.

Cross-module

Move the function + IIFE into a separate compilePackages module, import the function:

// node_modules/repro-pkg2/static_iife.js
export function sql(strings, ...params) { return { kind: 'sql', s: strings }; }
((sql2) => {
  sql2.identifier = function (v) { return { kind: 'name', v }; };
})(sql);
// probe_fn_static_xmod.ts
import { sql } from 'repro-pkg2/static_iife.js';
console.log((sql as any).identifier("foo"));

Perry:

sql.identifier: undefined
sql.identifier("foo"): 0    ← raw NaN-box of class id / nothing

Cross-module makes it worse: the call returns 0 (a raw integer, possibly a class id reinterpreted as f64) instead of an object.

What this pattern is

This is how TypeScript compiles namespace declarations. tsc translates:

function sql(...) { ... }
namespace sql {
  export function identifier(v: any) { ... }
  export function param(v: any, encoder: any) { ... }
  export function raw(s: string) { ... }
  // ...
}

into:

function sql(...) { ... }
((sql2) => {
  function identifier(v) { ... }
  sql2.identifier = identifier;
  function param2(v, encoder) { ... }
  sql2.param = param2;
  // ...
})(sql || (sql = {}));

Every TS library that exports a "callable with static methods" surface (drizzle's sql, lodash chains, jQuery-style libs, custom validators with helper methods) ships this exact shape in its compiled JS.

Why it matters for #488

drizzle-orm's sql tag function is exactly this pattern (drizzle-orm/sql/sql.js:283-329):

function sql(strings, ...params) { ... }
((sql2) => {
  sql2.empty = empty;
  sql2.fromList = fromList;
  sql2.raw = raw;
  sql2.join = join;
  sql2.identifier = identifier;
  sql2.placeholder = placeholder2;
  sql2.param = param2;
})(sql || (sql = {}));

drizzle uses these throughout: sql.identifier(name), sql.raw("..."), sql.join(chunks), sql.param(value, encoder). Each call returns 0 instead of the expected SQL chunk, so colEntries.map(...) produces [0, 0] instead of [Name("id"), Name("name")], and the eventual SQL template contains numbers instead of chunks.

Filed after #608 landed and the next acceptance run surfaced this as the next layer.

Where I'd start digging

crates/perry-hir/src/lower/... — the IIFE call ((sql2) => { sql2.identifier = ...; })(sql) lowers to:

  1. Allocate a closure for the arrow
  2. Call it with sql as the arg
  3. Inside the body, sql2.identifier = function(...) assigns a property

Step 3 is where it breaks: assigning a property onto a parameter that holds a function value. perry's HIR/codegen probably treats sql2 as a function (typed as Function) and silently drops property assignments, since Function isn't a "real" object in perry's static type system.

Two paths:

  1. Treat function values as plain objects for the purpose of property reads/writes — same as Node/Bun. Functions ARE objects in JS; perry should track per-function side-table state for any properties added at runtime.
  2. At least for the IIFE-namespace pattern (recognizable shape: ((x) => { x.foo = ...; ... })(fn)), lift the assignments to a side-table keyed by the function pointer, and route fn.foo lookups through that table.

Option 1 is cleaner and matches spec; Option 2 is a narrower fix but covers the dominant real-world use.

Workaround

Patch user-side libraries to use a wrapper class instead of a function with static methods. Not realistic for drizzle without forking.

Refs

Blocks #488. Surfaced from acceptance run on v0.5.712 after #608 landed.

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