ADR 0039: Syntax Pragmatism vs Smalltalk

Status

Implemented (2026-02-24)

Context

Beamtalk is Smalltalk-inspired, not Smalltalk-compatible. The language preserves Smalltalk's core innovation — message-passing syntax with named parameters — while making pragmatic departures from Smalltalk-80 conventions that create unnecessary friction for modern developers.

These departures are individually small but collectively define Beamtalk's character. They were documented informally in docs/beamtalk-syntax-rationale.md during early design, but have never been captured as a formal architectural decision. This ADR consolidates them into a single record.

Design Goal

Keep the soul of Smalltalk, remove the friction.

Beamtalk's target audience includes developers from Erlang/BEAM, mainstream languages (Python, TypeScript, Rust), and Smalltalk. The syntax choices must balance familiarity across these cohorts while preserving message-passing elegance.

What We Keep

These Smalltalk features are preserved without change:

Decision

Beamtalk departs from Smalltalk-80 in the following areas. Each departure follows a consistent principle: where Smalltalk convention conflicts with universal developer expectations, choose pragmatism.

1. Comments: "..."// and /* */

Smalltalk-80:

"This is a comment"
count := 0.  "inline comment"

Beamtalk:

// This is a comment
count := 0  // inline comment

/*
  Multi-line comment
  for longer explanations
*/

Rationale:

2. Math Precedence: Left-to-Right → Standard (PEMDAS)

Smalltalk-80:

2 + 3 * 4.   "=> 20 (evaluated left-to-right)"

Beamtalk:

2 + 3 * 4    // => 14 (standard math precedence)
(2 + 3) * 4  // => 20 (explicit grouping)

Rationale:

Precedence levels (highest to lowest):

  1. ** (exponentiation, right-associative)
  2. *, /, % (multiplicative)
  3. +, -, ++ (additive and string concatenation)
  4. <, >, <=, >= (comparison)
  5. =:=, ==, /=, =/= (equality — strict and loose)

Note: &&, ||, and, or are not binary operators — they are keyword messages taking blocks for short-circuit evaluation: condition and: [expensiveCheck].

3. Statement Terminator: Required . → Optional (Newlines)

Smalltalk-80:

count := 0.
count := count + 1.
self doSomething.

Beamtalk:

count := 0
count := count + 1
self doSomething

// Periods still work as explicit separators (useful inside blocks)
[count := 0. count := count + 1]

Rationale:

Note: Semicolons (;) are not statement separators — they are cascade operators (see "What We Keep" above).

4. Field Access: Add self.field Notation

Smalltalk has no field access syntax — all access is via messages. Beamtalk adds direct field access:

// Direct field access within actor
self.value          // read field
self.value := 10    // write field
self.value := self.value + 1

// Equivalent message send (still works)
self getValue       // unary message

Rationale:

Compilation: self.value compiles to maps:get('value', State) — a direct lookup, not a message send.

Future direction — Slots: The current self.field syntax is intentionally forward-compatible with a slot-based extension, as seen in Pharo and Dylan. A slot is a class-side object that controls how a field is compiled — the default slot emits maps:get (same as today), but custom slot types could emit type checks, change notifications, or lazy initialisation with zero overhead for the common case. Access control would be enforced at compile time rather than runtime, which fits Beamtalk's actor model naturally: field access is already bounded by process scope externally, so compile-time enforcement within the class hierarchy is the right level.

state: would remain the shorthand for the default slot — no existing code changes. Custom slots would be expressed as an extension of the existing state: declaration syntax (which already supports type annotations as state: value: Integer = 0):

state: value = 0                           // unchanged — default slot, maps:get
state: value: Integer = 0                  // already supported today
state: value slot: ObservableSlot = 0      // future — custom slot type

self.value remains the access form regardless of slot type.

5. Return Semantics: Implicit Returns, ^ for Early Returns Only

Smalltalk-80: ^ required for every return.

Beamtalk:

// Implicit return of last expression (preferred)
getValue => self.value

// Explicit return ONLY for early returns
max: other =>
  self > other ifTrue: [^self]   // early return — use ^
  other                           // last expression — implicit return

// Blocks also use implicit return
[:x | x * 2]  // returns x * 2

Rationale:

Style rule: Use ^ ONLY for early returns (returning before the final expression). Never use ^ on the last line of a method or block.

6. Control-Flow Block Mutations with State Threading

BEAM constraint: Smalltalk-80 blocks capture variables by reference (shared mutable cells), so whileTrue: with mutations works naturally in Pharo and Squeak. But BEAM enforces single-assignment variables — there are no heap-allocated mutable cells. Without compiler support, a naïve translation of whileTrue: to Erlang would fail to propagate mutations:

