Repository navigation
Generic-class specializations are distinct constructor objects: a.constructor !== Gen, and two specializations compare unequal #7757
Description
Two specializations of one generic class are distinct constructor objects, and an instance's .constructor is not the class the user wrote.
Repro
class Gen<T> { v: T | undefined; }
const a = new Gen<number>();
const b = new Gen<string>();
const c = new Gen(); // never specialized
console.log(a.constructor === Gen, b.constructor === Gen, c.constructor === Gen);
console.log(a.constructor === b.constructor);
console.log(Object.getPrototypeOf(a) === Gen.prototype,
Object.getPrototypeOf(a) === Object.getPrototypeOf(b));$ node --experimental-strip-types t.ts
true true true
true
true true
$ ./t
false false true
false
true false
Why
new Gen<number>() is monomorphized into a separate class Gen$num with its own class id (monomorph::mangle::generate_specialized_name), and the instance carries that id. TypeScript erases type arguments, so at runtime there is exactly one Gen — the specializations are an implementation detail that is leaking through every id-keyed identity surface.
This is the third face of the same leak:
instanceof— fixed ininstanceofa Map/Set SUBCLASS is false (m instanceof MyMap); only the native base edge survives #7575 / PR fix(instanceof): #7575 — a monomorphized generic class is an instance of the generic it came from #7631, by recordingClass::specialized_fromand teaching the chain walk a specialization→generic edge.constructor.name— fixed in constructor.name of a generic-class instance reports the mangled specialization (Gen$num), not Gen #7632 / PR fix(monomorph): a specialized generic class reports the generic's name, not Gen$num (#7632) #7756, by registering the origin's display name against the specialization's class id.- constructor / prototype IDENTITY — this issue. Not addressed by either, because both fixed a lookup (a chain walk, a name table) rather than the object identity itself.
Worth noting that #7756 makes this one harder to see, not easier: after it, a.constructor.name === b.constructor.name === "Gen" while a.constructor !== b.constructor. Two things that print identically and compare unequal is a worse failure mode for a user to debug than an obviously-wrong name, so this is the natural follow-up to schedule rather than leave implicit.
Already inconsistent today
Object.getPrototypeOf(a) === Gen.prototype is true while a.constructor === Gen is false — so the prototype edge and the constructor edge already disagree with each other about whether the specialization is Gen. Whatever the fix is, that pair should come out consistent.
Shape of a fix (unverified)
The specialization exists to monomorphize method bodies; nothing about that requires a distinct constructor object or prototype. Candidates, in rough order of blast radius:
- Point the specialization's constructor/prototype registry entries at the generic's, keeping only the specialized method table — i.e. treat
specialized_fromas an identity alias for the reflective surfaces, the wayinstanceofa Map/Set SUBCLASS is false (m instanceof MyMap); only the native base edge survives #7575 already treats it forinstanceof. - Stop specializing classes whose type arguments do not change any lowering decision (the erased-generic majority), so no second class exists to diverge.
driver.rsalready declines to specialize when a type argument is still an unresolved type var (the rxjsObservable<R>case) — this would extend that judgment.
Option 2 also removes the code-size cost of specializing classes that monomorphization does not benefit.
Not urgent
Every shape here is reflective/identity code, not a miscompile: instanceof, field access, method dispatch and .name are all correct. Filing so the last face of the leak is tracked rather than rediscovered.
Activity
fix(runtime): generic specializations share the generic's prototype o…
fix(runtime): generic specializations share the generic's prototype o…
proggeramlug commentedon Aug 10, 2026
Second attempt at the
.constructoredge. Not fixed, but the mechanism is now located and it is not what this issue says it is — so the next attempt should not start from monomorphization..constructoris const-folded toundefinedfor a non-escaping instancecrates/perry-codegen/src/expr/property_get.rs:813:The rule it implements is correct — "reading a missing field on a closed-shape object literal must produce
undefined" — butconstructoris not a missing field. Every object has one, and the read never reaches the runtime at all.Confirmed in the emitted IR:
a.constructor === Gencompiles toThat is why
class Empty {}givese.constructor === undefined— the finding from the previous round, now explained. It has nothing to do with generics; generics merely made it visible because a specialization has a second class id to disagree about.A fix at that site works, and is not sufficient
I folded
constructorto the class ref there (INT32_TAG | class_id, resolved throughClass::specialized_fromsoGen$numreportsGen). Module-scope results go from wrong to right:e.constructor === Emptyundefined)Emptyp.constructor === PlainBut it does not fire inside a function body —
ctx.local_typesdoes not carryNamed(<class>)for the local there, andscalar_replacedrecords onlyfield -> ssa value, no class. So the same code is right at module scope andundefinedone function deeper. I reverted rather than ship a.constructorwhose answer depends on whether the instance escaped and which scope it sits in; uniformly wrong is easier to reason about than intermittently right.And a third path exists
When the instance escapes, the read is not folded at all — it goes through a PIC (
pget.recv_merge/pic.mergein the IR) and still does not equal the class ref. That path is notinstance_constructor_valueeither: instrumented at its first statement, under an escaping receiver, it never fires.So the shape of a real fix
local_typesfor function-body locals, or record the class alongsidescalar_replaced), then foldconstructorto the origin class ref.specialized_frommapping matter — and by then it is one line in each.Method note
Two rounds were lost to instrumentation artifacts, both worth avoiding:
constructorarm is guarded onobject_static_prototype(obj).is_none(), and that accessor appears to materialize on first call — so a probe that merely reads it flips the branch under test.instance_constructor_value"is never called" is conditional on the receiver: with a folded (non-escaping) instance the runtime is never reached at all, so an absent log line means the fold fired, not that the function is dead.The prototype half of this issue is fixed and merged (#7762); this comment is only about the
.constructoredge.docs(changelog): add fragment for the #7757 specialization identity fix
fix(runtime): a generic specialization answers with its generic's con…