Skip to content

perry-codegen: import type { Class } silently no-ops method calls on parameters of that class type #446

Description

@proggeramlug

What happened

When a class is imported into a consumer module via TypeScript import type { Foo }, perry codegen drops the method-dispatch path for parameters typed Foo. At runtime, obj.method(...) calls on those parameters silently no-op — the body never runs, no TypeError is thrown, the call appears to succeed. Direct field reads/writes on the same object still work, because perry resolves those structurally.

This is a latent bug that bites whenever a refactor that splits a large class file uses import type for class types in a sibling module. In the wild it caused the @codehz/ecs library's command-buffer execution path to silently drop every world.set(...) — basic demos that bun runs cleanly produce wrong values or crash with downstream archetype errors when compiled with perry.

Minimal reproduction

Four files. Type-only import of the class is the only trigger.

// issue_a.ts — defines the class
import { bumpBodyRan } from "./issue_counter.ts";

export class Inner {
  readonly adds = new Map<number, string>();

  setAdd(k: number, v: string): void {
    bumpBodyRan();
    console.log("[Inner.setAdd] body ran");
    this.adds.set(k, v);
  }
}
// issue_counter.ts — independent counter, proves the body runs (or doesn't)
let n = 0;
export function bumpBodyRan(): void { n++; }
export function getBodyRan(): number { return n; }
// issue_consumer.ts — consumer with TYPE-ONLY import (the bug trigger)
import type { Inner } from "./issue_a.ts";

export function consume(inner: Inner): void {
  console.log("[consume] typeof inner.setAdd =", typeof (inner as any).setAdd);
  console.log("[consume] adds.size before  =", inner.adds.size);
  inner.setAdd(1, "hello");
  console.log("[consume] adds.size after   =", inner.adds.size);
}
// issue_main.ts — driver
import { Inner } from "./issue_a.ts";
import { consume } from "./issue_consumer.ts";
import { getBodyRan } from "./issue_counter.ts";

const inner = new Inner();
consume(inner);
console.log("Inner.setAdd body ran:", getBodyRan(), "time(s)");
console.log("inner.adds.get(1)    :", inner.adds.get(1));

Expected output (bun)

[consume] typeof inner.setAdd = function
[consume] adds.size before  = 0
[Inner.setAdd] body ran
[consume] adds.size after   = 1
Inner.setAdd body ran: 1 time(s)
inner.adds.get(1)    : hello

Actual output (perry 0.5.395)

[consume] typeof inner.setAdd = undefined
[consume] adds.size before  = 0
[consume] adds.size after   = 0          <-- mutation never happened
Inner.setAdd body ran: 0 time(s)          <-- body never ran
inner.adds.get(1)    : undefined

No exception is thrown — the call silently no-ops.

One-character fix confirms cause

Change import type { Inner }import { type Inner } in issue_consumer.ts. Nothing else. Perry now produces:

[consume] typeof inner.setAdd = undefined         <-- still wrong, see secondary issue below
[consume] adds.size before  = 0
[Inner.setAdd] body ran                            <-- body runs!
[consume] adds.size after   = 1
Inner.setAdd body ran: 1 time(s)
inner.adds.get(1)    : hello

So the trigger is unambiguously the import type form on the class.

Secondary issue: typeof obj.method is wrong even after the fix

Even with import { type Inner }, typeof inner.setAdd reports "undefined" instead of "function". Direct method invocation works, but property-existence checks via typeof / in / hasOwnProperty will give wrong answers on methods of class-typed parameters. Worth tracking separately, but mentioned here in case it shares a root cause.

Hypothesized cause

Perry strips type-only imports during codegen — they don't produce IR/object-file references. When a parameter declared x: Foo later appears in x.method(...) in the same module, perry needs some binding for Foo's method dispatch (whether via prototype, vtable, or direct call). With the import gone, the dispatch lookup yields nothing and lowering emits a no-op call instead of either:

  • (a) preserving the runtime metadata for the type (treat import type of a class the same as import for codegen purposes — the TS spec semantically requires the value to exist at runtime), or
  • (b) falling back to a dynamic prototype-chain lookup with a TypeError on miss.

Either path would fix this.

Real-world impact

Caught while debugging @codehz/ecs (a TypeScript ECS library). Three of the library's modules used import type { ComponentChangeset } and import type { Archetype } to satisfy lint rules around unused runtime imports. Under perry, the entire command-buffer execution path silently no-op'd, causing every world.set(...) to be dropped. Bun ran the same code correctly. Bug had been latent in the codebase since a Jan-2026 module-split refactor; a recent unrelated commit changed which call paths exercised it, surfacing the failure as "all examples broken now."

Environment

  • perry 0.5.395
  • macOS Darwin 25.4.0 (arm64)
  • TS source files compiled directly via perry compile <main>.ts -o <out>

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