ADR 0048: Class-Side Method Syntax

Status

Deferred (2026-03-15) — revisit post-0.1.0. Originally proposed 2026-03-02.

Context

History: class modifier introduced before metaclasses

ADR 0013 introduced class-side method definitions using a class prefix modifier:

class new -> Foo => @primitive "new"
class pi -> Float => @primitive "pi"
class uniqueInstance =>
    self.uniqueInstance ifNil: [self.uniqueInstance := super new].
    self.uniqueInstance

This syntax was designed as a pragmatic shortcut in a world where Beamtalk did not yet have metaclasses. The class modifier told the compiler "register this method on the class's dispatch table rather than the instance table." There was no metaclass object to navigate to, so the modifier was the only available mechanism.

ADR 0036 subsequently introduced the full metaclass tower: every class Foo now has a real Foo class metaclass object backed by the same class gen_server process (virtual tag dispatch). Counter class, Counter class class, Metaclass class class == Metaclass class — all work. The object model is Smalltalk-complete.

With the metaclass tower in place, the original motivation for the class modifier — no metaclass to navigate to — no longer holds.

The Collision Problem

class is simultaneously:

  1. A grammar modifier keyword — the class token at declaration level introduces a class-side method definition.
  2. A valid method name — the class unary message returns the receiver's metaclass (e.g., 42 class returns Integer class; Counter class returns the metaclass of Counter).

The parser cannot reliably distinguish these two roles. ADR 0047 documented this as Ambiguity 2:

sealed class -> Metaclass => @primitive "classClass"

Is this:

The parser resolved this as the former — generating class_->/2, with Self unbound. The intended reading was the latter: an instance method named class that returns the receiver's metaclass.

ADR 0047 (Arrow token) fixed Ambiguity 1 (-> as method selector) using a dedicated TokenKind::Arrow. It addressed Ambiguity 2 with a per-case lookahead hack: before consuming class as a modifier, peek ahead; if Arrow Identifier FatArrow follows, treat class as the method selector instead. This lookahead is correct for the one known case, but it is a patched exception to a broken general rule, not a fix to the underlying grammar problem.

The underlying problem is structural: the grammar keyword and the method name are the same token. Any method named class on any class must thread through the lookahead special case. Any future method whose name coincided with another modifier keyword would create the same class of problem. (ADR 0049 removes sealed as a method modifier — after that change, class is the sole remaining method modifier keyword, making it the last instance of this problem class rather than one of many.)

The Smalltalk Comparison

In Smalltalk, there is no class modifier keyword. There is only the object model. Class-side methods are defined by navigating to the metaclass via a message send:

"Pharo / Squeak syntax"
(MyClass class) >> #myMethod    "defines on Foo's metaclass — class side"
MyClass >> #myMethod             "defines on MyClass — instance side"

class is a unary message that returns the metaclass object. It is not special syntax. Defining a method "on the metaclass" is simply defining an ordinary method on an ordinary object that happens to be a metaclass. The class modifier collision is absent because Smalltalk has no modifier syntax at all.

Beamtalk's class modifier was a syntactic convenience that collapsed the two-step reflective pattern (navigate to metaclass, then define method) into a single inline declaration. Now that Beamtalk has real metaclasses (ADR 0036), the original Smalltalk approach is structurally available.

Scope

There are approximately 57 class-side method definitions across the stdlib following the current class methodName pattern. All would need migration under any of the alternatives below.

Decision

TBD — see Alternatives.

This ADR is drafted for discussion. The three options each represent a distinct position on the tradeoff between Smalltalk fidelity, syntactic minimalism, and migration simplicity. No option has been selected; the analysis and steelman sections are provided to structure the decision.

Prior Art

Pharo and Squeak (Smalltalk)

Pharo's image-based browser presents "instance" and "class" as two tabs on the class definition pane. In expression form (evaluating in the image), class-side methods are defined by navigating to the metaclass via a message send:

"Expression syntax — sends the `class` message to reach the metaclass"
(MyClass class) >> #new      "class side"
MyClass >> #balance          "instance side"

class is a unary message returning the metaclass — not a modifier keyword. There is no collision with method names because class is only ever a message send.

In text form (Tonel file format), the distinction is carried by a classSide attribute on the method definition header, not by inline syntax:

{ #category : 'instance creation' }
MyClass >> balance [
    ^balance
]

{ #category : 'instance creation', #classSide : true }
MyClass >> new [
    ^super new initialize
]

The receiver in both Tonel entries is MyClass >>; the #classSide : true attribute tells the Tonel loader to install the method on the metaclass rather than the class itself. This is an attribute approach — closer in spirit to Option B (meta modifier) than to a separate metaclass block — but without any syntax collision risk because the attribute is in the metadata header, not in the method signature.

Newspeak

Newspeak has no metaclasses. Classes are nestable first-class objects. Class-side behavior is expressed via nested class definitions:

class Counter [
    class var count = 0.       "class-side state"

    class >> increment [       "class-side method (theoretical syntax)"
        count := count + 1
    ]

    >> value [ ^count ]        "instance-side method"
]

In practice Newspeak uses nested class slots and the module instantiation model to provide class-like factories. There is no class modifier keyword; instead the module/class distinction provides the same scoping. Not directly applicable to Beamtalk's object model.

Ruby

Ruby uses class << self to open the eigenclass (singleton class) for class-side method definitions:

class MyClass
  class << self
    def new_instance
      # ...
    end
  end

  def instance_method
    # ...
  end
end

Alternatively, prefixing with self.:

class MyClass
  def self.new_instance   # class-side
    # ...
  end

  def instance_method     # instance-side
    # ...
  end
end

Ruby's self. prefix is functionally analogous to Beamtalk's class modifier, but it uses self (not class) and applies at the method level. The class << self block groups multiple class-side definitions. Neither form creates an ambiguity with method names because self. is parsed as a receiver qualifier, not a single modifier token.

Kotlin

Kotlin uses companion object to group class-side definitions:

class MyClass {
    companion object {
        fun create(): MyClass = MyClass()
        const val DEFAULT = "default"
    }

    fun instanceMethod() { ... }
}

companion object is an explicit nested object declaration, not a method modifier. It is verbose but unambiguous: methods inside a companion object block are class-side; everything outside is instance-side. This is structurally analogous to Option A below.

static (available for top-level functions and via @JvmStatic in companion objects) is Java-inherited syntax that Kotlin considers second-class.

Swift

Swift uses static (for classes that cannot be overridden) and class (for overridable class-side methods):

class MyClass {
    static func create() -> MyClass { return MyClass() }   // not overridable
    class func factory() -> MyClass { return MyClass() }   // overridable in subclasses
    func instanceMethod() { }
}

Notably, Swift uses class as a modifier keyword in exactly the same position Beamtalk does — and Swift does have the analogous ambiguity that class is both the keyword opening a class declaration and a modifier on methods within a class. Swift resolves this by context: at the declaration level inside a class body, class func is always a method modifier; there is no method named class. Beamtalk's problem is that class is a valid method name (the Smalltalk class message), which Swift does not have.

@staticmethod (Python), static (Java, C#, Kotlin, Swift) — the keyword-based approach is the dominant pattern in mainstream languages. The collision with method names is Beamtalk-specific, arising from the Smalltalk heritage where class is a ubiquitous unary message.

User Impact

The following analysis applies across all three options, noting where the options diverge.

Newcomer

Newcomers have no existing code to migrate and no preconceptions about which syntax is "correct." All three options are learnable. The question is discoverability:

Smalltalk Developer

A Smalltalk developer's strongest expectation is the object-model-first approach: class-side methods are defined by navigating to the metaclass. Option A is the closest match to this mental model — X class subclass: Foo class reads as "define the metaclass side of Foo." The class message is the familiar Smalltalk metaclass-navigation message; no new syntax is needed.

Options B and C use modifier keywords, which are alien to Smalltalk syntax philosophy. However, a Smalltalk developer will immediately understand what they do.

Neither Option B nor Option C collides with the Smalltalk mental model — meta and + are not method names in Smalltalk — so neither causes confusion, just mild aesthetic disappointment.

Erlang/BEAM Developer

An Erlang developer cares about clarity of what compiles to what. Options B and C are more predictable: a method prefixed with meta or + is straightforwardly a class-side function in the compiled module. Option A's separate declaration is structurally clear as well, but introduces a question: "are X subclass: Foo and X class subclass: Foo class compiled into the same module?" (Yes — both compile into foo.beam.)

All three options produce identical BEAM output; the choice is purely syntactic.

Tooling Developer (LSP, IDE)

For any option, the compiler must map method definitions to the correct dispatch table (instance-side vs class-side). The classification must be statically determinable from the AST without lookahead hacks.

All three options eliminate the Ambiguity 2 lookahead hack. From a tooling perspective, any option is preferable to the status quo.

Production Operator

No runtime impact. The class-side/instance-side distinction is purely a compile-time concern — it determines which dispatch table a method is registered in, not how it executes. All three options produce identical BEAM output.

Steelman Analysis

Option A: Separate Class Body Declaration

Best case from each cohort:

Honest tensions:

Option B: meta Modifier Keyword

Best case from each cohort:

Honest tensions:

Option C: + Sigil

Best case from each cohort:

Honest tensions:

Cross-Cohort Tensions

Alternatives Considered

Option A: SuperClass class subclass: Foo class Declaration

Class-side methods are placed in a second subclass: declaration in the same .bt file, using X class subclass: Y class to declare the metaclass side alongside the instance side. Both declarations live in the same file — no file split required.

// Float.bt — instance side and class side in the same file

Number subclass: Float
    state: value = 0.0

    + other -> Float => @primitive "+"
    sqrt -> Float => @primitive "sqrt"
    abs -> Float => @primitive "abs"

Number class subclass: Float class
    pi -> Float => @primitive "pi"
    nan -> Float => @primitive "nan"

The first declaration ends when the parser sees the second subclass: header — the same rule that already terminates any class body. No new terminator syntax is needed.

The X class subclass: Y class form uses only existing tokens and the existing subclass: keyword. The one new parser rule: when both the superclass and the class name in a subclass: header carry a trailing class message, register the methods on the metaclass of Y rather than on Y itself. The metaclass inheritance (Float class inheriting from Number class) is made explicit — and it is real.

For a class with both instance and class sides:

// TranscriptStream.bt

Object subclass: TranscriptStream
    classVar: uniqueInstance = nil

    class -> Metaclass => @primitive "classClass"   // unambiguous: method named `class`
    show: text => self.buffer := self.buffer ++ #(text)

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

For a class with only instance-side methods (the common case), no second declaration is needed — nothing changes.

In stdlib migration terms: The 57 class-side method definitions are extracted into a second X class subclass: Y class declaration in the same file. The instance-side declaration is unchanged.

Tradeoffs:

Option B: meta Modifier Keyword

Replace the class modifier with meta. Every existing class methodName definition becomes meta methodName. The grammar and parser change are purely mechanical.

Object subclass: TranscriptStream
    classVar: uniqueInstance = nil

    // Instance method — fully unambiguous
    class -> Metaclass => @primitive "classClass"
    show: text => self.buffer := self.buffer ++ #(text)

    // Class-side methods — `meta` is never a method name
    meta new => self error: 'Use uniqueInstance instead'
    meta uniqueInstance =>
        self.uniqueInstance ifNil: [self.uniqueInstance := super new].
        self.uniqueInstance

Stdlib examples:

meta new -> Foo => @primitive "new"
meta pi -> Float => @primitive "pi"

In stdlib migration terms: All 57 occurrences of class methodName => ... become meta methodName => .... Fully mechanical. No structural changes.

Tradeoffs:

Option C: + Sigil

Adopt the Objective-C/Swift convention: prefix class-side method definitions with +. Instance-side methods carry no prefix (the unmarked default).

Object subclass: TranscriptStream
    classVar: uniqueInstance = nil

    // Instance method — no prefix, `class` is unambiguously a method name
    class -> Metaclass => @primitive "classClass"
    show: text => self.buffer := self.buffer ++ #(text)

    // Class-side methods — `+` sigil
    + new => self error: 'Use uniqueInstance instead'
    + uniqueInstance =>
        self.uniqueInstance ifNil: [self.uniqueInstance := super new].
        self.uniqueInstance

Stdlib examples:

+ new -> Foo => @primitive "new"
+ pi -> Float => @primitive "pi"

In stdlib migration terms: All 57 occurrences of class methodName => ... become + methodName => .... Fully mechanical.

Tradeoffs:

Option D: Do Nothing — Keep class Modifier + ADR 0047 Lookahead

Keep the existing class modifier syntax. ADR 0047's lookahead handles the one known collision (sealed class -> Metaclass =>) correctly. After ADR 0049 removes sealed from methods, class is the only modifier keyword, and the one colliding method (Class.class) is handled by the targeted lookahead.

// Status quo — unchanged
class pi -> Float => @primitive "pi"
class new -> Foo => @primitive "new"
sealed class -> Metaclass => @primitive "classClass"  // ADR 0047 lookahead resolves this

Tradeoffs:

Consequences

The consequences below are conditional on the option selected.

Option A Consequences

Positive:

Negative:

Neutral:

Option B Consequences

Positive:

Negative:

Neutral:

Option C Consequences

Positive:

Negative:

Neutral:

Implementation

Affected Components

ComponentOption AOption BOption C
crates/beamtalk-core/src/source_analysis/lexer.rsNo changeAdd meta keyword tokenNo change (or minimal: + sigil detection in declaration context)
crates/beamtalk-core/src/source_analysis/token.rsNo changeAdd TokenKind::MetaNo change
crates/beamtalk-core/src/source_analysis/parser/declarations.rsNew recognition rule for X class subclass: Y class headersReplace class-modifier handling with meta-modifier handlingReplace class-modifier handling with +-sigil detection
crates/beamtalk-core/src/ast.rsTwo separate ClassDefinition nodes per class (instance-side and class-side); or a single node with an optional class_side_methods fieldNo change to AST structureNo change to AST structure
stdlib/src/*.bt~57 definitions extracted into new X class subclass: Y class declarations~57 class methodNamemeta methodName renames~57 class methodName+ methodName renames
crates/beamtalk-core/src/semantic_analysis/class_hierarchy/generated_builtins.rsRegenerate after stdlib migrationRegenerate after stdlib migrationRegenerate after stdlib migration

Migration Scope

The stdlib contains exactly 57 class-side method definitions distributed across 22 files:

For Options B and C, migration is token-level: replace the modifier/sigil and no structural reorganisation is needed. For Option A, each file with class-side methods gains a new top-level X class subclass: Y class declaration block.

Superseding ADR 0013

This ADR supersedes the class-side method syntax specified in ADR 0013 Section 2 ("Class-Side Methods — Syntax: class prefix"). ADR 0013's semantic decisions (class-side dispatch, inheritance, super in class-side methods) are unaffected — only the surface syntax changes.

ADR 0047 Relationship

ADR 0047's Ambiguity 2 fix (lookahead in parse_method_definition at line 619) was a targeted patch for the sealed class -> Metaclass => case. Whichever option is chosen here, that lookahead patch becomes unnecessary and should be removed:

Migration Path

This is a breaking change. All existing class methodName => ... definitions in .bt files require migration.

For Options B or C (Mechanical)

The migration is a token-level rename and can be performed with a targeted source transformation. Conceptually:

// Before (current syntax)
class new -> Foo => @primitive "new"
class pi -> Float => @primitive "pi"
sealed class -> Metaclass => @primitive "classClass"

// After (Option B)
meta new -> Foo => @primitive "new"
meta pi -> Float => @primitive "pi"
sealed class -> Metaclass => @primitive "classClass"   // no change: `class` is now unambiguously the method name

// After (Option C)
+ new -> Foo => @primitive "new"
+ pi -> Float => @primitive "pi"
sealed class -> Metaclass => @primitive "classClass"   // no change: `class` is now unambiguously the method name

After renaming, generated_builtins.rs must be regenerated via just build-stdlib.

For Option A (Structural)

Each class with class-side methods is split into two declarations. A manual review is required because classVar: placement must be decided per-class. The transformation is not purely textual.

Example migration for TranscriptStream:

// Before (current syntax — single block)
Object subclass: TranscriptStream
    classVar: uniqueInstance = nil

    show: text => ...
    class new => self error: 'Use uniqueInstance instead'
    class uniqueInstance =>
        self.uniqueInstance ifNil: [self.uniqueInstance := super new].
        self.uniqueInstance

// After (Option A — two declarations)
Object subclass: TranscriptStream
    classVar: uniqueInstance = nil

    show: text => ...

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

User-Facing Communication

Because this is a syntax breaking change, migration must be coordinated with a compiler version increment. The compiler should emit a clear diagnostic for any source file that still uses the old class methodName modifier form after the migration deadline:

// Option B example diagnostic:
error: `class` is no longer a method modifier — use `meta methodName`
  --> stdlib/src/Float.bt:12:5
   |
12 |     class pi -> Float => @primitive "pi"
   |     ^^^^^
   |
   = note: see ADR 0048 for migration instructions

The exact diagnostic text is conditional on the option selected. For Option A, the message would instead suggest restructuring into an X class subclass: Y class declaration. The diagnostic is only generated if an option that removes the class modifier is chosen — Option D (do nothing) emits no diagnostic.

References