%% Naïve Erlang translation — crashes at runtime
Count = 0,
while_true(fun() -> Count < 10 end, fun() -> Count = Count + 1 end).
%% Runtime error: {badmatch,1} — Count is already bound to 0; rebinding is not possible

Beamtalk solution: The compiler detects literal blocks in control-flow positions and generates tail-recursive loops with explicit state threading, restoring the Smalltalk mutation semantics that developers expect:

count := 0
[count < 10] whileTrue: [count := count + 1]
// count is now 10

// Field mutations work too
[self.value < 10] whileTrue: [
    self.value := self.value + 1
]

Rationale:

  1. Smalltalk idioms require mutations in control flow — whileTrue:, timesRepeat:, do: are central to Smalltalk style
  2. BEAM's single-assignment model requires compiler support to achieve this — compilation to tail-recursive loops with state threading bridges the semantic gap
  3. Better than alternatives: C-style loops lose message-passing elegance; immutable-only makes simple counters painful; mutable-everywhere loses reasoning guarantees

Current rule (Tier 1 — implemented):

Literal blocks in recognised control-flow positions can mutate local variables and fields. The compiler generates tail-recursive loops with explicit state threading.

// Literal block in control flow — works today
[count < 10] whileTrue: [count := count + 1]

Recognised control-flow constructs: whileTrue:, whileFalse:, timesRepeat:, to:do:, do:, collect:, select:, reject:, inject:into:.

Planned: Universal block protocol (Tier 2 — ADR 0041, accepted):

All blocks that capture mutable variables will use a universal state-threading protocol: fun(Args..., StateAcc) -> {Result, NewStateAcc}. The recognised constructs above become Tier 1 optimization hints rather than a correctness gate.

// Stored closure with mutation — planned (Tier 2: universal protocol)
myBlock := [count := count + 1]
10 timesRepeat: myBlock          // count will be 10

// User-defined HOM — planned (Tier 2: universal protocol)
items eachPair: [:a :b | count := count + a + b]   // mutations will propagate

Known limitations:

Cross-Referenced Departures

The following departures have dedicated ADRs and are summarised here for completeness:

DepartureADRSummary
String interpolationADR 0023"Hello, {name}!" — double quotes only, {expr} syntax, compiles to binary append
Equality semanticsADR 0002Use Erlang's ==, /=, =:=, =/= directly — structural equality, not identity-based
Class definition syntaxADR 0038Object subclass: Counter is parsed as syntax (not a message send), compiled to ClassBuilder protocol
No compound assignmentADR 0001No +=, -= — use explicit x := x + 1 to preserve message-passing purity
Control-flow block mutationsADR 0041Universal state-threading block protocol — mutations will work in user-defined higher-order methods, not just whitelisted stdlib selectors (Tier 2, planned)

Prior Art

Smalltalk-80 / Pharo

The baseline. All departures are measured against Smalltalk-80 conventions. Pharo has made its own pragmatic changes over the years (e.g., array literals, fluid class definition API) while maintaining core syntax. Beamtalk goes further.

Newspeak (Gilad Bracha)

Also Smalltalk-inspired, Newspeak makes pragmatic departures of its own. It keeps Smalltalk's "..." comments and left-to-right precedence. Beamtalk is more aggressive about modernising surface syntax while Newspeak focuses more on modularity and capability-based security.

Ruby

Took Smalltalk's "everything is an object" philosophy and made it palatable to mainstream developers via familiar syntax (def, end, # comments). Ruby's success validates the approach of keeping the paradigm while modernising the syntax. Beamtalk goes further by keeping keyword messages and blocks.

Erlang / Elixir

Beamtalk targets the same runtime. Elixir's success demonstrates that a modernised syntax on BEAM attracts developers. Beamtalk's equality operators, string handling, and compilation model follow Erlang conventions directly.

Kotlin / Swift

Both modernised their predecessors (Java, Objective-C) by removing ceremony. Implicit returns, standard precedence, // comments — Beamtalk makes the same moves relative to Smalltalk.

Dylan

Dylan (Apple/CMU, 1990s) was another Lisp/Smalltalk-inspired language with pragmatic syntax choices for mainstream developers. Relevant to Section 4: Dylan's slot system defines fields as class-side objects with define class specifying slot options (sealed, open, constant, virtual). The compiler dispatches to the appropriate accessor code at compile time based on the slot descriptor — zero runtime overhead for the common case, full extensibility for instrumented or virtual slots. This is the same compile-time polymorphism model that Pharo later adopted and that Beamtalk's self.field syntax is forward-compatible with.

User Impact

Newcomers (from Python/TypeScript/Rust)

