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
- Class processes: Every class has a gen_server process holding a
#class_state{}record (methods, superclass, fields, etc.) - Instance creation:
Point newcompiles tocall 'point':'new'()(direct module call).Counter spawncreates a gen_server process. - Workspace bindings:
TranscriptandBeamtalkdispatch throughpersistent_termto singleton actors (ADR 0010). - Reflection:
Beamtalk classNamed: #Counterreturns a#beamtalk_object{}wrapping the class pid.
What doesn't work
- Class variables not wired end-to-end: Classes can't hold shared state (e.g.,
UniqueInstancefor singletons) via the language today. While#class_state{}already includes aclass_variablesfield in the runtime, there is no syntax, parser/codegen support, or get/set handling wired up to expose it. - 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_methodsis not implemented, so all methods in a.btfile are effectively instance methods only. - No dynamic class dispatch:
cls := Point. cls newfails — the compiler only generates direct module calls forClassReferenceAST nodes, not for variables holding class objects. - No
initializehook for actors:spawnstarts 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:
- Class variables are shared state on the class process (not on instances).
- Stored in the class gen_server state (
#class_state{class_variables :: map()}). - Accessible from both class-side and instance-side methods via
self.varNameon the class side, and a yet-to-be-determined syntax on the instance side. - Mutable via
:=assignment (same as instance state). - Not inherited by subclasses — each class has its own class variables (class instance variables in Pharo terminology). This matches Pharo's class instance variables, which is what singletons actually need.
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:
classprefix declares a method on the class object, not on instances.- Inside class-side methods,
selfrefers to the class object (the gen_server process). self.uniqueInstanceaccesses a class variable.- Class-side methods are stored in the class process and dispatched via
gen_server:call. - Class-side
newcan be overridden (e.g., to prevent direct instantiation). - Class-side methods are inherited through the superclass chain, just like instance methods. If
Objectdefinesclass new, all subclasses inherit it. A subclass can override with its ownclass new.
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 spawn → initialize chain:
Counter spawn→ codegen generatesgen_server:start_link(counter, #{}, [])→init/1builds default state map → gen_server is fully started- Codegen immediately sends
gen_server:call(Pid, {initialize, []})as the first message to the new process initializeruns as a normal instance method —selfis the new actor, full messaging capability (can send messages to other actors, await futures, etc.)- 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"):
- Optimized path (compile-time known class):
Point new→call 'point':'new'()(direct module call, zero overhead). This is what works today. - Dynamic path (runtime class object):
cls new→gen_server:call(ClassPid, {new, Args}). In today's runtime,beamtalk_object_class:handle_call({new, Args}, ...)delegates toModule:spawn/1for actor classes and returns a#beamtalk_object{}wrapping the spawned pid; it does not invoke aModule:new/*function or construct an immutable value map. For Phase 1 value types wherenewis intended to return maps, dynamiccls newwill require additional runtime support (e.g., a separate dispatch branch inbeamtalk_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{}:
'Class'or class name: dispatch throughflattened_class_methods(class-side protocol —new,uniqueInstance, user-defined class methods)'X class'(metaclass): dispatch through metaclass protocol (methodsreturns class-side selectors,superclassreturns parent metaclass,classreturns'Metaclass')
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:
x classreturns a real object, not an atom — you can send messages to it- Class-side method lookup via
flattened_class_methodswith inheritance superclasson the metaclass mirrors the instance-side hierarchy- Compatible with Smalltalk reflection idioms
What this does NOT give us (deliberate simplification):
- No
Metaclassinstances —Metaclassis a fixed class, not user-extensible - No
Point class class classinfinite tower — terminates atMetaclass - No per-metaclass state (metaclass instance variables) — class variables serve this role
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
classprefix for class methods is intuitive — reads naturally.new/initializepattern is familiar from most OO languages.classVar:is explicit about what it does.
Smalltalk Developer
- Will expect
classVariableNames:shared across hierarchy — we use per-class variables instead (Pharo class instance variable semantics). This is actually what they want for singletons. class uniqueInstanceinstead of defining onTranscriptStream class— minor syntax difference.- For actor-backed classes,
spawn→initializereplaces thenew → basicNew → initializechain — same user-facing pattern (defineinitializeto set up your object), different mechanics (first message after spawn, not allocation hook). Value types only supportnew/new:and do not haveinitialize.
Erlang/BEAM Developer
- Class variables stored in gen_server state is natural OTP.
gen_server:callfor class-side dispatch is standard.- No new runtime concepts — just wiring existing patterns.
Operator
- Class variable state lives in the class process — observable via
observer, restartable via supervision. - Singleton actors are just named processes — standard OTP monitoring.
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:
- 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. - 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 andself.varNameaccess pattern don't change. - 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
- Enables singleton pattern, factory pattern, and REPL class introspection.
- Virtual metaclasses give ~95% Smalltalk metaclass compatibility with zero extra processes.
- Class-side method inheritance enables framework patterns (SUnit, Seaside-style).
- Dynamic class dispatch makes classes true first-class objects.
- Builds on existing gen_server infrastructure —
build_flattened_methodsreused for class-side table. classVar:is simple to parse (same pattern asstate:).- Strict field validation in default
new:/spawnWith:catches typos immediately — no type system required. The compiler already knows the fields; we just check them. - Honest split: value types are constructed (
new/new:), actors are initialized (spawn). No pretending immutable maps support mutation hooks.
Negative
- Class variable access from instance methods requires two gen_server calls (
self class→ get class object, thenvarName→ get variable), adding ~10-20μs total. From class-side methods, access is a single gen_server call (~5-10μs). This is acceptable for the patterns class variables serve (configuration, caching, singletons) but not for hot-path per-message state. Future optimization: compile-time-known class pids can reduce instance-side access to one call; ETS-backed reads can eliminate gen_server overhead entirely — without changing theclassVar:interface. - Class-side dispatch serializes through a single gen_server per class, creating a throughput ceiling for high-frequency class-side operations (e.g., factory
newunder load). The interface is independent of the dispatch mechanism, so optimization (direct module calls, ETS reads, process pools) can be applied without language changes. - Two kinds of
self(class-side vs instance-side) could confuse newcomers, though theclassprefix makes context clear. - Parser, AST, codegen, and runtime all need changes — medium-sized cross-cutting feature.
- Value types not having
initializeis a divergence from Smalltalk that will surprise Smalltalkers expectingnew → basicNew → initializeeverywhere. Mitigation: class-sidenew:override provides pre-construction validation, which is arguably better (object never exists in invalid state).
Neutral
- Virtual metaclasses replace ADR 0005 Phase 2 — no separate metaclass work needed later.
- Value types and actors have different construction models (
new/new:vsspawn/spawnWith:), matching their existing semantic split. - Class variable state is lost if the class process crashes and restarts (standard OTP behavior). For singletons and caches, lazy re-initialization on next access is the correct pattern. If persistent class variable state is needed, the
classVar:interface supports adding optional persistence (mnesia, dets) as an implementation detail.
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):
- Hover: On class objects (
clsafterclassNamed:), show type asClass(Point)with link to class definition. - Diagnostics: Warn on
ActorSubclass newat compile time (not just runtime error).
Phase 2 (Class-side methods + initialize):
- Completions: Inside class body after
classprefix, offer method name completions. On class objects, offer class-side method selectors (fromflattened_class_methods). - Go to definition:
class uniqueInstance =>navigates to its definition.super newin class-side methods navigates to parent's class-sidenew. - Hover on
self: Showself: Counterin instance methods,self: Counter classin class-side methods. This is critical — two kinds ofselfrequires tooling to disambiguate at a glance. - Document symbols: Class-side methods appear in outline with a distinct icon or
(class)suffix.
Phase 3 (Virtual metaclasses):
- Completions: On
x classresult, offer metaclass protocol (methods,superclass,class).
Phase 4–5 (Class variables):
- Completions:
classVar:declarations appear in completion afterstate:. In class-side methods,self.offers class variable names alongside class-side method accessors. - Rename: Renaming a
classVar:declaration updates allself.varNamereferences in class-side methods andself class varNamereferences in instance methods. - Diagnostics: Accessing
self.classVarNamefrom an instance method should warn ("Did you meanself class classVarName?").
Field validation (all phases):
- Diagnostics: For
Point new: #{z => 1}, report "Unknown field 'z' for Point" at compile time when the class definition is known. This is a static analysis win — the compiler already has the field list.
Parser Note: class Keyword Disambiguation
The class token appears in two contexts:
- Method definition prefix:
class uniqueInstance => ...(at declaration level) - Unary message:
self classorx 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:
class class => ...— a class-side method namedclass. Legal but discouraged; parser matchesclass <ident> =>pattern.class => ...— instance method namedclass(no prefix, overrides the intrinsic). The parser sees<ident> =>withoutclassprefix.
Migration Path
No breaking changes. All existing code continues to work:
Point newstill compiles to direct module call (optimized path).Counter spawnunchanged.- New syntax (
classVar:,classprefix) is additive.
References
- Related issues: BT-246 (first-class class objects), BT-221 (universal new), BT-234 (metaclass hierarchy)
- Related ADRs: ADR 0005 (BEAM object model), ADR 0010 (global objects and singleton dispatch)
- Prior art: Pharo by Example Ch. 6 (Instance Side and Class Side), Pharo singleton MOOC slides
- Documentation:
docs/beamtalk-language-features.md(class definition section)