ADR 0066: Open Class Extension Methods (>> Syntax)

Status

Accepted (2026-03-18)

Context

Beamtalk supports adding methods to existing classes — including sealed primitives — using the >> standalone method definition syntax:

Counter >> increment => self.value := self.value + 1
String >> shout => self asUppercase ++ "!"
Integer >> double => self * 2
Array class >> ofSize: n => Array new: n withAll: 0

This feature is already implemented across the pipeline: parser (StandaloneMethodDefinition AST node), semantic analysis, codegen (runtime registration via beamtalk_extensions ETS), REPL, and hot reload. The >> token also serves as a binary operator in expression context for method reflection (Counter >> #increment returns a CompiledMethod).

What was missing was:

  1. An ADR documenting the design decisions
  2. A file naming convention for extension method files
  3. Clear policy on sealed class extensions and conflict resolution
  4. A compile-time analysis model for type checking and conflict detection

Current Implementation

Parser: ClassName [class] >> selector => body is parsed as a StandaloneMethodDefinition containing the target class name, an is_class_method flag, and the method definition. Standalone methods are stored in Module.method_definitions, separate from class bodies.

Codegen: Standalone methods are not compiled into the target class's static BEAM module. Instead, they are registered at load time via beamtalk_extensions:register/4, which stores them in an ETS table keyed by {ClassName, Selector}.

Dispatch: When a message is not found in the target class's local method table, the dispatcher checks beamtalk_extensions:lookup/2 before walking the class hierarchy. If an extension is found, it is called directly. This integrates extensions into the normal dispatch chain at each level of the hierarchy walk — extensions on the current class are checked before moving to the superclass.

Conflict resolution: Last-writer-wins with warning logging and conflict history tracking.

The Problem: Type System and Conflict Detection

The ETS-based runtime dispatch works correctly and is O(1) — there is no performance problem. However, the purely dynamic model has two gaps that must be addressed as the language matures:

  1. Type system blind spot: Extension methods in ETS are invisible to the type checker. A typed call site like x factorial (where x :: Integer) cannot verify at compile time that factorial exists on Integer. This makes extensions second-class citizens in the gradual type system (ADR 0025).
  2. Runtime-only conflict detection: Two packages defining String >> json is only detected at load time via a logged warning. In production deployments, log output during load may be silently discarded.

The solution is compile-time static analysis — a build step that collects extension declarations and feeds them to the conflict detector and type checker — without changing the runtime dispatch mechanism.

Decision

1. Syntax

The >> syntax for standalone method definitions is confirmed as the standard way to add methods to existing classes:

// Instance method
String >> reversed => self asArray reversed join: ""

// Class method
String class >> fromCharCodes: codes => codes collect: [:c | c asCharacter] join: ""

// Binary operator
Point >> + other => Point x: self x + other x y: self y + other y

// Keyword method
Array >> chunksOf: n =>
  result := Array new.
  self withIndex do: [:item :i |
    (i % n) = 0 ifTrue: [result add: Array new].
    result last add: item
  ].
  result

REPL usage:

>> Integer >> double => self * 2
=> <StandaloneMethodDefinition>
>> 21 double
=> 42
>> (Integer >> #double) source
=> "double => self * 2"

Error on misuse:

>> 42 >> increment => self + 1
Error: Expected class name before '>>' (got Integer literal)

2. File Naming Convention: ClassName+Feature.bt

Extension method files follow the Swift-style naming convention:

stdlib/src/String+JSON.bt       // String >> json => ..., String >> fromJson: => ...
stdlib/src/Array+Sorting.bt     // Array >> sortBy: => ..., Array >> sorted => ...
myapp/src/Integer+Roman.bt      // Integer >> asRoman => ...
myapp/src/Actor+Logging.bt      // Actor >> logInfo: => ..., Actor >> logError: => ...

Rules:

What goes where:

3. Runtime Dispatch: ETS with Hierarchy-Interleaved Lookup

Runtime dispatch uses the existing ETS-based extension registry. Extensions are checked at each level of the hierarchy walk, not deferred to a fallback position:

  1. Local methods on the receiver's class
  2. ETS extension lookup on the receiver's class
  3. Inherited methods from superclass
  4. ETS extension lookup on superclass
  5. ... (continue up hierarchy to Object)
  6. doesNotUnderstand: fallback

This interleaved ordering ensures that:

An extension cannot override a method defined in the class body — it can only add new selectors or override inherited methods.

4. Compile-Time Analysis: Conflict Detection and Type Metadata

A compile-time analysis pass runs during just build to provide static guarantees without changing the runtime:

  1. The compiler scans all StandaloneMethodDefinition nodes across the project and its dependencies
  2. Duplicate {Class, Side, Selector} registrations — from any source, same-package or cross-package — are compile errors. Instance-side and class-side methods are distinct: String >> json and String class >> json do not conflict (they target different BEAM modules — the class and its metaclass respectively)
  3. Extension declarations are written to a type metadata file that the gradual type checker reads, making extensions part of a class's typed method surface
error[E0451]: extension conflict on String>>json
  --> myapp/src/String+JSON.bt:3:1
   |
3  | String >> json => ...
   | ^^^^^^^^^^^^^^^^ defined here
   |
  --> myapp/src/String+Serialization.bt:7:1
   |
7  | String >> json => ...
   | ^^^^^^^^^^^^^^^^ also defined here
   |
   = help: rename one of the extensions to avoid the conflict

This is a hard error — the build fails. No last-writer-wins ambiguity in compiled projects.

In the REPL: Last-writer-wins with provenance tracking remains, as currently implemented. This is intentional — the REPL supports iteratively redefining extensions during development:

Why compile-time analysis, not runtime consolidation:

An earlier revision of this ADR proposed an Elixir-style "protocol consolidation" model that would generate a static dispatch index at build time, replacing ETS for compiled code. This was rejected because:

  1. ETS lookup is already O(1) — there is no runtime performance problem to solve. Elixir protocol consolidation addresses module enumeration overhead; Beamtalk's ETS-keyed lookup has no equivalent bottleneck.
  2. A consolidated dispatch index introduces a dispatch ordering bug — consolidated extensions checked before ETS fallback would make REPL-defined extensions unreachable when they conflict with inherited methods, breaking the interactive development model.
  3. Hot code reload becomes complex — a static index cannot be updated without rebuilding, creating a split between dev and production behavior.
  4. The real goals (type checking, conflict detection) only require compile-time analysis of declarations, not a new runtime dispatch mechanism. Keeping ETS dispatch unchanged avoids all of these problems.

5. Sealed Class Policy

Sealed classes CAN be extended. "Sealed" means a class cannot be subclassed, not that it cannot receive new methods. Extensions are the primary mechanism for adding behaviour to sealed primitives like Integer, String, Float, Boolean, and Array.

// This is valid — Integer is sealed but extensible
Integer >> factorial =>
  self <= 1
    ifTrue: [1]
    ifFalse: [self * (self - 1) factorial]

This follows Pharo's model where extension methods are regular methods in the class's logical method dictionary.

6. Load Order

Extensions are registered when their containing module's on_load callback fires (the register_class/0 function generated by codegen). In compiled .bt files, this happens when the BEAM module is loaded by the VM. In the REPL, this happens immediately on evaluation. During hot reload, re-loading a file re-registers its extensions, overwriting previous registrations from the same owner.

Load order within a package follows the order specified in beamtalk.toml. Cross-package load order follows dependency declarations. Within these constraints, extension registration order is deterministic. The compile-time conflict detector catches duplicates regardless of load order.

7. ETS Table Lifecycle

The beamtalk_extensions and beamtalk_extension_conflicts ETS tables are created by beamtalk_extensions:init/0, called during the beamtalk_runtime OTP application startup. Both tables are public and named_table with read_concurrency enabled. They are owned by the runtime supervisor process and survive individual process crashes. If the runtime application itself restarts, extensions are re-registered as modules are re-loaded by the VM.

Extensions are node-local — in a distributed BEAM cluster, each node maintains its own extension registry. This matches the standard BEAM model where code loading is per-node.

Prior Art

LanguageMechanismFile ConventionConflict ResolutionExtend Sealed?
PharoOpen classes via *Package protocolsClass.extension.st in package dirLast loaded winsYes (all classes)
NewspeakNone (by design)N/AN/ANo
RubyOpen classes / refinementscore_ext/class_name.rb (Rails)Last defined wins / lexical (refinements)Yes
Swiftextension Type { }Type+Feature.swiftCompile error (same module)Yes
Kotlinfun Type.method()TypeExtensions.ktMember wins; ambiguity = errorYes
C#this Type parameterTypeExtensions.csMember wins; ambiguity = errorYes
ElixirProtocols (type-class-like)Co-located with protocolCompile error (duplicate impl)Yes (all types)

Key influences:

Rejected influences:

User Impact

Newcomer (from Python/JS/Ruby):

Smalltalk developer:

Erlang/BEAM developer:

Production operator:

Tooling developer:

Steelman Analysis

Option B: ClassName.extension.bt (Pharo Tonel-style)

Option C: ClassName_extensions.bt (Kotlin/C#-style)

ETS-only with no compile-time analysis

Runtime consolidation (Elixir-style)

Tension Points

Alternatives Considered

ClassName.extension.bt (Pharo Tonel-style)

One extension file per target class (e.g., String.extension.bt). Rejected because it doesn't scale — a popular class like String would accumulate dozens of unrelated extensions in a single file, making the file hard to navigate and causing merge conflicts when multiple developers add extensions to the same class.

ClassName_extensions.bt (Kotlin/C#-style)

Same single-file-per-class problem as Tonel, plus the underscore conflicts with Beamtalk's established PascalCase file naming convention (String.bt, Array.bt, not string.bt or array_extensions.bt).

Mixed convention (A + B)

Allow both +Feature and .extension patterns. Rejected because having two conventions leads to inconsistency — developers would need to decide which to use, and different packages would make different choices, fragmenting the ecosystem.

No convention (freeform)

Let developers name extension files however they want. Rejected because consistent naming enables tooling (glob patterns, IDE file browsers) and makes extensions discoverable across packages.

ETS-only with no compile-time analysis

Keep extensions purely dynamic with no build-time checks. Rejected because:

  1. The type checker cannot see ETS-registered methods — extensions would be permanently untyped
  2. Conflicts are detected only at runtime, which is too late for production deployments

Runtime consolidation (Elixir-style protocol consolidation)

Generate a static dispatch index at build time, replacing ETS for compiled code. Rejected because:

  1. ETS lookup is already O(1) — there is no runtime performance problem to solve
  2. A consolidated dispatch index creates a dispatch ordering bug: REPL-defined extensions placed after the full hierarchy walk become unreachable when they conflict with inherited methods
  3. Hot code reload of extensions requires rebuilding the consolidated index, breaking the live development model
  4. The "consolidated extension index" BEAM artifact is undefined — no clear implementation path on BEAM without modifying target class modules
  5. Compile-time analysis achieves the same goals (type checking, conflict detection) without changing runtime dispatch

Protocol/typeclass approach (Elixir-style)

Instead of open classes, use protocols — Serializable protocol with per-type implementations. This avoids global mutation and last-writer-wins conflicts entirely, and is statically analyzable. Rejected as a replacement for open classes, but acknowledged as complementary — many practical uses of >> (JSON serialization, formatting, logging) are protocol-shaped and would be better expressed as protocols once they exist.

The two mechanisms solve different axes of the expression problem:

Beamtalk will add protocols (required for the gradual type system, ADR 0025). When that happens, the guidance should be: use protocols for cross-type operations, use >> for type-specific additions. Some existing >> extensions may migrate to protocol implementations — this is expected and healthy.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1a: Current (Implemented)

ETS-based extension registration and dispatch.

ComponentFileStatus
Parsercrates/beamtalk-core/src/source_analysis/parser/declarations.rsImplemented
ASTcrates/beamtalk-core/src/ast.rs (StandaloneMethodDefinition)Implemented
Semantic analysiscrates/beamtalk-core/src/source_analysis/semantic_analysis/Implemented
Codegencrates/beamtalk-core/src/codegen/core_erlang/gen_server/methods.rsImplemented
Dispatch (ETS)crates/beamtalk-core/src/codegen/core_erlang/gen_server/dispatch.rsImplemented
Runtime registryruntime/apps/beamtalk_runtime/src/beamtalk_extensions.erlImplemented
REPLInline >> definitions and :load of extension filesImplemented
Hot reloadExtension re-registration on file reloadImplemented

Note: REPL >> definitions work correctly for state mutations because the REPL recompiles the entire class (concatenating existing source with the new method), making the extension a local method with full state threading. File-loaded extensions go through the ETS dispatch path, which has the state threading bug described in Phase 1b.

Phase 1b: Fix Extension State Threading (Bug)

The ETS dispatch path does not thread state for extension methods. The current generated code:

let ExtResult = apply ExtFun(Args, Self) in
{'reply', ExtResult, State}          %% ← returns OLD State, mutations discarded

An extension like Counter >> debugIncrement => self.value := self.value + 1 silently discards the state mutation — the method appears to run but the actor's state is unchanged.

Fix: Change the extension closure signature and dispatch to match regular methods:

apply ExtFun(Args, Self, State)      %% ← extension receives State
                                     %% ← returns {'reply', Result, NewState} directly
ComponentDescriptionStatus
Extension closure codegenChange signature from fun(Args, Self) -> Result to fun(Args, Self, State) -> {'reply', Result, NewState}Not started
Dispatch unwrappingRemove {'reply', ExtResult, State} wrapper; use extension's return directlyNot started
Extension registryUpdate beamtalk_extensions:register/4 to store new-signature closuresNot started
TestsAdd test: extension on actor class with state mutation via ETS pathNot started

Phase 2: Compile-Time Analysis

Add a build-time pass that collects extension declarations for conflict detection and type metadata.

ComponentDescriptionStatus
Extension collectorScan all StandaloneMethodDefinition across project + dependenciesNot started
Conflict detectorReport duplicate {Class, Side, Selector} as compile errorsNot started
Type metadata emitterWrite extension declarations to metadata for the type checkerNot started

Phase 3: Type System Integration

Make extensions visible to the gradual type checker via the metadata from Phase 2.

ComponentDescriptionStatus
Type checker reads extension metadataExtensions contribute to a class's typed method surfaceDone (BT-1518)
Extension type annotationsInteger >> factorial :: -> Integer => ...Done (BT-1519)

Documentation (BT-1473)

  1. Add >> syntax section to docs/beamtalk-language-features.md
  2. Test and document sealed class extension behaviour explicitly
  3. Enforce ClassName+Feature.bt naming convention in documentation and examples

Known Limitations

Flat namespace assumption: The ETS key {ClassName, Selector} uses bare atoms, assuming a flat class namespace (ADR 0031). If Beamtalk later introduces namespaced classes, the registry key format will need migration.

REPL extensions are untyped: Extensions defined interactively in the REPL bypass compile-time analysis and are invisible to the type checker. This is by design — the REPL is the "dynamic zone" of gradual typing — but means REPL-defined extensions won't get type error reporting until they are moved to a +Feature.bt file.

ETS is global mutable state: All extensions share a single ETS table per node. Test isolation requires explicit cleanup between test suites that register extensions. This is inherent to the Pharo-style open class model.

Implementation Tracking

Epic: BT-1513

PhaseIssueTitleSizeStatus
1bBT-1512Fix extension state threading — ETS dispatch discards mutationsSBacklog
2BT-1473Document >> syntax in language features docsMIn Review
2BT-1514Test sealed class extensions via ETS dispatch pathSBacklog
3BT-1515Extension collector — scan declarations across projectMBacklog
3BT-1516Compile-time conflict detection for duplicate extensionsSBacklog
3BT-1517Emit extension method type metadata for the type checkerMBacklog
4BT-1518Type checker reads extension metadata — typed method surfaceMBacklog
4BT-1519Extension type annotation syntax — return types on >>MBacklog

References