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.
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'sSchema.tsfailing to initialize (#1758): effect'smake(ast)returnsclass SchemaClass { static ast = ast }, so every schema ends up sharing one class with anundefined(or last-write-wins).ast.Minimal repros
(1) Class identity — a class expression evaluated twice should yield distinct classes:
(2) Static field initializers are dropped for class expressions (any context):
(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(theast::Expr::Classarm, ~line 1280) lowers a class expression by pushing one class intoctx.pending_classesunder a single synthetic name and returning aClassRefto it. Static fields are initialized once via the module'sinit_static_fields_latephase, against the shared class — at which point any initializer that references the factory's parameters/locals readsundefined(the factory hasn't run; and there is only one class instance to hold the value). #894 added an eagerRegisterClassStaticSymbolfor computed/Symbol-key static fields (e.g.static [TypeId] = variance) sequenced in front of theClassRef, 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"andimport * as S from "effect/Schema"throwTypeError: Cannot read properties of undefined (reading '_tag')duringSchema.tsinit, becauseStruct(...)callsAST.typeAST(field.ast)wherefield.astisundefined.Refs #1758, #321.