Skip to content

Methods of generic classes resolve to undefined once their name is demoted to prototypeMethodAccesses #963

Description

@espetro

Summary

After the object-literal census fix, I traced the mechanism it exposed and there is a second, independent bug underneath it: when a class method's name is legitimately demoted into prototypeMethodAccesses (by a real computed-key write onto a receiver, a C.prototype[expr] read, or a genuine prototype mutation), every instance of a generic class silently loses that method. The method resolves to undefined with no trap and no fallback. The object-literal case was one accidental way to trigger the demotion; any intentional one hits the same wall.

Root cause

Two pieces combine:

  1. When a method name is in prototypeMethodAccesses, the consumers drop the dyn.hasKey guard and read the per-instance dyn bag unconditionally: packages/compiler/src/frontend/lowering/class-callbacks.ts:119 emits a bare dynKeyGet, and packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts:2152 hardcodes the guard's ternary condition to true.
  2. classPrototypeData in packages/compiler/src/frontend/lowering/class-prototypes.ts:90 returns null for info.mixinInstance || info.generic || info.genericInstance || …, so a generic class has no prototype-data helper behind the bag. The bag on a plain instance is empty, the unguarded read yields undefined, and calling the method throws TypeError: x.foo is not a function at runtime.

The IR shows the shape directly: in a build where the demotion fired, the class struct is a raw field layout whose only dynamic surface is the %dynProperties slot, 15 unique %dyn.class.property.N helpers appear against zero in a clean build of the same program, and method_value closure globals for the demoted methods appear (20 groups) where the clean build has none.

Repro

This is a legitimate demotion (not the object-literal case PR #962 fixes): obj[expr] = fn is an element write onto a real instance with an unfoldable key, exactly the shape the census is designed to catch, and Node's observable behavior is unaffected because the written key never collides with a method name.

class Ctx<T = string> {
  v: number;
  constructor() { this.v = 0; }
  json(o: T): string { return JSON.stringify(o); }
  status(n: number): Ctx<T> { this.v = n; return this; }
}

// A genuine computed-key write onto a real receiver. The census must
// assume this can hit any string name, so `json`/`status` are demoted.
function demote(obj: object, key: string, fn: () => number) {
  (obj as any)[key] = fn;
}

const c = new Ctx<{ ok: boolean }>();
demote(c, "other", () => 42);

console.log(typeof c.json, typeof c.status, c.status(200).json({ ok: true }));

Node prints:

function function {"ok":true}

The compiled binary throws TypeError: c.status is not a function (0.3.0, macOS arm64): both methods are undefined because the bag read has no prototype fallback for the generic class. I re-verified this repro against a build with the census fix applied, so it is not an artifact of the accidental trigger.

Note that the same program with a non-generic class keeps working, because classPrototypeData builds the prototype-data helper for it; it is specifically the generic/genericInstance path that returns null.

Ask

The demotion itself looks correct to me. The gap is what happens after it:

  • emit prototype data for generic instances too, so the unguarded bag read has a real fallback, or
  • keep the dyn.hasKey guard for methods of generic classes (the guard that ran before demotion) so the read falls back to the static method when the bag has no entry.

I found this while root-causing the accidental trigger fixed in PR #962, but the two are separable: that PR removes one spurious way to enter this state, while any legitimate demotion of a generic class's method still breaks it. I'm happy to prototype either direction if you have a preference.

Activity

  1. espetro commented on Oct 14, 2026

    @espetro
    ContributorAuthor

    Retested on v0.3.1: still reproduces. The release binary (macOS arm64) fails the repro with Uncaught TypeError: c.status is not a function, the same as 0.3.0. It also still fails on a source build of v0.3.1 with the fix from #962 applied, which confirms this is independent of the object-literal trigger.

  2. espetro commented on Oct 15, 2026

    @espetro
    ContributorAuthor

    Follow-up with data from shipping a fix in chttp (sigilco/chttp, PR #56). TL;DR: confirmed the trigger is broader than typeof — any expression-position mention of a generic class's method name demotes it — and 0.3.3 is still affected. Worked around by de-genericizing; no compiler change needed on our side, but the silent failure mode seems worth a fence.

    Trigger breadth. My original repro used typeof (sinkOrRes as ResponseSink).setStatus === "function" as the discriminator. That turned out to be incidental — it's just what put setStatus into prototypeMethodAccesses. After removing the typeof entirely, instances stayed methodless until the classes themselves were de-genericized. So the rule of thumb we now code against: a generic class plus any expression-position reference to one of its method names = every instance of that class loses all methods.

    Still broken on 0.3.3. Same family, different first-failure site: Uncaught TypeError: rv._setBody is not a function (0.3.1/0.3.2 failed earlier, at c.json). Probed on bench/scriptc-0.3.3-ctx-probe @ 54e5c44; verbatim script output at bench/probe-0.3.3.log on that branch.

    What lowers fine in the same build: routing and method tables (correct 405 + allow: POST before the process dies), and methods on non-generic classes (NodeResponseSink). The bug is specific to instances of generic classes with a name in prototypeMethodAccesses.

    The workaround we shipped (commit 89d3576 on PR #56): de-genericize the implementation classes, restore the generic surface as interface + const alias so the public API is unchanged under tsc:

    class CtxCore { /* non-generic: concrete fields, methods intact */ }
    interface Ctx<P extends string = string> extends CtxCore {}
    const Ctx = CtxCore;

    plus eliminating the class unions (ResponseSink | ServerResponse → ResponseSink only) so nothing discriminates on class-typed values. After the narrowing, the compiled ELF serves real 200s on 0.3.3 — routing, method tables, params, JSON, body echo.

    This matches the classPrototypeData analysis in the issue body: a generic class has no prototype-data helper behind the dyn bag, so the unguarded dynKeyGet reads an empty bag and yields undefined. The trigger shape doesn't matter to the lowered code.

    Happy to contribute a minimal corpus program exercising the generic-class demotion shape if useful. Two things that would have caught this on our side: a runtime fence (fail loudly when a demoted method is hit with no prototype-data fallback, instead of returning undefined from the dyn bag) or a coverage diagnostic that flags generic classes with demoted method names — static coverage was 100% on every broken build.

  3. alantriesagain commented on Oct 16, 2026

    @alantriesagain
    Contributor

    I ran into this too (a Signal<T> class losing its methods) and opened #1150.

    It follows your second suggestion: reads for these classes check for an own property first and fall back to the declared method.
    It also covers calls through a non-generic base type and a method the program really replaces on one instance.

  4. added a commit that references this issue on Oct 18, 2026
    d762393
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