Positive. Comments, math precedence, implicit returns, and string interpolation all work as expected. The learning curve focuses on keyword messages and blocks (Smalltalk's actual innovations) rather than on comment syntax and operator precedence (historical accidents).

Smalltalk Developers (from Pharo/Squeak)

Mixed. Keyword messages, blocks, cascades, and := assignment all feel familiar. However: // comments instead of "...", standard math precedence instead of left-to-right, and implicit returns will require adjustment. The control-flow mutation semantics fix a genuine pain point in Smalltalk-80.

Erlang/BEAM Developers

Positive. Equality operators map 1:1 to Erlang. self.field compiles to maps:get/2 — transparent. String interpolation compiles to binary operations. No hidden runtime magic.

Language Designers / Operators

Neutral. The departures are well-documented and internally consistent. Each follows the principle of "keep the paradigm, modernise the surface."

Steelman Analysis

For Smalltalk-80 Compatibility (Smalltalk cohort)

"Every departure from Smalltalk-80 makes Beamtalk harder to learn for existing Smalltalkers and reduces the value of Smalltalk educational materials. Left-to-right precedence is simpler (one rule, no precedence table to memorise). "..." comments are fine once you know them. The departures optimise for first impressions at the cost of paradigm coherence. Worse, the 'literal vs stored' block distinction has no Smalltalk precedent — in Pharo, all blocks can mutate outer variables. Beamtalk claims to preserve Smalltalk idioms but introduces a restriction that breaks the fundamental assumption that blocks are composable."

Response: Valid concerns on both counts. For surface syntax: Beamtalk's target audience is broader than the existing Smalltalk community. The departures remove barriers that prevent developers from discovering Smalltalk's actual innovations (message passing, live objects). A developer who bounces off "comment" syntax never reaches keyword messages. For block compositionality: this is a genuine limitation imposed by BEAM's single-assignment model. ADR 0041 (accepted) will resolve this by introducing a universal state-threading block protocol where all blocks that capture mutable state use fun(Args..., StateAcc) -> {Result, NewStateAcc}. Once implemented, user-defined higher-order methods will participate in state threading and the hardcoded whitelist will be reclassified from a correctness gate to a performance optimization hint.

For Maximum Modernisation (Mainstream cohort)

"If you're already departing from Smalltalk, go further. Use def instead of =>, use fn for blocks, use class Counter < Object instead of Object subclass: Counter. Half-measures satisfy nobody."

Response: The departures are carefully scoped to remove friction without removing identity. Keyword messages, blocks, and cascades are what make Beamtalk worth using. Replacing them with mainstream syntax yields "another Ruby" — functional but undifferentiated. The retained Smalltalk features are the value proposition.

For Pure BEAM Semantics (Erlang cohort)

"Control-flow mutations add hidden complexity. In Erlang, all variables are single-assignment — the semantics are transparent. State threading behind whileTrue: is magic that hides what the BEAM is actually doing. And it gets worse: non-local returns (^ inside do: blocks) compile to throw/catch, which has measurable allocation cost and produces opaque crash dumps. The 'BEAM transparency' claimed elsewhere in this ADR is selectively true."

Response: The state threading compiles to explicit tail-recursive loops — no hidden mutable state at the BEAM level. The "magic" is syntactic sugar with a clear compilation story. The throw/catch concern for non-local returns is valid and acknowledged as a known performance cost (see Section 6 limitations). The alternative (requiring developers to write explicit recursive functions for simple counters) undermines Smalltalk's control-flow elegance, which is a core design goal. The debuggability concern for crash dumps is real — BEAM-level variable names like StateAcc do not map obviously to Beamtalk source — and is an area for future tooling improvement.

Alternatives Considered

Keep Full Smalltalk-80 Syntax

Description: Use "..." comments, left-to-right precedence, required . terminators, no field access, no string interpolation.

Rejected because: Creates unnecessary friction for the vast majority of potential users. Smalltalk-80 syntax is a barrier to adoption, not a feature. Developers who want full Smalltalk-80 can use Pharo.

Adopt Ruby/Python Syntax Entirely

Description: Use def/end, # comments, class Counter(Object):, standard function call syntax.

Rejected because: Loses keyword messages, blocks, and cascades — the features that differentiate Beamtalk. Results in "another Ruby on BEAM" which has less value than a language that brings Smalltalk's unique features to modern developers.

Make Departures Optional (Compatibility Mode)

Description: Support both "..." and // comments, both left-to-right and PEMDAS precedence (via a pragma), etc.

Rejected because: Two dialects doubles the testing surface, complicates tooling, and fragments the community. One syntax, consistently applied, is better than optional compatibility.

Consequences

Positive

Negative

Neutral

Implementation

All departures documented here are already implemented in the compiler.

Affected components:

No further implementation work is required. This ADR formalises decisions that were made incrementally during early development.

References