Skip to content

perry-hir: class expressions hoisted to a single shared class — no per-evaluation identity or static-field state (blocks effect Schema #1758) #1772

Description

@proggeramlug

Summary

Perry hoists a class expression to a single shared class, so every evaluation of the same class { ... } expression returns the same class object. This breaks the "factory function returns a class" pattern — most importantly each returned class cannot have its own static field state. This is the root blocker behind effect's Schema.ts failing to initialize (#1758): effect's make(ast) returns class SchemaClass { static ast = ast }, so every schema ends up sharing one class with an undefined (or last-write-wins) .ast.

Minimal repros

(1) Class identity — a class expression evaluated twice should yield distinct classes:

function make(ast: any) { return class C { static ast = ast }; }
const A = make("AAA");
const B = make("BBB");
console.log(A === B);      // node: false   perry: true
console.log((A as any).ast, (B as any).ast);  // node: AAA BBB   perry: undefined undefined

(2) Static field initializers are dropped for class expressions (any context):

const T = class { static x = 77 };
console.log((T as any).x); // node: 77   perry: undefined

(A top-level class declaration class T { static x = 77 } works; only class expressions — and class declarations nested inside a function — lose their static field initializers.)

Root cause

crates/perry-hir/src/lower/lower_expr.rs (the ast::Expr::Class arm, ~line 1280) lowers a class expression by pushing one class into ctx.pending_classes under a single synthetic name and returning a ClassRef to it. Static fields are initialized once via the module's init_static_fields_late phase, against the shared class — at which point any initializer that references the factory's parameters/locals reads undefined (the factory hasn't run; and there is only one class instance to hold the value). #894 added an eager RegisterClassStaticSymbol for computed/Symbol-key static fields (e.g. static [TypeId] = variance) sequenced in front of the ClassRef, but regular-named static fields (static ast = ast) are not handled, and even the symbol path writes to the one shared class.

Properly supporting this needs per-evaluation class identity: each time a class expression evaluates, produce a distinct runtime class object carrying its own static fields (initialized from the enclosing scope at that moment). That's a codegen + runtime change (class objects as first-class per-evaluation values rather than a single hoisted definition).

Impact

Blocks #1758 / Effect Schema end-to-end (#321): import { Effect } from "effect" and import * as S from "effect/Schema" throw TypeError: Cannot read properties of undefined (reading '_tag') during Schema.ts init, because Struct(...) calls AST.typeAST(field.ast) where field.ast is undefined.

Refs #1758, #321.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions