ADR 0013: Class Variables, Class-Side Methods, and Instantiation Protocol

Status

Implemented (2026-02-15)

Context

ADR 0005 committed to "full Smalltalk metaclass model as the target" with classes as first-class objects backed by gen_server processes. Phase 1 (implemented) provides a fixed protocol (methods, superclass, new/spawn) via beamtalk_object_class.erl. Phase 2 requires extending this to support class variables, class-side methods, and the new/initialize instantiation chain.

What works today

What doesn't work

  1. Class variables not wired end-to-end: Classes can't hold shared state (e.g., UniqueInstance for singletons) via the language today. While #class_state{} already includes a class_variables field in the runtime, there is no syntax, parser/codegen support, or get/set handling wired up to expose it.
  2. Class-side methods not exposed to the language: There is no syntax to define methods on the class (vs. on instances), and the parser/codegen/runtime dispatch path for class_methods is not implemented, so all methods in a .bt file are effectively instance methods only.
  3. No dynamic class dispatch: cls := Point. cls new fails — the compiler only generates direct module calls for ClassReference AST nodes, not for variables holding class objects.
  4. No initialize hook for actors: spawn starts the gen_server and returns immediately. There's no post-spawn initialization method where actors can set up derived state (e.g., open connections, subscribe to topics).

Motivating use cases

REPL introspection (interactive-first language):

cls := (Beamtalk classNamed: #Point) await
cls new: #{x => 3, y => 4}
cls methods

Factory pattern (classic OO):

Object subclass: ShapeFactory
  state: shapeClass = nil
  create => self.shapeClass new

Singleton pattern (Transcript, SystemDictionary):

Object subclass: TranscriptStream
  classVar: uniqueInstance = nil
  
  class uniqueInstance =>
    self.uniqueInstance ifNil: [self.uniqueInstance := super new].
    self.uniqueInstance

Decision

1. Class Variables

Syntax: classVar: declarations alongside state: declarations.

Object subclass: TranscriptStream
  state: buffer = #()
  classVar: uniqueInstance = nil
  classVar: maxBuffer = 1000

Semantics:

Storage: In the class gen_server process state. Access compiles to gen_server:call(ClassPid, {get_class_var, VarName}) / gen_server:call(ClassPid, {set_class_var, VarName, Value}).

2. Class-Side Methods

Syntax: class prefix before method definition.

Object subclass: TranscriptStream
  state: buffer = #()
  classVar: uniqueInstance = nil

  // Instance methods (no prefix)
  show: text => self.buffer := self.buffer ++ #(text)
  
  // Class-side methods (class prefix)
  class uniqueInstance =>
    self.uniqueInstance ifNil: [self.uniqueInstance := super new].
    self.uniqueInstance

  class new => self error: 'Use uniqueInstance instead'

Semantics:

Inheritance example:

Object subclass: TestCase
  class allTestSelectors =>
    self methods select: [:m | m startsWith: 'test']

Object subclass: MyTest
  // Inherits allTestSelectors from TestCase — no need to redefine
  testAddition => self assert: (1 + 1) equals: 2

Implementation: The existing build_flattened_methods machinery in beamtalk_object_class.erl already walks the superclass chain to pre-compute inherited method tables (O(1) lookup at dispatch time, with cascading invalidation when a parent class changes). Class-side methods reuse this same infrastructure — a flattened_class_methods table alongside the existing flattened_methods table. No metaclass processes needed; the class gen_server handles both instance-side and class-side dispatch.

3. Instantiation Protocol

Value types and actors have different instantiation semantics, reflecting the fundamental difference between immutable values and stateful processes.

Value types (Object subclasses) — construction, not initialization:

Point new              // → #{$beamtalk_class => 'Point', x => 0, y => 0}
Point new: #{x => 3}   // → #{$beamtalk_class => 'Point', x => 3, y => 0}

Value types are immutable maps. new creates a map with default field values. new: merges provided arguments with defaults. There is no initialize hook — you can't mutate an immutable value after creation. This is the honest design: value types are constructed, not initialized.

Strict field validation in new:: The default new: rejects unknown fields at runtime. The compiler knows the declared state: fields at compile time and generates a validation check:

Point new: #{x => 3, y => 4}     // ✅ OK — x and y are declared fields
Point new: #{x => 3, z => 99}    // ❌ Error: "Unknown field 'z' for Point. Valid fields: x, y"
Point new: #{x => 'hello'}       // ✅ OK at construction (no types yet — future type system can add this)

Implementation: The generated new/1 function extracts the known field names from the class definition and checks maps:keys(Args) -- KnownFields. Any extra keys produce a beamtalk_error with kind unknown_field and a hint listing valid fields. This is zero-cost for new/0 (no args to validate) and ~1μs for new: (one maps:keys call). Class-side new: overrides bypass this check — if you override class new:, you own validation.

This catches the most common construction bug (typos in field names) without requiring a type system. A future type system can add value-type checking on top — the field-name check is orthogonal and immediately useful.

Actors (Actor subclasses) — spawn + initialize:

Actor subclass: Counter
  state: value = 0
  state: connections = nil

  initialize =>
    self.connections := Dictionary new

  increment => self.value := self.value + 1
Counter spawn              // → starts gen_server, calls initialize, returns #beamtalk_object{}
Counter spawnWith: #{value => 5} // → starts gen_server with overrides, calls initialize
Counter new                // → Error: "Use spawn instead of new for actors"

The same strict field validation from new: applies to spawnWith: — unknown fields are rejected at runtime before the gen_server starts.

The spawninitialize chain:

  1. Counter spawn → codegen generates gen_server:start_link(counter, #{}, [])init/1 builds default state map → gen_server is fully started
  2. Codegen immediately sends gen_server:call(Pid, {initialize, []}) as the first message to the new process
  3. initialize runs as a normal instance method — self is the new actor, full messaging capability (can send messages to other actors, await futures, etc.)
  4. Returns #beamtalk_object{} to the caller

Why first-message, not inside init/1? The gen_server init/1 callback runs during process startup — the process isn't fully registered yet. If initialize tried to send a message back to itself or to another actor that messages it back, it would deadlock. By running initialize as the first message after the process is alive, actors have full concurrency capabilities during initialization. This is the BEAM-natural approach.

Default initialize: If no initialize method is defined, the codegen skips the post-spawn call — spawn returns immediately after gen_server:start_link. This preserves backward compatibility with existing actors.

initialize inheritance: initialize is a regular instance method, so normal super dispatch applies. If LoggingCounter extends Counter and both define initialize, LoggingCounter's initialize should call super initialize to run the parent's initialization. This follows the same pattern as Smalltalk and other OO languages.

initialize with arguments (via spawnWith:):

Actor subclass: Server
  state: port = 8080
  state: socket = nil

  initialize =>
    self.socket := Socket listen: self.port

Server spawnWith: #{port => 9090} merges #{port => 9090} into defaults (during init/1), then initialize runs with the merged state — so self.port is 9090.

Overriding new on the class side (for singletons, factories):

Object subclass: TranscriptStream
  classVar: uniqueInstance = nil
  
  class new => self error: 'Use uniqueInstance instead'
  class uniqueInstance =>
    self.uniqueInstance ifNil: [self.uniqueInstance := super new].
    self.uniqueInstance

Class-side new can be overridden via the class prefix. super new calls the parent class's class-side new, which by default constructs a value type instance (immutable map) or spawns an actor (gen_server process). This enables singletons, object pools, and factory patterns.

Default class-side new: Every class inherits a default class new from Object (for value types) or Actor (for actors). Object's default class new constructs an immutable map with default field values. Actor's default class new raises an error ("Use spawn instead"). These defaults can be overridden per-class.

Why no initialize for value types?

In Smalltalk, new → basicNew → initialize works because initialize mutates the freshly allocated object. Beamtalk value types are immutable maps — self.x := 5 inside initialize would be meaningless (or would require initialize to use functional-update semantics that differ from every other method). Rather than create a confusing special case, we accept that value types and actors have different construction models — just as they already have different instantiation syntax (new vs spawn).

4. Dynamic Class Dispatch

When the receiver is not a compile-time ClassReference, dispatch goes through the runtime:

cls := (Beamtalk classNamed: #Point) await   // cls is a {beamtalk_object, 'Point', point, ClassPid}
cls new                               // → gen_server:call(ClassPid, {new, []})
cls methods                           // → gen_server:call(ClassPid, methods)

Two paths (ADR 0005 principle: "runtime dispatch is the contract, direct calls are the optimization"):

  1. Optimized path (compile-time known class): Point newcall 'point':'new'() (direct module call, zero overhead). This is what works today.
  2. Dynamic path (runtime class object): cls newgen_server:call(ClassPid, {new, Args}). In today's runtime, beamtalk_object_class:handle_call({new, Args}, ...) delegates to Module:spawn/1 for actor classes and returns a #beamtalk_object{} wrapping the spawned pid; it does not invoke a Module:new/* function or construct an immutable value map. For Phase 1 value types where new is intended to return maps, dynamic cls new will require additional runtime support (e.g., a separate dispatch branch in beamtalk_object_class.erl) in addition to codegen changes — the gap is not purely in code generation.

Future optimization: The dynamic path can be made ~10x faster for methods that don't access class variables. The #beamtalk_object{} record already carries the module name — extract it and use apply(Module, Selector, Args) (~0.5μs) instead of routing through the class gen_server (~5-10μs). The gen_server path is only needed when the method accesses class variable state. This is a codegen optimization pass that requires no language-level changes.

5. super in Class-Side Methods

Class-side super walks the class-side inheritance chain, not the instance-side chain:

Object subclass: TranscriptStream
  classVar: uniqueInstance = nil

  class new => self error: 'Use uniqueInstance instead'
  class uniqueInstance =>
    self.uniqueInstance ifNil: [self.uniqueInstance := super new].  // calls Object's class-side new
    self.uniqueInstance

super new in a class-side method dispatches to the parent class's flattened_class_methods table — the same mechanism as instance-side super, but using the class-side method table. The codegen generates gen_server:call(ClassPid, {super_class_send, Selector, Args, DefiningClass}), and the class process looks up the method in the parent's class-side table.

6. Virtual Metaclasses

Smalltalk developers expect Point class to return a metaclass object that responds to messages. We achieve full Smalltalk metaclass API compatibility with zero extra processes using virtual metaclasses.

The trick: The #beamtalk_object{} record is {beamtalk_object, Class, ClassMod, Pid}. The same class pid can be wrapped with different Class names to distinguish instance-side vs class-side dispatch:

cls := (Beamtalk classNamed: #Point) await  // → {beamtalk_object, 'Point', point, ClassPid}  (class object)
cls class                                    // → {beamtalk_object, 'Point class', point, ClassPid}  (metaclass — SAME pid)

When the class process receives a message, it checks the Class field in the #beamtalk_object{}:

Full Smalltalk API:

cls := (Beamtalk classNamed: #Point) await
cls class                          // → {beamtalk_object, 'Point class', point, ClassPid}
cls class methods                  // → class-side method selectors
cls class superclass               // → 'Object class' (metaclass of parent)
cls class class                    // → 'Metaclass'

What this gives us:

What this does NOT give us (deliberate simplification):

This covers ~95% of Smalltalk metaclass usage. The remaining 5% (custom metaclasses, metaclass mixins) is esoteric and can be added later if needed.

Prior Art

Smalltalk-80 / Pharo

Class variables (classVariableNames:) are shared across the entire hierarchy. Class instance variables are per-class (stored on the metaclass). Pharo singletons use class instance variables.

Class-side methods are defined on the metaclass. Every class Foo has a metaclass Foo class. Method lookup on the class side walks Foo class → FooSuper class → ... → Class → Behavior → Object.

Instantiation: new → basicNew → initialize chain. basicNew is a primitive that allocates memory. initialize is a hook that mutates the fresh object.

What we adopt: Class-side method inheritance, class instance variables (not shared class variables), and new as the public constructor API.

What we adapt: No metaclass processes — virtual metaclasses via the same class gen_server process with dual dispatch tables. No initialize for value types (they're immutable maps, not mutable heap objects). Actor initialize runs as a first-message-after-spawn rather than an allocation hook. Metaclass tower terminates at Metaclass (no infinite regression).

Erlang/OTP

No class system. Module attributes are compile-time constants. Per-module mutable state uses persistent_term, ETS, or process state.

What we adopt: Using gen_server process state for class variables aligns perfectly with OTP patterns.

Newspeak

Class-side state via "class declarations" in the class header. Modules are instantiable — class definitions are essentially factories.

What we note: Newspeak's approach of classes-as-modules-as-factories is elegant but more radical than we need. Our gen_server approach is simpler.

Ruby

class << self or self.method_name for class-side methods. Class variables (@@var) are shared across hierarchy (widely considered a design mistake). Class instance variables (@var on the class) are per-class.

What we reject: Ruby's @@var sharing across hierarchy. We follow Pharo's class instance variable model (per-class).

User Impact

Newcomer

Smalltalk Developer

Erlang/BEAM Developer

Operator

Steelman Analysis

"Skip class variables — just spawn dedicated processes for shared state"

Erlang/BEAM developer: "The whole point of BEAM is that state belongs in processes, not language-level class constructs. If you want a singleton counter, spawn a counter_registry gen_server. If you want shared config, use persistent_term or application:get_env. Class variables are an OO concept that fights against BEAM's process-oriented model. Every BEAM developer already knows how to manage state in processes — adding a new abstraction (class variables) is one more thing to learn with no real payoff."

Newcomer: "I don't understand what 'class variable' means vs 'state'. Why are there two kinds of state? In Python I'd just use a module-level variable."

Response: Class variables aren't about raw state management — they're about object protocol. The singleton pattern (uniqueInstance), the factory pattern (class-side new: override), and framework hooks (allSubclasses) are standard OO idioms that users from any OO language will reach for. Telling them "spawn a process instead" breaks the object metaphor that Beamtalk promises. Under the hood, class variables are process state (gen_server state) — we're not fighting BEAM, we're wrapping it in the right abstraction.

"Use ETS or persistent_term for class variable storage"

Erlang/BEAM developer: "ETS gives you concurrent reads (~100ns), atomic writes, no gen_server bottleneck. persistent_term gives ~13ns reads for rarely-changing values. gen_server:call adds ~5-10μs per access and serializes all reads. For a language that compiles to BEAM, you should use BEAM's strengths, not add a gen_server bottleneck."

Operator: "I can inspect ETS tables in observer. gen_server state requires attaching to the process. ETS is more observable in production."

Response: The performance argument is real but misplaced for class variables. Class variables serve patterns like singletons (read once, cache), instance counting (low frequency), and configuration (rarely changes) — not hot-path per-message state. The gen_server approach gives us: (1) crash recovery via supervision (ETS tables die with their owner), (2) consistent mutation semantics (no race conditions), (3) identical model to actor state (less to learn). If profiling reveals a bottleneck, we can optimize specific patterns to ETS/persistent_term without changing the language semantics — the storage is an implementation detail hidden behind classVar:.

"Full metaclass tower (Smalltalk-80 style) instead of virtual metaclasses"

Smalltalk developer: "Virtual metaclasses are clever but they're a lie. Point class doesn't return a real metaclass object — it returns the same pid with a different tag. You can't add metaclass-specific instance variables. You can't define methods on individual metaclasses independently. In Pharo, I can do Point class addInstVarNamed: 'cache' to add a class instance variable dynamically. Your virtual metaclasses can't do that. You're at 95% compatibility, but the 5% you're missing is exactly the 5% that metaprogramming frameworks need."

Response: This is the strongest remaining argument. The 5% gap (custom metaclass state, per-metaclass method definitions, metaclass mixins) does matter for advanced frameworks. However: (1) The virtual metaclass approach is forward-compatible — if we later need real metaclass processes, the syntax (class prefix, classVar:) and the API (Point class methods, Point class superclass) don't change. Only the runtime implementation changes. (2) Zero extra processes means zero extra supervision complexity, zero extra memory, and zero extra failure modes. (3) The 95% we cover handles SUnit, singleton, factory, and REPL introspection — the use cases driving this ADR. We can promote virtual metaclasses to real metaclass processes later if the 5% gap becomes a blocker.

"classVar: is misleading — it's NOT Smalltalk classVariableNames:"

Smalltalk developer: "In Smalltalk, classVariableNames: declares variables shared across the entire class hierarchy. Your classVar: is actually Pharo's 'class instance variable' — per-class, not shared. Using the name classVar: will confuse every Smalltalk developer who expects hierarchy-wide sharing. You should call it classInstVar: or similar to be honest about what it does."

Newcomer: "I assumed classVar: would be inherited by subclasses, like class fields in Java. The per-class behavior is surprising."

Response: Fair naming concern. However: (1) Hierarchy-shared class variables are widely considered a design mistake — even Ruby's @@var is a known footgun, and Pharo documentation recommends class instance variables for nearly all use cases. (2) classInstVar: is jargon that only makes sense if you already understand the metaclass model. (3) The name classVar: communicates the right mental model for 90% of users: "a variable that belongs to the class, not to instances." We document the per-class semantics explicitly and note the Smalltalk divergence in the syntax rationale.

"Class-side methods aren't needed — just use module functions"

Erlang/BEAM developer: "Every Beamtalk class already compiles to an Erlang module. Module functions are class-side methods. Point new already compiles to call 'point':'new'(). Why add class prefix syntax when you can just define module-level functions? The BEAM already has this concept — it's called a module."

Response: Module functions work for the compile-time-known case (Point new), but fail for the dynamic case (cls new where cls is a variable). The class prefix isn't about module functions — it's about methods on the class object that participate in message dispatch. When you write cls := Beamtalk classNamed: #Point. cls new, the runtime must dispatch new to the class process, not to a module. The class prefix tells the compiler "register this method on the class process's dispatch table" rather than "generate a module function." Both paths can coexist: direct module calls as the optimization, class process dispatch as the contract.

"The class process is a serialization bottleneck for class-side dispatch"

Erlang/BEAM developer: "Every class-side message — new, methods, user-defined class methods, class variable reads — serializes through a single gen_server mailbox. If 1000 actors concurrently call MyFactory create, they queue up behind one process. You've turned every class into a single-threaded bottleneck. In Erlang, we'd use ETS for reads and gen_server only for coordinated writes — you're using gen_server for everything."

Performance-focused developer: "Instance dispatch goes directly to the actor's gen_server or a static module function — O(1), no class process involved. But class-side dispatch always hits the class process. You've created an asymmetry where instance methods are fast and class methods are slow. Under load, Point new (compiled path) is ~0.5μs but cls new (dynamic path) is ~5-10μs and can't scale horizontally."

Response: This is a real architectural constraint, and we accept it deliberately for Phase 1. The key insight is that the interface (class prefix, classVar:, cls new) is independent of the dispatch mechanism. Three optimization paths are available without changing the language:

  1. Read-only fast path: Class-side methods that don't mutate class variables can be compiled to direct apply(Module, Selector, Args) calls — the #beamtalk_object{} record already carries the module name. This makes read-only class methods as fast as instance dispatch (~0.5μs). The gen_server path is only needed when class variable state is involved.
  2. ETS-backed reads: Class variables can be mirrored to an ETS table for concurrent reads, with writes going through the gen_server for consistency. The classVar: syntax and self.varName access pattern don't change.
  3. Process pool: For truly hot class-side methods (factories under load), the class process can delegate to a pool of workers. Again, no language-level change.

For Phase 1, correctness matters more than throughput. The patterns driving this ADR (singletons, REPL introspection, factory patterns) are low-frequency operations. If profiling reveals a bottleneck in a specific class, we optimize that class's dispatch — the interface is the contract, the implementation is the optimization.

"Value types with new have no way to validate constructor arguments"

Smalltalk developer: "Point new: #{x => 'hello'} silently creates a broken Point with a string where a number should be. Without initialize, there's no hook to validate arguments, enforce invariants, or reject bad input. Every OO language needs a constructor validation story. Immutable objects are more important to validate, not less — you can't fix them after creation."

Newcomer: "In Python I'd raise ValueError in __init__. In Java I'd throw from the constructor. Where do I put my validation logic?"

Response: The default new: already rejects unknown fields (see Decision section 3). For type-level validation, class-side new: can be overridden:

Object subclass: Point
  state: x = 0
  state: y = 0

  class new: args =>
    (args at: #x) isNumber ifFalse: [self error: 'x must be a number'].
    (args at: #y) isNumber ifFalse: [self error: 'y must be a number'].
    super new: args

Field-name validation is the default (catches typos). Type validation is opt-in per class (catches wrong types). This is actually cleaner than initialize-based validation: the object is never created in an invalid state. With initialize, the object exists briefly in a pre-validation state (basicNew returns it, initialize validates it) — a window where invariants don't hold. With class-side new:, validation happens before construction. A future type system can automate the type-checking layer — the field-name check is orthogonal and immediately useful.

Alternatives Considered

Alternative: shared: instead of classVar:

Object subclass: Counter
  shared: instanceCount = 0

Rejected: "shared" is ambiguous (shared with whom? — other instances? other classes? other processes?). classVar: is explicit about scope and familiar to OO developers, even if its exact semantics differ from Smalltalk's classVariableNames:.

Alternative: classInstVar: (Pharo-accurate naming)

Object subclass: TranscriptStream
  classInstVar: uniqueInstance = nil

Rejected: Only meaningful to developers who understand the metaclass model. classVar: communicates the right intuition ("variable on the class") for 90% of users. The per-class (non-inherited) semantics are documented explicitly.

Alternative: Annotation-based class methods

@classMethod
uniqueInstance => ...

Rejected: Annotations are not Smalltalk-idiomatic. The class prefix reads more naturally ("class method uniqueInstance") and keeps the method definition syntax consistent. Annotations suggest metadata, not behavior.

Alternative: Separate class body block

Object subclass: TranscriptStream
  // instance side
  show: text => ...
  
  classSide:
    uniqueInstance => ...

Rejected: Adds structural complexity and ordering ambiguity (what if instance methods appear after classSide:?). Pharo's browser has two "tabs" (instance/class side), but in a text file the class prefix per method is clearer — you can freely interleave instance and class methods grouped by concern.

Alternative: Defer everything — only implement dynamic dispatch (Phase 1)

Ship BT-221 + BT-246 without classVar: or class methods. Let users use workspace bindings for singletons and module functions for class-level behavior. Add class variables later if demand appears.

This is a viable incremental approach. The risk is that Phase 1 without Phase 2-3 leaves an awkward gap: users can call cls new dynamically but can't define their own class-side methods or singletons. We include all phases in this ADR for design coherence, but Phase 1 can ship independently.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1: new for Value Types + Dynamic Dispatch (BT-221)

Parser: No changes needed — Point new already parses. Codegen: When receiver is not a ClassReference, emit gen_server:call(ClassPid, {Selector, Args}) instead of beamtalk_primitive:send. Note: The class process currently uses mixed message formats — {new, Args} and {method, Selector} use tuple messages, while methods, superclass, and class_name use bare atoms. The codegen must match these existing formats. A future cleanup issue should standardize the class process to use the {Selector, Args} protocol consistently (like other actors). Codegen — field validation: Generated new/1 and spawn/1 validate argument keys against declared state: fields. Unknown keys produce a beamtalk_error with kind unknown_field, listing valid fields in the hint. This is compile-time-generated validation (field names are known from the AST), executed at runtime (~1μs per construction). Runtime: beamtalk_object_class handles {new, Args} in handle_call, but currently delegates to Module:spawn/1 (actor-style). For value types, a new handle_call clause is needed that constructs an immutable map via Module:new/0 or Module:new/1. Test: cls := (Beamtalk classNamed: #Point) await. cls new works end-to-end. Point new: #{z => 1} raises unknown_field error.

Phase 2: Class-Side Methods, Inheritance, and Actor initialize (BT-246)

Parser: Add class prefix token to method definitions. Store separately in AST (class_methods: Vec<MethodDefinition>). No parser changes needed for initialize — it's a regular instance method with a well-known name. AST: Add class_methods: Vec<MethodDefinition> to ClassDefinition. Codegen — class-side methods: Generate class-side methods registered via beamtalk_object_class during class bootstrap. Codegen — actor initialize: If a class defines an initialize method, spawn/0 and spawn/1 codegen emits gen_server:call(Pid, {initialize, []}) as the first message after gen_server:start_link succeeds. If no initialize is defined, spawn returns immediately (backward compatible). Detection is compile-time via the method table. Runtime: Add class_methods and flattened_class_methods fields to #class_state{}. Reuse build_flattened_methods for class-side table. Route class-side message sends to the class process via gen_server:call, dispatching through flattened_class_methods. Test: TranscriptStream uniqueInstance returns singleton. MyTest allTestSelectors inherits from TestCase. Actor with initialize sets up derived state after spawn.

Phase 3: Virtual Metaclasses

Codegen: Change class intrinsic to return #beamtalk_object{class='X class', class_mod=Mod, pid=ClassPid} instead of an atom. For actors, the class pid is already available (element 4 of #beamtalk_object{}). For value types (primitives, maps), look up the class process via beamtalk_object_class:whereis_class(ClassName). Runtime: Class process checks Class field to distinguish class-side vs metaclass-side dispatch. Metaclass superclass returns parent's metaclass name. Metaclass class returns 'Metaclass'. Bootstrap: Register Metaclass class in bootstrap (~50 lines). Test: p := Point new. p class methods returns class-side selectors. p class superclass returns 'Object class'. p class class returns 'Metaclass'.

Phase 4: Class Variables

Parser: Add classVar: declaration (same as state: but different AST node). AST: Add class_variables: Vec<StateDeclaration> to ClassDefinition. Codegen: Initialize class variables in the class gen_server init/1. Generate gen_server:call for class variable access from class-side methods. Runtime: The class_variables field already exists in #class_state{}; add {get_class_var, Name} and {set_class_var, Name, Value} handlers in handle_call and wire up initialization from parsed classVar: declarations. Test: Singleton pattern with class variable storage.

Phase 5: Instance-Side Access to Class Variables

Recommended approach: self class varName — explicit message send to the class object.

Object subclass: Counter
  classVar: instanceCount = 0
  state: value = 0
  
  class new =>
    self.instanceCount := self.instanceCount + 1.
    super new
  
  getInstanceCount => self class instanceCount   // explicit class var access

Why this approach: (1) No new syntax — reuses existing self class message + chained send. (2) Makes the cost visible — it's clearly a message send, not a field access. (3) No variable shadowing — instance vars and class vars have distinct access patterns. (4) Works polymorphically — self class resolves to the actual runtime class.

Performance note: self class instanceCount involves two dispatches (get class, then get var). For hot paths, cache the value in a local: count := self class instanceCount. In practice, instance-side class var access is rare — most class var usage is in class-side methods where self.varName works directly.

LSP and Tooling Integration

Each phase should include corresponding language service updates. Features users can't discover through autocomplete won't get used.

Phase 1 (Dynamic dispatch):

Phase 2 (Class-side methods + initialize):

Phase 3 (Virtual metaclasses):

Phase 4–5 (Class variables):

Field validation (all phases):

Parser Note: class Keyword Disambiguation

The class token appears in two contexts:

  1. Method definition prefix: class uniqueInstance => ... (at declaration level)
  2. Unary message: self class or x class (in expressions)

The parser distinguishes these by context: at the class body's declaration level, class followed by an identifier and => is a class-side method definition. Inside an expression (method body, block, REPL), class is a unary message.

Edge cases to handle:

Migration Path

No breaking changes. All existing code continues to work:

References