Skip to content

Generic-class specializations are distinct constructor objects: a.constructor !== Gen, and two specializations compare unequal #7757

Closed

Description

@proggeramlug

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:

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:

  1. Point the specialization's constructor/prototype registry entries at the generic's, keeping only the specialized method table — i.e. treat specialized_from as an identity alias for the reflective surfaces, the way instanceof a Map/Set SUBCLASS is false (m instanceof MyMap); only the native base edge survives #7575 already treats it for instanceof.
  2. Stop specializing classes whose type arguments do not change any lowering decision (the erased-generic majority), so no second class exists to diverge. driver.rs already declines to specialize when a type argument is still an unresolved type var (the rxjs Observable<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

  1. proggeramlug commented on Aug 10, 2026

    @proggeramlug
    ContributorAuthor

    Second attempt at the .constructor edge. 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.

    .constructor is const-folded to undefined for a non-escaping instance

    crates/perry-codegen/src/expr/property_get.rs:813:

    if ctx.scalar_replaced.contains_key(id) {
        return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
    }

    The rule it implements is correct — "reading a missing field on a closed-shape object literal must produce undefined" — but constructor is not a missing field. Every object has one, and the read never reaches the runtime at all.

    Confirmed in the emitted IR: a.constructor === Gen compiles to

    %r20 = bitcast double 0x7FFC000000000001 to i64   ; TAG_UNDEFINED  <- a.constructor
    %r21 = bitcast double 0x7FFE000000000001 to i64   ; INT32_TAG|1    <- Gen
    %r22 = call i64 @js_eq(i64 %r20, i64 %r21)

    That is why class Empty {} gives e.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 constructor to the class ref there (INT32_TAG | class_id, resolved through Class::specialized_from so Gen$num reports Gen). Module-scope results go from wrong to right:

    before after
    e.constructor === Empty false (undefined) true, name Empty
    p.constructor === Plain true true

    But it does not fire inside a function body — ctx.local_types does not carry Named(<class>) for the local there, and scalar_replaced records only field -> ssa value, no class. So the same code is right at module scope and undefined one function deeper. I reverted rather than ship a .constructor whose 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.merge in the IR) and still does not equal the class ref. That path is not instance_constructor_value either: instrumented at its first statement, under an escaping receiver, it never fires.

    So the shape of a real fix

    1. Give the scalar-replaced fold the class it was built from (either populate local_types for function-body locals, or record the class alongside scalar_replaced), then fold constructor to the origin class ref.
    2. Find and fix the escaping/PIC path, which is a third mechanism again.
    3. Only then does the specialized_from mapping matter — and by then it is one line in each.

    Method note

    Two rounds were lost to instrumentation artifacts, both worth avoiding:

    • The tail's constructor arm is guarded on object_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 .constructor edge.

  2. added a commit that references this issue on Aug 11, 2026
  3. added a commit that references this issue on Aug 11, 2026
    c740f88
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