Repository navigation
Methods of generic classes resolve to undefined once their name is demoted to prototypeMethodAccesses #963
Description
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:
- When a method name is in
prototypeMethodAccesses, the consumers drop thedyn.hasKeyguard and read the per-instance dyn bag unconditionally:packages/compiler/src/frontend/lowering/class-callbacks.ts:119emits a baredynKeyGet, andpackages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts:2152hardcodes the guard's ternary condition to true. classPrototypeDatainpackages/compiler/src/frontend/lowering/class-prototypes.ts:90returnsnullforinfo.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 yieldsundefined, and calling the method throwsTypeError: x.foo is not a functionat 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.hasKeyguard 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
espetro commentedon Oct 14, 2026
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.fix(router): avoid scriptc #963 method-name demotion in Ctx/_guard/_b…
docs: lift the Map/Set ban — compiles clean under scriptc 0.3.3
docs: lift the Map/Set ban — compiles clean under scriptc 0.3.3
espetro commentedon Oct 15, 2026
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 putsetStatusintoprototypeMethodAccesses. After removing thetypeofentirely, 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, atc.json). Probed onbench/scriptc-0.3.3-ctx-probe@54e5c44; verbatim script output atbench/probe-0.3.3.logon that branch.What lowers fine in the same build: routing and method tables (correct
405+allow: POSTbefore the process dies), and methods on non-generic classes (NodeResponseSink). The bug is specific to instances of generic classes with a name inprototypeMethodAccesses.The workaround we shipped (commit
89d3576on PR #56): de-genericize the implementation classes, restore the generic surface as interface + const alias so the public API is unchanged under tsc:plus eliminating the class unions (
ResponseSink | ServerResponse→ResponseSinkonly) 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
classPrototypeDataanalysis in the issue body: a generic class has no prototype-data helper behind the dyn bag, so the unguardeddynKeyGetreads an empty bag and yieldsundefined. 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
undefinedfrom the dyn bag) or a coverage diagnostic that flags generic classes with demoted method names — static coverage was 100% on every broken build.feat(router): add app.fetch Web Standards surface, keep app.handler a…
feat(router): multipart/form-data uploads — per-route opt-in, streami…
alantriesagain commentedon Oct 16, 2026
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.
Find methods of generic classes when a write can replace methods by name
Keep declared methods of generic class instances after a computed-key…