Beamtalk Language Features

Language features for Beamtalk. See Design Principles for design philosophy and Syntax Rationale for syntax design decisions.

Status: v0.4.0 — implemented features are stable, including generics, protocols, union types, control flow narrowing, the typed class modifier, FFI type inference, package management with qualified pkg@Class names, native Erlang sources in packages, named actor registration, and Result-shaped supervisor lifecycles. See ADR 0068 for the type system design.

Syntax note: Beamtalk uses a modernised Smalltalk syntax: // comments (not "..."), standard math precedence (not left-to-right), and optional statement terminators (newlines work).


Table of Contents


String Encoding and UTF-8

Beamtalk strings are UTF-8 by default. This follows modern BEAM conventions and matches Elixir's approach. String is a subclass of Binary (Collection > Binary > String) — see Binary — Byte-Level Data for byte-level operations inherited by String.

String Types

// Double-quoted strings - UTF-8 binaries
name := "Alice"
greeting := "Hello, 世界! 🌍"

// String interpolation (ADR 0023)
message := "Welcome, {name}!"
emoji := "Status: {status} ✓"

// Escape sequences inside strings:
//   ""   doubled delimiter → literal double-quote character
//   \{   backslash preserved → literal \{  (prevents interpolation)
//   \}   backslash preserved → literal \}
quote := """"                    // 1-char string containing "
dialog := "She said ""hello"""  // → She said "hello"
// Note: \{ and \} keep the backslash in the string value (current lexer behavior)

// All strings are <<"UTF-8 binary">> in Erlang

Character Encoding

BeamtalkErlang/BEAMNotes
"hello"<<"hello">>UTF-8 binary
"Hi, {name}"<<"Hi, ", Name/binary>>Interpolated UTF-8 (ADR 0023)
Grapheme clusterVia :string module"👨‍👩‍👧‍👦" is one grapheme, multiple codepoints
$a97 (codepoint)Character literal = Unicode codepoint

Character Literal Methods

Character literals dispatch through the Character method table, so methods like asString, printString, uppercase, lowercase, and class return Character-appropriate values:

$A asInteger              // => 65
$A asString               // => "A"
$A printString            // => "$A"
$A uppercase              // => 65
$A lowercase              // => 97
$A class                  // => Character
$A respondsTo: #uppercase // => true
Character value: 65       // => $A

String Operations (Grapheme-Aware)

String operations respect Unicode grapheme clusters (user-perceived characters):

// Length in graphemes, not bytes
"Hello" length        // => 5
"世界" length          // => 2 (not 6 bytes)
"👨‍👩‍👧‍👦" length        // => 1 (family emoji is 1 grapheme, 7 codepoints)

// Slicing by grapheme
"Hello" at: 1         // => "H"
"世界" at: 1           // => "世"

// Iteration over graphemes
"Hello" each: [:char | Transcript show: char]

// Case conversion (locale-aware)
"HELLO" lowercase     // => "hello"
"straße" uppercase    // => "STRASSE" (German ß → SS)

Inherited Byte-Level Methods

String inherits byte-level methods from Binary. These provide unambiguous byte access regardless of grapheme semantics:

// Byte access (inherited from Binary)
"hello" byteAt: 0      // => 104 (byte value, 0-based)
"hello" byteSize       // => 5 (byte count)
"café" byteSize        // => 5 (bytes — more than 4 graphemes due to UTF-8)

// Byte-level slicing returns Binary, not String
"hello" part: 0 size: 3  // => Binary (raw bytes, not String)

// Byte-level concatenation returns Binary
"hello" concat: " world"  // => Binary (use ++ for String concatenation)

// Byte list conversion
"hello" toBytes        // => #(104, 101, 108, 108, 111)

See Binary — Byte-Level Data for the full Binary API.

BEAM Mapping

BeamtalkErlangNotes
"string"<<"string">>Binary, not charlist
"世界"<<228,184,150,231,149,140>>UTF-8 encoded bytes
String operations:string moduleGrapheme-aware (:string.length/1)
$xInteger codepoint$a = 97, $世 = 19990
Charlist (legacy)[104,101,108,108,111]Via Erlang interop

Why UTF-8 by Default?

  1. Modern web/API standard - JSON, HTTP, REST APIs all use UTF-8
  2. Compact for ASCII - 1 byte per ASCII character (most code/English text)
  3. Elixir compatibility - Seamless interop with Elixir libraries
  4. BEAM convention - Erlang's :string module is Unicode-aware
  5. Agent/LLM-friendly - AI models output UTF-8; easy integration

Legacy Charlist Support

Charlists are Erlang lists of integer codepoints. Beamtalk uses binaries for strings, but you can convert when needed for Erlang interop via binary_to_list / list_to_binary.


Core Syntax

Actor Definition

Actor subclass: Counter
  state: value = 0

  increment => self.value := self.value + 1
  decrement => self.value := self.value - 1
  getValue => self.value
  incrementBy: delta => self.value := self.value + delta

Actor Lifecycle Hooks

Actors support two optional lifecycle hooks:

Actor subclass: ResourceActor
  state: handle = nil

  initialize =>
    self.handle := Resource open

  terminate: reason =>
    self.handle isNil ifFalse: [self.handle close]

  doWork => self.handle process

Key behaviour:

Aspectinitializeterminate:
Called onspawn / spawnWith:stop (graceful shutdown)
Error effectSpawn fails with InstantiationErrorShutdown proceeds anyway
Called on kill?N/ANo — kill bypasses terminate:
Actor stateAccessible via self.fieldAccessible via self.field

Both hooks are optional — actors without them work normally.

Initialization chaininginitialize methods defined on ancestors run automatically, parent-first, before the child's own initialize. State threads through each call so the child sees the parent's mutations. Do not write super initialize yourself — the compiler warns on redundant super initialize sends inside an Actor's initialize. After the full chain runs, any typed state: field without a default (on the child or any ancestor, including cross-file parents) must have been assigned, or the actor crashes with UninitializedStateError naming the owning class. See ADR 0078.

Three Class Kinds (ADR 0067)

Beamtalk has three class kinds with distinct data keywords and construction protocols:

Class KindData KeywordSemanticsConstructionInstance Process
Valuefield:Immutable data slots, self.slot := is compile errornew / new: / keyword ctorNo
Actorstate: (permitted, not required)Mutable process state, self.slot := persists via gen_serverspawn / spawnWith:Yes
Object(none)No Beamtalk-managed data; often class-methods-only, but can have instances with runtime-backed state (ETS, handles)Custom constructorsNo
// Value — immutable data, no process (ADR 0042)
Value subclass: Point
  field: x = 0
  field: y = 0

  // Methods return new instances (immutable)
  plus: other => Point new: #{x => (self.x + other x), y => (self.y + other y)}
  printString => "Point({self.x}, {self.y})"

// Actor — process with mailbox
Actor subclass: Counter
  state: count = 0

  // Methods mutate state via message passing
  increment => self.count := self.count + 1
  getCount => self.count

// Object — no Beamtalk-managed data; commonly class-methods-only
Object subclass: MathHelper
  class factorial: n =>
    n <= 1
      ifTrue: [1]
      ifFalse: [n * (self factorial: n - 1)]

Key differences:

AspectValue (Value subclass:)Actor (Actor subclass:)Object (Object subclass:)
Data keywordfield:state:(none — compile error)
InstantiationPoint new or Point x: 3 y: 4Counter spawn or Counter spawnWith: #{count => 0}Not instantiable
RuntimePlain Erlang mapBEAM process (gen_server)Class methods only
MutationImmutable — methods return new instancesMutable — methods modify stateN/A
Message passingN/A (direct function calls)Sync messages (gen_server:call)N/A
EqualityStructural (by value)Identity (by process)N/A
Use casesData structures, coordinates, moneyServices, stateful entities, concurrent tasksFFI namespaces, protocol providers, abstract bases

Class hierarchy:

ProtoObject (minimal — identity, DNU)
  └─ Object (protocol provider — reflection, equality, error handling)
       ├─ Integer, String (primitives)
       ├─ Value (immutable value objects — field:)
       │    ├─ Point, Color (value types)
       │    ├─ Collection(E) (abstract)
       │    │    └─ Set(E), Bag(E), Interval
       │    └─ TestCase (BUnit test base)
       └─ Actor (process-based — state: + spawn)
            └─ Counter, Server (actors)

Why this matters:

Object's Three Roles

Object subclass: cannot have instance data (field: or state: is a compile error). Object serves three purposes:

  1. Protocol provider — common methods inherited by all Value and Actor subclasses: isNil, respondsTo:, printString, hash, error:, yourself, show:, showCr: (debug output to Transcript; replaces the former trace:/traceCr: which are deprecated aliases)
  2. FFI namespace — zero-overhead class-method wrappers around Erlang modules and OTP primitives (e.g., Json, System, File, Ets, Random). No instances, no process
  3. Abstract extension point — framework contracts designed for subclassing, where subclasses define methods but hold no data (e.g., Supervisor, DynamicSupervisor)
// FFI namespace — wraps Erlang modules as class methods
Object subclass: Json
  class parse: str => // ... Erlang FFI
  class stringify: obj => // ... Erlang FFI

// Abstract extension point — designed for subclassing
abstract Object subclass: Supervisor
  class children => self subclassResponsibility

Sendability Tiers (ADR 0103)

Because a BEAM send copies the term, whether a value survives crossing a process boundary depends on its class kind. The type checker derives a sendability tier for every inferred type and warns (advisory only, per ADR 0100) when a value's tier is too weak for the boundary it crosses. No new annotations are required for the common case — the class kind is the annotation.

TierWhat it isBoundary behaviour
SendableValue kinds, primitives, symbols, Referencecopies perfectly — always fine
SendableRefActor kinds and the builtin Pidcopies the reference; identity preserved (hover-visible; no v1 diagnostic)
HandleScoped(#scope)Object kinds wrapping a scoped runtime handle#process handles warn when sent; #node handles are silent in v1
UnknownDynamic / untyped FFI / unclassified Objectsilent (nothing to grade on)

A Value composes structurally, inheriting the weakest tier of its fields, and generic collections inherit their element tier (List(Port) is HandleScoped). The builtin table classifies the canonical hazards directly: PidSendableRef, Port/FileHandleHandleScoped(#process), ReferenceSendable, SubscriptionHandleScoped(#node).

Checked boundaries (a HandleScoped(#process) value warns): actor message arguments, spawnWith: initial-state maps, blocks sent to actors (including Timer every:do: and postfix ! casts), and Announcement payloads. Local blocks (do:, collect:, ifTrue:) and self-sends do not warn.

Declaring handle scope

A user Object subclass: that wraps runtime state may declare its scope with a class-side handleScope: clause. The value is symbol-valued and the set is open (#process and #node ship first); undeclared Object kinds stay Unknown (silent).

// node-global handle: fine to send within the node, not across nodes
sealed typed Object subclass: MetricsTable
  handleScope: #node

handleScope: is only meaningful on Object-kind classes (a Value/Actor declaration is an advisory no-op). A companion lint nudges FFI-wrapping (native:) Object classes that carry instance behaviour but declare no scope. Suppress a sendability finding with @expect sendability.

Header clauses are parsed in a fixed order — write them as: modifiersSuperclass subclass: Name(TypeParams)native: modulehandleScope: #scope. In particular native: must precede handleScope:; reversing them (handleScope: #process native: pb) silently drops the native: clause (it falls into the class body).

Wrong Keyword Errors

The compiler enforces keyword/class-kind rules with clear error messages:

// state: on a Value — compile error
Value subclass: BadValue
  state: x = 0
// error: use 'field:' for Value subclass data declarations, not 'state:'

// field: on an Actor — compile error
Actor subclass: BadActor
  field: x = 0
// error: use 'state:' for Actor subclass data declarations, not 'field:'

// Any data declaration on an Object — compile error
Object subclass: BadObject
  state: x = 0
// error: Object subclass cannot have instance data declarations;
//   use 'Value subclass:' for immutable data or 'Actor subclass:' for mutable state

Class Modifiers

Class definitions support optional modifier keywords before the superclass:

ModifierMeaningExample
sealedCannot be subclassed by user codesealed Object subclass: Stream
abstractMust be subclassed; cannot be instantiated directlyabstract Object subclass: Supervisor
typedAll fields and methods require type annotations (ADR 0025)typed Actor subclass: TypedAccount

Modifiers can be combined: sealed typed Collection subclass: Array.

Most stdlib classes are sealed — this prevents user code from subclassing built-in types like Integer, String, Array, Result, and Stream. If you need custom behaviour, compose with these types rather than subclassing them.

Performance: Sealed actor classes benefit from a direct-call optimization — self-sends within the class emit direct function calls instead of dynamic dispatch, since the compiler knows no subclass can override the method. This is automatic and requires no user intervention.

Value subclass: in Depth

Value subclass: defines an immutable value object. All slots are set at construction time; there is no mutation.

Construction forms

Three forms create instances — all produce equivalent results:

// 1. new — all slots get their declared defaults
p := Point new                       // => Point(0, 0)

// 2. new: — provide a map of slot values; missing keys keep defaults
p := Point new: #{#x => 3, #y => 4}  // => Point(3, 4)

// 3. Keyword constructor — auto-generated from slot names
p := Point x: 3 y: 4                 // => Point(3, 4)

The keyword constructor form (Point x: 3 y: 4) is preferred for readability. The argument order follows the order the slots were declared.

with*: functional setters

Each slot automatically gets a with<SlotName>: method that returns a new instance with that slot changed. The original object is unchanged.

p  := Point x: 1 y: 2
p2 := p withX: 10       // new object: x=10, y=2
p  x                     // => 1   (original unchanged)
p2 x                     // => 10
p2 y                     // => 2

// Chaining
p3 := (Point new withX: 5) withY: 7   // x=5, y=7

Immutability enforcement

Direct slot mutation is illegal in value types:

// Compile error — rejected before the code runs:
// Value subclass: BadPoint
//   field: x = 0
//   badSetX: v => self.x := v   ← error: Cannot assign to slot

// Runtime error:
p := Point x: 1 y: 2
p fieldAt: #x put: 99   // raises: immutable_value

Value equality

Value objects compare by structural equality: two objects with the same class and the same slot values are equal (==).

p1 := Point x: 3 y: 4
p2 := Point x: 3 y: 4
p1 == p2    // => true

p3 := Point x: 9 y: 9
p1 == p3    // => false

// with*: result equals a freshly constructed object
(p1 withX: 10) == (Point x: 10 y: 4)   // => true

Value objects in collections

Value objects work seamlessly with all collection methods:

points := #((Point x: 1 y: 1), (Point x: 2 y: 2), (Point x: 3 y: 3))

// collect: transforms elements
points collect: [:p | p x]           // => #(1, 2, 3)

// select: filters elements
points select: [:p | p x > 1]        // => #(Point(2,2), Point(3,3))

// inject:into: folds
points inject: 0 into: [:sum :p | sum + p x]   // => 6

Reflection

p := Point x: 3 y: 4
p fieldAt: #x          // => 3
p fieldAt: #y          // => 4
p fieldNames size      // => 2  (contains #x and #y; order is not guaranteed)
p class                // => Point
Point superclass       // => Value

// Full chain from self up to (and including) Object (or Object class for metaclass receivers)
Counter superclassChain        // => [Counter, Actor, Object]
Object superclassChain         // => [Object]
Counter class superclassChain  // => [Counter class, Actor class, Object class]

Message Sends

// Unary message
counter increment

// Binary message (standard math precedence: 2 + 3 * 4 = 14)
2 + 3 * 4

// Keyword message
dict at: #name put: "hello"

// Cascade - multiple messages to same receiver
Transcript show: "Hello"; cr; show: "World"

Message Precedence (high to low)

  1. Unary messages: 3 factorial
  2. Binary messages: 3 + 4 (with standard math precedence within binary)
  3. Keyword messages: dict at: #name

Binary Operators

Binary operators follow standard math precedence (highest to lowest):

Exponentiation (highest precedence)

Multiplicative

Additive

Comparison

Equality (lowest precedence)

Note: bare = is not a valid Beamtalk operator — it has no entry in the parser's precedence table, so x = y fails to parse. Use =:= for value equality or == for reference equality.

Short-circuit boolean operators (and:/or:)

and and or are not binary operators. They are keyword messages that take blocks for short-circuit evaluation:

// Short-circuit AND - second block only evaluated if first is true
result := condition and: [self expensiveCheck]

// Short-circuit OR - second block only evaluated if first is false  
result := condition or: [self fallbackValue]

Field Access and Assignment

Direct field access within actors using dot notation:

// Read field
current := self.value

// Write field
self.value := 10

// Explicit assignment
self.value := self.value + 1
self.count := self.count - delta
self.total := self.total * factor

Note: self.field compiles to direct map access, not a message send. For external access to another actor's state, use message sends.

Parenthesized assignment: Field assignments can be used as expressions when wrapped in parentheses — (self.x := 5) returns the assigned value:

// Assignment as expression (returns 6)
(self.x := 5) + 1

// Field assignments as sequential statements
self.x := 5
self.y := self.x + 1
self.y

Limitation: Field assignments (self.x :=) in stored closures are a compile error — they require control-flow context for state threading. Local variable mutations in stored closures work fine (ADR 0041 Tier 2).

// ❌ ERROR: field assignment inside stored closure
nestedBlock := [:m | self.x := m]

// ✅ Field mutation in control flow blocks
true ifTrue: [self.x := 5]

// ✅ Local variable mutation in stored closure (Tier 2)
count := 0
myBlock := [count := count + 1]
10 timesRepeat: myBlock   // count => 10

Blocks (Closures)

// Block with no arguments
[self doSomething]

// Block with arguments
[:x :y | x + y]

// Block with local variables
[:x | temp := x * 2. temp + 1]

Non-local returns: ^ inside a block returns from the enclosing method, not just from the block. This is the standard Smalltalk non-local return semantics and enables clean early-exit patterns:

Object subclass: Finder
  firstPositive: items =>
    items do: [:x | x > 0 ifTrue: [^x]]
    nil   // returned only if no positive element found

Object subclass: Validator
  validate: x =>
    x isNil ifTrue: [^"missing"]
    x isEmpty ifTrue: [^"empty"]
    "ok"

^ at the top level of a method body is an early return (the method exits immediately). ^ inside a block argument causes the method to exit with that value.

Abstract and Stub Methods

Empty method bodies are a compile-time error. Use one of these two explicit forms instead:

// Abstract interface contract — must be overridden by subclasses
area => self subclassResponsibility

// Work-in-progress stub — not yet implemented
processPayment => self notImplemented
MethodPurposeError message
subclassResponsibilityAbstract method; subclass must override"This method is abstract and must be overridden by a subclass"
notImplementedWork-in-progress stub"Method not yet implemented"

Both methods raise a runtime error with a clear message. The distinction is intent: subclassResponsibility signals an interface contract, while notImplemented marks incomplete work.

Class-Side Methods (ADR 0048)

Methods prefixed with class belong to the class itself, not to instances. They are called on the class name directly.

Object subclass: MathUtils
  class factorial: n =>
    n <= 1
      ifTrue: [1]
      ifFalse: [n * (self factorial: n - 1)]

  class fibonacci: n =>
    n <= 1
      ifTrue: [n]
      ifFalse: [(self fibonacci: n - 1) + (self fibonacci: n - 2)]

MathUtils factorial: 10    // => 3628800
MathUtils fibonacci: 10    // => 55

Common uses:

classState: declares mutable class-level state — shared across all instances and accessible from class methods. Used for singletons:

sealed Object subclass: MyRegistry
  classState: current = nil

  class current => self.current
  class current: instance => self.current := instance

classState: is distinct from state: (per-instance actor state) and field: (per-instance immutable data). It stores values at the class level, analogous to Smalltalk class variables.

Programmatic Class Creation (ClassBuilder) (ADR 0038 / ADR 0084)

Object subclass: Counter … is the grammar form, but a class can also be built programmatically by cascading messages to a ClassBuilder and ending with register. This is a first-class user API (the same protocol the compiler emits for the grammar form): register returns the new, dispatchable class object.

// Build a class in one cascade. register returns the canonical class object.
account := Object classBuilder
  name: #Account;
  superclass: Object;
  classVars: #{ #opened => 0 };
  fields: #{ #balance => 0 };
  methods: #{ #balance => [:inst | inst fieldAt: #balance] };
  classMethods: #{ #open => [:self | self.opened := self.opened + 1. self.opened] };
  register

account new balance        // => 0
Account open               // => 1  (also reachable by name — the name IS the class)
Account open               // => 2  (class-variable state threads correctly)

Each block value is compiler-lowered into the same dispatchable fun a file-defined method produces, so class-variable mutations thread, and super / self resolve — it is not a naive runtime closure. Class methods built this way are callable and reflectable (ADR 0084).

Incremental piece API. A class can be assembled one piece at a time before register — the "browser" use case. The add* setters write the same maps the bulk setters do, and removeMethod: / removeClassMethod: drop a piece:

Object classBuilder
  name: #Tally;
  superclass: Object;
  addClassState: #total default: 10;
  addField: #count default: 0;
  addMethod: #answer body: [:inst | 42];
  addClassMethod: #tally body: [:self | self.total := self.total + 5. self.total];
  register

Tally new answer           // => 42
Tally tally                // => 15

Instantiation. A purely programmatic (module-less) builder class instantiates with new / new:, exactly like a compiled value type:

point := Object classBuilder name: #P; superclass: Object; fields: #{ #x => 0, #y => 0 }; register
point new: #{ #x => 3, #y => 4 }     // => a P  (fields seeded from the map)

Metadata parity. Optional setters bring a builder-defined class to :help parity with a file-defined one — methodSignatures: / classMethodSignatures:, methodDocs: / classMethodDocs:, methodReturnTypes: / classMethodReturnTypes:, classDoc:, meta:, and isConstructible:. The class-side method bodies are also auto-indexed for SystemNavigation source-text queries.

Class-side live edit. A registered class's class method can be live-edited with the same >> patcher used for instance methods (see Live Patching and Extension Methods below), e.g. Counter class >> reset => self.opened := 0. The class-side dispatch path picks up the new class method immediately (ADR 0084).

The grammar form (Object subclass: …) remains the idiomatic way to define a class in source. The programmatic builder is for tooling, REPL exploration, and metaprogramming where the class shape is computed rather than written out.

Doc Comments (///)

Triple-slash comments (///) are structured documentation parsed into the AST and queryable at runtime via Beamtalk help:. They support Markdown formatting and a ## Examples convention with fenced code blocks.

/// Counter — A simple incrementing actor.
///
/// Demonstrates actor state and message passing.
///
/// ## Examples
/// ```beamtalk
/// c := Counter spawn
/// c increment   // => 1
/// ```
Actor subclass: Counter
  state: value = 0

  /// Increment the counter by 1 and return the new value.
  ///
  /// ## Examples
  /// ```beamtalk
  /// Counter spawn increment   // => 1
  /// ```
  increment => self.value := self.value + 1

Query documentation at runtime:

Beamtalk help: Counter
// => "== Counter < Actor ==\n  increment\n  ..."

Beamtalk help: Counter selector: #increment
// => "Counter >> increment\n  Increment the counter by 1..."

Doc comments flow from source → AST → compiled BEAM module → runtime. They are not stripped at compilation. The ## Examples blocks are the source for Beamtalk help: output and can be verified by the test framework.

Erlang FFI

Beamtalk provides direct access to all Erlang modules via the Erlang gateway object (ADR 0028). Send a unary message with the module name to get a proxy, then send messages as normal:

// Call any Erlang module function
Erlang lists reverse: #(3, 2, 1)      // => [1, 2, 3]
Erlang erlang node                     // => current node atom
Erlang maps merge: #{#a => 1} with: #{#b => 2}

// Store a module proxy for repeated use
proxy := Erlang crypto
proxy strong_rand_bytes: 16            // => random binary

The (Erlang module) pattern is used throughout the stdlib to wrap Erlang functions as Beamtalk class methods:

// How File.readAll: is implemented — a thin wrapper.
// The `{ok, _} | {error, _}` tuples beamtalk_file returns become a Result
// at the FFI boundary (see "Result conversion" below), so the return type
// is Result(String, Error), not a bare String.
Object subclass: File
  class readAll: path :: String -> Result(String, Error) =>
    (Erlang beamtalk_file) readAll: path

A class that delegates wholesale to one Erlang module can declare the module on subclass: and replace each body with => self delegate instead of hand-writing the FFI call — see native: for stateless Objects. Stream (native: beamtalk_stream) is the worked example.

Keyword mapping: The Erlang function name is taken from the first keyword (with its colon removed); the remaining keyword values follow as positional arguments. Erlang maps merge: a with: b calls maps:merge(A, B) (not maps:merge_with — only the first keyword names the function). Unary selectors map directly: Erlang erlang node calls erlang:node().

Result conversion (ADR 0076): Erlang functions that return {ok, Value} or {error, Reason} tuples are automatically converted to Result objects at the FFI boundary. This means FFI calls use the same error-handling idiom as native Beamtalk code:

// FFI calls returning ok/error tuples become Result objects
result := Erlang file read_file: "/tmp/hello.txt"
result              // => Result ok: "Hello, world!\n"
result value        // => "Hello, world!\n"

// Use Result combinators directly on FFI returns
result map: [:content | content size]
// => Result ok: 14

// Error path
result := Erlang file read_file: "/nonexistent"
result              // => Result error: enoent
result isError      // => true

// Chain FFI calls with andThen:
(Erlang file read_file: "/tmp/config.json")
  andThen: [:content | Erlang json decode: content]
  mapError: [:e | "Config load failed: " ++ e message]

// Bare ok atoms (e.g. file:write_file/2) become Result ok: nil
Erlang file write_file: "/tmp/out.txt" with: "data"
// => Result ok: nil

Conversion rules:

Atom-enum precedence (type inference): When the spec reader maps a pure-atom union type containing atoms beyond ok/error, it infers a singleton union rather than applying ADR-0076 Result recognition. Only unions whose atoms are a subset of {ok, error} infer as Result. For example, a spec of text | json | xml infers #text | #json | #xml, while ok | error infers Result.

⚠️ Type/runtime mismatch for enums containing ok/error. Type inference and runtime coercion use different rules. A spec like ok | error | pending infers the singleton union #ok | #error | #pending at the type level, but runtime ok/error coercion (coerce_result/1) is unconditional and spec-unaware: at runtime a bare ok still arrives as Result ok: nil and a bare error as Result error: nil. So code that matches the inferred #ok/#error singletons passes the type checker but never matches at runtime for those branches. When a spec uses ok or error as semantic enum members, handle those call-site branches as Result, not as the inferred singleton (only the non-ok/error members — here #pending — arrive as singletons).

undefined stays #undefined: Erlang undefined in an FFI return type spec maps to the singleton #undefined (a Symbol), not Nil. The FFI boundary does not coerce undefinednil — callsites that want nil semantics must convert explicitly.

Scope: Conversion applies only to FFI calls via Erlang module method: args. Messages received from Erlang processes via receive or actor mailboxes remain raw Tuples. Use Result fromTuple: to explicitly convert those:

// Converting a Tuple received from a message
tuple := receiveMessage  // raw {ok, data} Tuple from Erlang process
result := Result fromTuple: tuple
result value  // => data

Migration from Tuple-based FFI code:

// Before (Tuple-based, pre-ADR-0076 — FFI returned raw Tuples):
result := Erlang file read_file: path
result isOk ifTrue: [result unwrap] ifFalse: ["error"]  // Tuple methods

// After (Result-based):
result := Erlang file read_file: path
result ifOk: [:content | content] ifError: [:e | "error"]
// Or simply:
result value  // raises on error

Error handling — wrap by default (ADR 0101): every FFI call is safe by default. The boundary treats the two channels a BEAM function can use independently, and they are mutually exclusive per call (a function either returns or raises):

Outcome of the callChannelBeamtalk resultHandle with
returns {ok, V} / {error, R}return valuea Result(V, R) valueisOk / value / andThen:
raises error:Reason (badarg, {badkey,_}, function_clause, …)exceptiona raised #beamtalk_error{}on:do: / ensure:, or bubbles to the REPL
raises exit:Reasonexceptionpropagates unwrapped (an enclosing on:do: catches it as erlang_exit)supervision / let-it-crash
raises throw:Termexceptionpasses through unchanged^ / Beamtalk exceptions

The guarantee: a user never sees a raw Erlang error tuple. A badarg becomes a structured #beamtalk_error{} (kind type_error) with a hint, not a bare {badarg, [...]} — most visible at the REPL, where the abstraction is most exposed. exit:/throw: are deliberately not wrapped, so (Erlang erlang) exit: #killed still terminates the process with reason killed.

Same function, both channels. Because the channels are orthogonal, one function can use both: File readAll: returns a Result error: for the modeled missing-file case, but raises a #beamtalk_error{} if called with a non-String path. The rule is Result for expected/recoverable outcomes the API models, exceptions for misuse/faults. Wrapping changes no return type — a raised #beamtalk_error{} is invisible to the type system, so Result stays the only type-visible error channel.

A raised FFI error is catchable as BEAMError, ExitError, or ThrowError. The handler block parameter is typed from the exception class argument, so e in on: BEAMError do: [:e | ...] is inferred as BEAMError (not Dynamic):

[Erlang erlang error: #badarg] on: BEAMError do: [:e | e message]
// => "badarg"
// e is typed as BEAMError — `e message` type-checks without warnings

A native: method's wrapped error carries the Beamtalk Class/selector (e.g. Type error in 'take:' on Stream), whereas inline (Erlang …) FFI carries the Erlang-facing ErlangModule context (e.g. Type error in 'atom_to_list' on ErlangModule) — a documented limitation, since the proxy knows the MFA but not the calling class.

Type Specs for Native Modules

When writing native Erlang modules that implement Beamtalk class methods, prefer the exported Dialyzer-facing types for Result and wrapped error values instead of bare map(). These types are primarily useful for Erlang -spec annotations and Dialyzer; Beamtalk's spec importer may resolve them to Dictionary in generated Beamtalk signatures rather than Result(...) / Exception.

TypeDescription
beamtalk_result:t()Any Result (unparameterized)
beamtalk_result:t(OkType, ErrType)Result with known ok/error types
beamtalk_error:t()Exception tagged map (the wrapped error visible to Beamtalk)

Example specs:

-module(beamtalk_mylib).
-include_lib("beamtalk_runtime/include/beamtalk.hrl").

%% Unparameterized — any Result
-spec 'readConfig:'(binary()) -> beamtalk_result:t().
'readConfig:'(Path) ->
    case file:read_file(Path) of
        {ok, Bin} ->
            beamtalk_result:from_tagged_tuple({ok, Bin});
        {error, Reason} ->
            Err0 = beamtalk_error:new(io_error, 'MyLib', 'readConfig:'),
            Err1 = beamtalk_error:with_details(Err0, #{path => Path, reason => Reason}),
            beamtalk_result:from_tagged_tuple({error, Err1})
    end.

%% Parameterized — precise ok/error types for Dialyzer
-spec 'parse:'(binary()) -> beamtalk_result:t(map(), beamtalk_error:t()).
'parse:'(Data) -> ...

These types are defined in:

FFI Collection Element Types

The spec importer carries collection element types from Erlang -spec attributes (ADR 0075 amendment), so iterating an FFI-typed list binds block parameters to the element type instead of Dynamic:

Erlang specImported Beamtalk type
[integer()]List(Integer)
[{atom(), pos_integer(), atom()}]List(Tuple(Symbol, Integer, Symbol))
{atom(), binary()}Tuple(Symbol, String | Binary)
[term()] / tuple()List / Tuple (uninformative elements collapse to the bare type)
// allSendsIn/1 is specced [{atom(), pos_integer(), atom()}]:
sends := (Erlang beamtalk_interface) allSendsIn: source
sends do: [:s |
  // s is Tuple(Symbol, Integer, Symbol) — no annotation needed
  selector := s at: 1   // inferred Symbol
  arity := s at: 2       // inferred Integer
]

Literal-index tuple access: Sending at: with a literal integer index to a value of a known Tuple(T1, …, Tn) type infers the element type at that 1-based index:

pair := someTupleTyped     // Tuple(Symbol, Integer)
pair at: 1                  // inferred Symbol
pair at: 2                  // inferred Integer

A non-literal index (pair at: i) or an out-of-range literal falls back to Dynamic — no false-positive warning. An untyped tuple() spec (unknown arity) stays bare Tuple, so at: on it remains Dynamic as before.

Loading Code into the Workspace

Beamtalk source files are loaded into the live workspace via :load or the Workspace singleton. Loaded classes are immediately available — existing actors pick up new code on next dispatch.

// Via REPL shortcut
:load examples/counter.bt
// => Loaded: Counter

// Via native message send (works from compiled code and MCP)
Workspace load: "examples/counter.bt"

// Load an entire directory (compiles all .bt files in dependency order)
Workspace load: "src/"

// Reload a specific class from its source file
Counter reload
// => Counter  (recompiled and hot-swapped)

// Or via REPL shortcut
:reload Counter

See Workspace and Reflection API for the full Workspace singleton interface.


Gradual Typing (ADR 0025)

Beamtalk supports optional type annotations and typed classes. Type checks are compile-time warnings (not hard errors), so interactive workflows remain fast.

Typed Class Syntax

typed Actor subclass: TypedAccount
  state: balance :: Integer = 0
  state: owner :: String = ""

  deposit: amount :: Integer -> Integer =>
    self.balance := self.balance + amount
    self.balance

  balance -> Integer => self.balance

Annotation Forms

// Unary return annotation
getBalance -> Integer => self.balance

// Keyword parameter annotation
deposit: amount :: Integer => self.balance := self.balance + amount

// Binary parameter + return annotation
+ other :: Number -> Number => other

// Multiple keyword parameters with annotations
sum: left :: Integer with: right :: Integer -> Integer => left + right

// Union type annotations parse (full checking is phased in)
maybeName: flag :: Boolean -> Integer | String =>
  ^flag ifTrue: [1] ifFalse: ["none"]

// Self return type — resolves to the static receiver class at call sites
// (only valid in return position, not parameters)
collect: block :: Block(E, R) -> Self =>
  self species withAll: (self inject: #() into: [:acc :each |
    acc addFirst: (block value: each)
  ]) reversed

// At call sites, Self resolves to the static receiver type:
// (List new collect: [:each | each])  — inferred return type: List
// (Set new collect: [:each | each])   — inferred return type: Set

// Self also substitutes inside nested generic return types
class named: name :: Symbol -> Result(Self, Error) => ...
// (Counter named: #c) — inferred return type: Result(Counter, Error)

// On parameterised receivers, Self preserves type arguments:
// (Box(Integer) new) someMethod — where someMethod -> Result(Self, Error)
//   inferred return type: Result(Box(Integer), Error)

// Self class — the receiver's metatype, i.e. the class object (ADR 0083).
class -> Self class => @primitive "class"
// (Counter new) class — inferred type: the metatype-of-Counter (rendered
// `Counter class`). Sends are routed class-side: `(Counter new) class new`
// infers a Counter instance, and `(Counter new) class instanceCount`
// type-checks against Counter's class-side methods.

// <ClassName> class — a named class metatype annotation.
// Valid in any type position (fields, parameters, return types, locals).
// Resolves to the metatype-of-<ClassName> (a tracked type, ADR 0083), so
// class-side methods on that class resolve without false DNU warnings, and a
// class value flows with type through variables, collections, and FFI returns.
// The metatype is name-only — the class object is unparameterized (ADR 0068),
// so there is no `List(E) class`, only `List class`. Type-erased at runtime.
field: actorClass :: Actor class | nil = nil
// After `actorClass isNil ifTrue: [^nil]`, actorClass is narrowed to
// `Actor class` — class-side methods like `isSupervisor` type-check.

// Class literal inference: a bare class literal (`Counter`) infers as the
// metatype `Counter class`, so class values stored in variables, collections,
// or returned from FFI calls route class-side sends correctly without annotation.
klass := Counter            // inferred Counter class (the metatype)
klass new                   // inferred Counter (an instance)
klass instanceCount         // resolves class-side method

// Metatype subtyping: `C class <: Class <: Behaviour <: Object`, so a class
// value satisfies `:: Class` / `:: Behaviour` parameters and `List(Behaviour)`
// FFI returns. `new` / `basicNew` on a *concrete* class metatype infers an
// instance of that class; on an *abstract* class (e.g. `Collection`,
// `Behaviour`) it stays Dynamic — instantiating an abstract class is an error.

Current Semantics

Local Variable Type Annotations

Local variables can carry a type annotation using name :: Type := expr. The declared type overrides the inferred type of the right-hand side, which is useful at type-erasure boundaries (FFI returns, deserialization, untyped APIs). The annotation is erased at codegen — there is no runtime effect.

x :: Integer := 42
dict :: Dictionary := Binary deserialize: content
name :: String | nil := dictionary at: "name"
r :: Result(Integer, Error) := computeSomething

Supported type forms: simple (Integer), parametric (Result(T, E)), and union (String | nil).

Type checking: The compiler warns when the RHS type is unrelated to the declared type (e.g., x :: Integer := "hello"). Narrowing assertions — where the declared type is more specific than the inferred type — are accepted silently, since the annotation communicates that the runtime type is known to be more specific (BT-2015). Dynamic and Never RHS types are always accepted.

Dynamic Type Visibility (ADR 0077)

When the compiler cannot determine an expression's type, it infers Dynamic. Beamtalk makes Dynamic visible so you can see exactly where the compiler lacks type information and why.

Dynamic with Reasons

Each Dynamic type carries a reason explaining why the type could not be determined:

ReasonDescriptionWhat to fix
unannotated parameterParameter has no type annotationAdd :: Type to the parameter
unannotated returnMethod has no return type and body could not be inferredAdd -> Type return annotation
dynamic receiverReceiver is Dynamic, so message send result is DynamicFix the receiver's type first
ambiguous control flowControl flow produces incompatible typesAdd type annotations to branches
untyped FFIErlang FFI call with no spec or all-Dynamic specAdd -spec to the Erlang module
(none)Fallback — no specific reason availableShown as plain Dynamic

LSP Hover

When hovering over an expression in the editor, the LSP shows the inferred type including Dynamic with its reason:

Identifier: `handler` — Type: Dynamic (unannotated return)
Identifier: `result` — Type: Dynamic (dynamic receiver)
Identifier: `data` — Type: Dynamic (unannotated parameter)
Identifier: `count` — Type: Integer

When the reason is Unknown, the hover shows just Type: Dynamic. Previously, Dynamic expressions showed no type information at all — the type line was omitted entirely.

Typed Class Diagnostics

typed classes opt into thorough type checking. In addition to requiring parameter and return annotations on methods, typed classes produce warnings for:

Missing State Field Annotations

State fields without type annotations produce a warning:

typed Actor subclass: BankAccount
  state: balance = 0          // warning: Missing type annotation for state field
                               //          `balance` in typed class `BankAccount`
  state: owner :: String = ""  // OK — annotated

Dynamic Inference Warnings

When an expression in a typed class infers as Dynamic (for a root-cause reason like unannotated parameter or unannotated return), the compiler warns. Propagated reasons like dynamic receiver are not warned on separately since the root cause already has its own warning.

typed Actor subclass: BankAccount
  process: handler =>
    handler doWork    // warning: expression inferred as Dynamic in typed class
                      //          `BankAccount` (unannotated parameter)

Suppressing Type Warnings with @expect type

When Dynamic dispatch is intentional (e.g., a method that deliberately accepts any object), suppress the warning with @expect type on the preceding line:

typed Actor subclass: BankAccount
  process: handler =>
    @expect type
    handler doWork    // no warning — suppressed

@expect type suppresses all type-related warnings on the next expression, including type mismatches, does-not-understand hints, and Dynamic inference warnings. @expect all also works as a broader suppression.


Parametric Types — Generics (ADR 0068)

Beamtalk supports declaration-site parametric types (generics) with compile-time substitution. Type parameters use parenthesis syntaxResult(T, E) — keeping < reserved exclusively as a binary message (comparison operator). All generic type information is erased at runtime (zero cost).

Declaring a Generic Class

Classes declare type parameters in parentheses after the class name:

sealed Value subclass: Result(T, E)
  field: okValue :: T = nil
  field: errReason :: E = nil

  sealed unwrap -> T =>
    self.isOk ifTrue: [
      self.okValue
    ] ifFalse: [(Erlang beamtalk_result) unwrapError: self.errReason]

  sealed map: block :: Block(T, R) -> Result(R, E) =>
    self.isOk ifTrue: [Result ok: (block value: self.okValue)] ifFalse: [self]

  sealed andThen: block :: Block(T, Result(R, E)) -> Result(R, E) =>
    self.isOk ifTrue: [block value: self.okValue] ifFalse: [self]

Type parameters are bare uppercase identifiers (by convention single letters: T, E, K, V, R). They appear in:

Using Generic Types

When using a generic class as a type annotation, concrete types replace the parameters:

// Annotating a variable
result :: Result(String, IOError) := File read: "config.json"
result unwrap    // Type checker knows: -> String

// Annotating a method parameter
processResult: r :: Result(Integer, Error) -> Integer =>
  r unwrap + 1   // r unwrap is Integer, Integer has '+'

// Annotating state
Actor subclass: Cache(K, V)
  state: store :: Dictionary(K, V) = Dictionary new

Type Inference Through Generics

The type checker performs positional substitution: when it encounters Result(String, IOError), it maps T -> String, E -> IOError, and substitutes through all method signatures:

r :: Result(Integer, Error) := computeSomething
r unwrap          // Return type T -> Integer
r map: [:v | v asString]   // Block param T -> Integer, return Result(String, Error)
r error           // Return type E -> Error

When concrete type parameters are unknown, they fall back to Dynamic:

r := someMethod        // someMethod returns bare Result (no type params)
r unwrap               // -> Dynamic (T is unknown)
r unwrap + 1           // No warning — Dynamic bypasses checking

Constructor Type Inference

For named constructors (ok:, error:, new), the compiler infers type parameters from the argument types:

r := Result ok: 42                  // Inferred: Result(Integer, Dynamic)
r unwrap                            // -> Integer

r2 := Result error: #file_not_found // Inferred: Result(Dynamic, Symbol)
r2 error                            // -> Symbol

Generic Inheritance

When a generic class extends another, the type parameter mapping must be explicit:

// Array passes its E to Collection's E
Collection(E) subclass: Array(E)

// IntArray fixes E to Integer
Collection(Integer) subclass: IntArray

// SortedArray passes E through
Array(E) subclass: SortedArray(E)

Block Type Parameters

Block(...) is special-cased — the last type parameter is always the return type:

Design Constraints

Dialyzer Spec Generation

Generic annotations generate expanded Dialyzer specs with concrete types at the BEAM interop boundary:

processResult: r :: Result(Integer, Error) -> Integer => r unwrap + 1

Generates:

-spec processResult(#{
  '__class__' := 'Elixir.Result',
  'okValue' := integer(),
  'errReason' := any()
}) -> integer().

Unresolved type parameters map to any() in Dialyzer specs.

REPL Type Display

The REPL displays generic type information when available:

> :help Result >> unwrap
unwrap -> T

When the workspace knows the concrete type parameters, :help substitutes them:

> r := Result ok: 42
> r unwrap
=> 42
// Type info: Integer (inferred from Result(Integer, Dynamic))

Structural Protocols (ADR 0068)

Protocols define named message sets. A class conforms to a protocol if it responds to all required messages — no implements: declaration needed. This is Smalltalk's duck-typing philosophy made explicit.

Defining a Protocol

Protocol define: Printable
  /// Return a human-readable string representation.
  asString -> String
  /// Return a developer-oriented representation (for debugging/REPL).
  printString -> String

Protocol define: Comparable
  < other :: Self -> Boolean
  > other :: Self -> Boolean
  <= other :: Self -> Boolean
  >= other :: Self -> Boolean

Protocol define: Collection(E)
  /// The number of elements in this collection.
  size -> Integer

  /// Iterate over each element.
  do: block :: Block(E, Object)

  /// Transform each element, returning a new collection of the same kind.
  collect: block :: Block(E, Object) -> Self

  /// Return elements matching the predicate.
  select: block :: Block(E, Boolean) -> Self

Protocol bodies use class-body style — method signatures without => implementations. Doc comments are supported on each required method.

Using Protocols as Types

Protocol names are used in type annotations the same way as class names — the compiler resolves the name and determines whether to perform nominal (class) or structural (protocol) checking:

// Structural/protocol type — Printable guarantees asString
display: thing :: Printable =>
  Transcript show: thing asString

// Generic protocol type
printAll: items :: Collection(Object) =>
  items do: [:each | Transcript show: each asString]

Automatic Conformance

Conformance is structural and automatic — no implements: declaration needed:

// String has asString -> conforms to Printable
// Integer has asString -> conforms to Printable
display: "hello"           // String conforms to Printable
display: 42                // Integer conforms to Printable
display: Counter spawn     // Counter conforms to Printable (inherited from Object)

Classes that override doesNotUnderstand: conform to every protocol (they can respond to any message).

Protocol Composition

// Require multiple protocols
sort: items :: Collection(Object) & Comparable => ...

// Protocol extending another
Protocol define: Sortable
  extending: Comparable
  /// The key used for sort ordering.
  sortKey -> Object

Class Method Requirements (BT-1611)

Protocols can require class-side methods using the class prefix, the same syntax as class definitions:

Protocol define: Serializable
  asString -> String
  class fromString: aString :: String -> Self

A class conforms to Serializable only if it has both the instance method asString and the class method fromString:. This is useful for factory methods, singleton patterns, and other class-level contracts.

Type Parameter Bounds

Type parameters can be bounded by protocols:

// T must conform to Printable
Actor subclass: Logger(T :: Printable)
  log: item :: T =>
    Transcript show: item asString    // Guaranteed by Printable bound

Runtime Protocol Queries

> Integer conformsTo: #Printable
=> true

> Integer conformsTo: #NonExistentProtocol
=> false

> Integer protocols
=> [#Printable, #Comparable]

> Protocol requiredMethods: #Printable
=> [#asString, #printString]

> Protocol conformingClasses: #Printable
=> [Integer, Float, String, Boolean, Symbol, Array, ...]

conformsTo: returns false for unknown or non-protocol names — a class cannot conform to something that is not a registered protocol.

Diagnostic Philosophy

Protocol conformance issues are warnings, never errors:

SituationSeverity
Protocol conformance unverifiableWarning
Missing method for protocolWarning
Namespace collision (class + protocol same name)Error (structural)

Two-Protocol String Model (Debug / Display)

Beamtalk follows a two-string-protocol model (ADR 0094), mirroring Rust's Debug / Display split:

String demonstrates the Debug/Display split directly: "hi" printString"\"hi\"" (quoted, Debug) while "hi" displayStringhi (plain, Display).

Default printString forms by class kind

The a/an article prefix (the old a Point / ungrammatical a Integer default) is removed entirely. The default printString now takes one of four visually distinct forms, one per kind of thing:

Class kindDefault printStringExample
Value (immutable data)ClassName(field: value, ...) — class-headed, labelled fields, in sorted field orderPoint(x: 3, y: 4); no fields → Point()
Actor (live process)Actor(ClassName, pid) — kind-headed, positionalActor(Counter, 0.123.0)
Supervisor (supervising process)Supervisor(ClassName, pid) / DynamicSupervisor(ClassName, pid)Supervisor(WebApp, 0.200.0)
Object (plain reference)bare ClassName, or a class-defined formFileHandle, or e.g. #Pid<0.123.0> for raw primitives

Value forms carry field: labels while process forms are positional; the process heads (Actor / Supervisor / DynamicSupervisor) are reserved kind words no user Value class may shadow, so the two shapes are unambiguous. Raw platform primitives (Pid, Port, Reference, Tuple) keep their Erlang-native #Pid<…> / #Port<…> rendering — they are Erlang terms.

Nested Value fields expand recursively (so Line(from: Point(x: 0, y: 0), to: Point(x: 3, y: 4)) shows in full), each rendered via its own printString (Debug form — strings stay quoted), bounded by depth (default 5), width, and total-length caps with a cycle guard; truncated positions render as ....

Printable Protocol and Display Methods

The Printable protocol is the standard contract for objects that can represent themselves as strings. It requires two methods:

Most stdlib classes conform automatically because Object provides a default printString (the bare class name, or the structural ClassName(field: value, ...) form for Value subclasses) and most subclasses implement asString. Custom classes only need to implement these two methods to conform:

Value subclass: Point
  field: x = 0
  field: y = 0

  // Human-readable
  asString -> String => "({self.x}, {self.y})"

  // Developer-readable (REPL display)
  printString -> String => "Point({self.x}, {self.y})"

displayString is not part of Printable (deferred per ADR 0094 §5), and inspect is not part of any protocol — so the two-protocol changes leave protocol conformance unaffected.

The related display methods on Object are:

MethodBehaviour
asStringHuman-readable string conversion (override per class)
printStringDebug representation — self-describing, structural; the REPL default and what nested rendering uses
displayStringDisplay representation — the string-interpolation {...} hook; defaults to printString, override for a natural human form
inspectOpens an Inspector cursor on the receiver (Inspector on: self) — a navigable, drillable view (ADR 0095). For the structural Debug string, use printString.
show: valueWrite value to Transcript (nil-safe, returns self)
showCr: valueWrite value to Transcript followed by newline (nil-safe, returns self)

show: and showCr: are convenience methods on Object that delegate to TranscriptStream. They are nil-safe — when no transcript is active (e.g. batch compilation), they silently do nothing and return self, making them safe for cascaded chains:

// Cascaded output
Transcript show: "Hello"; cr; show: "World"

// show:/showCr: on any object — nil-safe
42 show: "value: "
42 showCr: "hello world"

TranscriptStream >> show: accepts any Printable value, so custom classes that conform to Printable work directly with Transcript show: without manual asString conversion.

Navigable Inspector (ADR 0095)

printString renders an object to one string. The Inspector lets you navigate into it — drill through fields, render across surfaces (REPL text tree, MCP/browser wire form), and re-snapshot live actor state. anObject inspect is the shorthand for Inspector on: anObject; it returns a cursor, not a string.

i := (Point x: 3 y: 4) inspect      // an Inspector cursor (#value kind)
i fields                            // the drillable InspectorField records (x, y)
i at: #x                            // Result(Inspector) — a child cursor on the value 3
(i at: #x) unwrap subject           // => 3
i printString                       // an indented text tree (Inspector(Point) + fields)

One polymorphic class, four kinds. A single Inspector carries a kind tag rather than a subclass per kind — classification lives in the runtime shim:

kindSubjectFields are…
#valuean immutable Value (or scalar)its slots, in ADR 0094 sort order (#slot)
#actora live actora lazy, timeout-guarded sys:get_state snapshot of its state
#collectionList/Array/Set/Dictionary/Baga window (page size 50) of #element / #association fields
#foreigna non-Beamtalk OTP processbest-effort process_info + a guarded state snapshot (#processInfo)

A wedged, dead, or non-sys actor degrades to a single #status: #unavailable field — it never crashes.

Navigation is immutable. Every navigation message returns a new cursor, so a UI can hold several at once:

MessageReturns
fieldsList(InspectorField) — the current window of drillable fields
at: keyResult(Inspector) — a child cursor on that field's value (#no_such_field on a miss)
parent / rootthe parent cursor / the top of the drill path (nil parent at the root)
paththe breadcrumb of drilled keys from the root
refresha fresh cursor on a newly-captured snapshot (the original is unchanged)
sizethe cheap full element count (for #collection), else the field count
page: na new cursor on the n-th window (1-based) of a large collection
printString / printStringExpanded: depththe indented text tree (depth 1 = immediate fields)
asDictionaries / asDictionarythe typed cross-surface wire form (one dict per field / the cursor envelope)

Each InspectorField is an immutable Value record with name (the navigation key), label, value, kind, and drillable (isLeaf is its negation).

evaluate: is values-only. On a #value cursor, i evaluate: "self x + self y" compiles and runs the expression with self bound to the inspected value, returning a Result — never raising. On an #actor cursor it returns Result error: with kind #actor_eval_unsupported (actor evaluate-in-context is a deferred §7 follow-up). Live updates are poll-only: re-issue refresh.

i := (Point x: 3 y: 4) inspect
(i evaluate: "self x * 10") unwrap        // => 30
(i evaluate: "self nonesuch") isError     // => true  (a Result error:, not a crash)

Deferred §7 seams (not in v1): actor evaluate-in-context (live routing), per-class inspectorFields custom views, sealedFromInspection, and push live updates (poll-only for now).


Union Types and Narrowing (ADR 0068)

Union Types

Union types express that a value may be one of several types:

// All members must respond to the message
x :: Integer | String := getValue
x asString             // Both Integer and String have asString
x size                 // Warning: Integer does not respond to 'size'
x + 1                  // Warning: String does not respond to '+'

The nullable pattern (String | nil) is the most common union — Beamtalk's Option/Maybe type:

name :: String | nil := dictionary at: "name"
name size              // Warning: Nil does not respond to 'size'

Similarly, false in type position resolves to False — used for Erlang FFI patterns:

entry :: Tuple | false := ErlangLists keyfind: key

Singleton members. Singleton symbol types (#foo, a subtype of Symbol) may appear in any type position — including unions — to express a closed set of atom values:

// a parameter that is an Integer or the sentinel #infinity
withTimeout: ms :: Integer | #infinity => ...

// a closed enum of singletons
restart: policy :: #temporary | #transient | #permanent => ...

A singleton receiver resolves methods against Symbol's protocol — #infinity asString infers String, and an unknown selector produces a DNU hint naming the singleton (e.g. #infinity does not understand 'frobnicate'). Binary sends (=:=, =) are excluded from this redirect so statically-decidable comparison hints still fire.

Discriminate a singleton union with =:= (identity) and the branches narrow — see Control Flow Narrowing.

Difference and Intersection Types (\ / &)

Beyond | (union), type annotations support two more set-theoretic operators (ADR 0102):

// "any Symbol except #north" — a co-finite atom set
tag :: Symbol \ #north := #south

// chained difference — left-associative, same as subtracting a union:
// equivalent to "any Symbol except {#north, #south}"
heading :: Symbol \ #north \ #south := #east

// union binds looser than difference: `Integer | Symbol \ #foo` is
// `Integer | (Symbol \ #foo)`, not `(Integer | Symbol) \ #foo`
withSentinel: ms :: Integer | Symbol \ #infinity => ...

// intersection: class ∩ protocol (ADR 0068's operator, now general)
describe: value :: Integer & Printable -> String => value asString

// chained intersection — left-associative, same tier as `\`
requires: value :: A & B & C => ...

Precedence (lowest-binding to highest), all only inside a type annotation — & and \ remain ordinary binary message selectors in value position, unaffected:

OperatorMeaningBinding
|unionlowest
&intersectionmiddle
\differencemiddle (left-assoc, same tier as &)
(atomic type)class name / singleton / generichighest

So Integer | Symbol \ #foo parses as Integer | (Symbol \ #foo)\ binds tighter than |. Within one operator, chains are left-associative: Symbol \ #a \ #b parses as (Symbol \ #a) \ #b, and A & B & C parses as (A & B) & C.

Grouping parentheses. (...) is a grouping operator inside a type annotation, just as in value expressions (in addition to its generic-argument use, Result(T, E)). A group wraps any annotation — unions, &/\ chains, generics — and groups nest. Grouping is purely syntactic: the parenthesised annotation is the same annotation, so (Integer) means Integer, and (A | B) | C is the same flat three-member union as A | B | C.

// subtract a whole union in one step — equivalent to the chain
// `Symbol \ #a \ #b`, which the algebra normalises to the same result
tag :: Symbol \ (#a | #b) := #c

// a grouped difference as a union member (parens redundant here — `\`
// already binds tighter than `|` — but allowed)
withSentinel: ms :: Integer | (Symbol \ #infinity) => ...

Grouping is also how you mix & and \ in one annotation: mixing them in the same chain without parentheses is a deliberate parse error, not a left-associative fallback — A & B \ #c is rejected with "Cannot mix & and \ in a type annotation without parentheses; parenthesise to disambiguate", because (A & B) \ #c and A & (B \ #c) differ and neither reading is obviously the intended one. Parenthesise the reading you mean:

// intersect first, then subtract
narrow -> (A & B) \ #c => ...

// subtract first, then intersect
narrow -> A & (B \ #c) => ...

The lexer also greedily merges \\ (two backslashes) into the Smalltalk modulo selector, so a doubled backslash in type position — the natural typo for \ — gets a targeted diagnostic ("did you mean \?") instead of a confusing generic parse error.

Difference types show up in Control Flow Narrowing too: the false branch of a singleton equality test (x =:= #foo) narrows x to Symbol \ #foo, and hover displays that co-finite type directly.

Control Flow Narrowing

When the type checker recognises a type-testing pattern followed by ifTrue: / ifFalse:, it narrows the variable's type inside the block scope:

// class identity check — narrows to exact class
process: x :: Object =>
  x class =:= Integer ifTrue: [
    x + 1          // x is Integer here — has '+'
  ]
  x + 1            // x is Object here — no narrowing outside the block

// kind check — narrows to class including subclasses
process: x :: Object =>
  x isKindOf: Number ifTrue: [
    x abs           // x is Number here
  ]

// early return narrows the rest of the method
validate: x :: Object =>
  x isNil ifTrue: [^nil]
  x doSomething    // x is non-nil for the remainder

Supported narrowing patterns:

PatternNarrows toScope
x class =:= Foo ifTrue: [...]x is Foo in true blockTrue block only
x isKindOf: Foo ifTrue: [...]x is Foo in true blockTrue block only
x isNil ifTrue: [^...]x is non-nil after the statementRest of method
x isNil ifTrue: [self error: "..."]x is non-nil after the statementRest of method
x isNil ifFalse: [...]x is non-nil in false blockFalse block
x isNil ifTrue: [^...] ifFalse: [...]x is non-nil in false blockFalse block
x ifNotNil: [:v | ...]v is non-nil in blockBlock only
x ifNil: [...] ifNotNil: [:v | ...]v is non-nil in notNil blockNotNil block

The diverging-guard pattern (isNil ifTrue: [self error: "..."]) recognises any block whose body infers as Never — including calls to error:, notImplemented, or any -> Never method — not just non-local returns (^). Narrowing also works on self.field reads: inside self.field isNil ifFalse: [...], the field narrows to non-nil within the block.

Conditional Return Type Inference

ifTrue:ifFalse: on Boolean (and True/False) is declared as Block(R), Block(R) -> R — the type checker unifies both arms to a common return type. The result of a conditional expression is now statically typed rather than Dynamic:

x := condition ifTrue: [42] ifFalse: [0]
// x is inferred as Integer (not Dynamic)

result isOk ifTrue: [result unwrap] ifFalse: [default]
// inferred as the common type of both arms

ifNil:ifNotNil: (and ifNotNil:ifNil:) on nullable receivers also infers a branch-union return type — typeof(nilBranch) | typeof(notNilBranch). A branch containing a non-local return (^) contributes Never, leaving only the surviving branch's type:

name :: String | nil := dictionary at: "name"
result := name ifNil: ["unknown"] ifNotNil: [:n | n size]
// result is inferred as String | Integer

value := name ifNil: [^nil] ifNotNil: [:n | n]
// value is inferred as String (nil branch contributes Never, skipped)

Union + Narrowing Compose

name :: String | nil := dictionary at: "name"
name isNil ifTrue: [^"unknown"]
name size              // name is narrowed to String — nil eliminated by early return

Control Flow and Mutations

Beamtalk supports Smalltalk-style control flow via messages to booleans and blocks, with full mutation support via a universal state-threading protocol (ADR 0041).

How It Works

The compiler uses a two-tier optimization for block mutations:

Local variable mutations work in all blocks — including stored closures and blocks passed to user-defined higher-order methods. Field mutations (self.x :=) require control-flow context and are a compile error in stored closures.

Control Flow Constructs

These message sends are Tier 1 optimized — the compiler generates inlined tail-recursive loops with zero overhead:

ConstructExampleMutations Allowed
whileTrue: / whileFalse:[count < 10] whileTrue: [count := count + 1]
timesRepeat:5 timesRepeat: [sum := sum + n]
to:do:1 to: 10 do: [:n | total := total + n]
do:, collect:, select:, reject:items do: [:x | sum := sum + x]
inject:into:items inject: 0 into: [:acc :x | acc + x]

Local Variable Mutations

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

// Multiple variables
sum := 0
product := 1
i := 1
[i <= 5] whileTrue: [
    sum := sum + i
    product := product * i
    i := i + 1
]
// sum = 15, product = 120, i = 6

// Collection iteration
numbers := #(1, 2, 3, 4, 5)
total := 0
numbers do: [:n | total := total + n]
// total = 15

// With index
result := 0
1 to: 10 do: [:n | result := result + n]
// result = 55 (sum of 1..10)

Field Mutations

Mutations to actor state (self.field) work the same way:

Actor subclass: Counter
  state: value = 0
  state: count = 0

  // Field mutation in control flow
  increment =>
    [self.value < 10] whileTrue: [
      self.value := self.value + 1
    ]
    self.value

  // Multiple fields
  incrementBoth =>
    [self.value < 10] whileTrue: [
      self.value := self.value + 1
      self.count := self.count + 1
    ]

Mixed Mutations

Local variables and fields can be mutated together:

processItems =>
    total := 0
    self.processed := 0
    
    self.items do: [:item |
        total := total + item
        self.processed := self.processed + 1
    ]
    
    ^total

What Works and What Doesn't

Local variable mutations work in all blocks — including stored closures and user-defined higher-order methods (ADR 0041 Tier 2):

// ✅ Local mutation in stored closure — works via Tier 2 protocol
count := 0
myBlock := [count := count + 1]
10 timesRepeat: myBlock
count  // => 10

// ✅ Local mutation in user-defined HOM — works via Tier 2 protocol
count := 0
items myCustomLoop: [:x | count := count + x]
count  // => sum of items

Field mutations (self.x :=) require control-flow context and are a compile error in stored closures:

// ❌ ERROR: Field assignment in stored closure
badBlock =>
    myBlock := [self.value := self.value + 1]
    // ERROR: Cannot assign to field 'value' inside a stored closure.

// ✅ CORRECT: Field mutation in control flow
increment =>
    10 timesRepeat: [self.value := self.value + 1]  // ✅ Works!

Why This Design?

Property✅ Benefit
UniversalLocal variable mutations work in all blocks — no whitelist
Smalltalk-likeNatural iteration patterns work, including user-defined HOMs
SafeField mutations in stored closures are caught at compile time
Good DXClear errors with fix suggestions
BEAM-idiomaticCompiles to tail recursion + state threading
PerformantStdlib hot paths are zero overhead (Tier 1); user HOMs ~65ns (Tier 2)

Error Messages

When you accidentally assign to a field inside a stored closure, the compiler provides guidance:

// This won't compile — stored closure can't mutate fields
myBlock := [:item | self.sum := self.sum + item]
items do: myBlock
Error: Cannot assign to field 'sum' inside a stored closure.

Field assignments require immediate execution context for state threading.

Fix: Use control flow directly, or extract to a method:
  // Instead of:
  myBlock := [:item | self.sum := self.sum + item].
  items do: myBlock.

  // Write:
  items do: [:item | self.sum := self.sum + item].

  // Or use a method:
  addToSum: item => self.sum := self.sum + item.
  items do: [:item | self addToSum: item].

Actor Message Passing

Beamtalk uses sync-by-default actor message passing (ADR 0043). The . message send operator uses gen_server:call, which blocks the caller until the actor processes the message and returns a value. This is the same natural synchronous feel as Smalltalk, while preserving full process isolation and fault tolerance.

Default: Sync with Direct Return

// Load the Counter actor
:load examples/counter.bt

// Spawn an actor — returns a reference
c := Counter spawn

// Messages to actors return values directly
c increment             // => 1
c increment             // => 2
c getValue              // => 2

REPL and Compiled Code

Actor sends behave identically in REPL and compiled code — both return values directly:

> c := Counter spawn
Actor(Counter, _)

> c increment
1

> c increment
2

> c getValue
2

Explicit Async Cast (!)

For fire-and-forget scenarios, use the ! (bang) operator, which uses gen_server:cast and returns nil immediately:

// Fire-and-forget — does not block, returns nil
c increment!

The ! is postfix — it terminates the send it applies to. A cast is only legal as a bare statement: using its (always-nil) value in an assignment, return, or argument is a parse error (cast_in_expression_error).

Use ! when you intentionally don't need the result and don't want to block.

Deadlock Prevention

Because . sends block the caller (gen_server:call), two actors calling each other creates a deadlock. The default timeout is 5000ms, after which a #timeout error is raised:

// DeadlockA calls DeadlockB, which calls DeadlockA — timeout after 5s
self should: [a callPeer] raise: #timeout

Design actor interactions to avoid circular synchronous calls. Use ! (cast) when an actor needs to notify another without expecting a response.

Custom Timeouts

The default . send timeout is 5000ms. For actors that may take longer (database queries, HTTP calls), use withTimeout: to create a timeout proxy:

// Wrap an actor with a custom timeout (milliseconds)
slowDb := db withTimeout: 30000
slowDb query: sql              // forwarded with 30s timeout
slowDb stop                    // stop the proxy when done

// Infinite timeout (use with care — blocks indefinitely)
infDb := db withTimeout: #infinity
infDb query: sql
infDb stop

withTimeout: returns a TimeoutProxy — a lightweight actor that forwards ordinary messages to the target via doesNotUnderstand:args: using the specified timeout. Lifecycle messages such as stop apply to the proxy itself, not the target. This is pure message passing with no special syntax or reserved keywords.

Lifecycle: The proxy is a separate actor process. Capture the reference and call stop when finished to avoid leaking processes.

Static Typing of Actor Protocols (ADR 0104)

An Actor subclass:'s public method set is its message protocol — the checker types actor sends exactly as ordinary method sends, with no parallel channel type and no new syntax. Four typing rules apply (all advisory per ADR 0100, and static-only — no runtime, codegen, or wire change):

  1. A sync send types as the method's return. c increment on a Counter whose increment declares (or infers) -> Integer types as Integer, and forwards its declared/inferred return to callers:

    c := Counter spawn
    c increment          // :: Integer — the method's declared/inferred return
    
  2. A bare cast (!) types as Nil. The fire-and-forget gen_server:cast has no synchronous reply, so a cast statement evaluates to Nil regardless of the target method's return type. (Using a cast's value in an assignment, return, or argument is already a parse error — see Explicit Async Cast above). One consequence: a method declaring a non-Nil return whose body ends in a bare cast now warns "body returns Nil" — usually a genuine bug where a mutator returns nothing:

    c increment!         // :: Nil — no reply is awaited
    
    // ⚠️ Method 'bump' declares return type Integer, but body returns Nil
    bump -> Integer => c increment!
    
  3. spawnWith: keys are checked against state: slots. The keys of a literal init-state map are validated against the actor's declared state: slots; an unknown key is a Warning (a provably-failing construction, not merely an unresolved selector) with a typo suggestion naming the nearest slot. When a slot is typed, the literal value's type is checked against it too:

    Counter spawnWith: #{#count => 0}     // :: Counter — key `count` is a declared slot
    Counter spawnWith: #{#cuont => 0}     // ⚠️ unknown state key `cuont` — did you mean `count`?
    

    Only a literal map is inspected; a spawnWith: argument flowing in through a variable is not key-checked. The rule fires only for Actor subclass: receivers.

  4. withTimeout: is transparent; cross-process DNU grades like a local send. withTimeout: returns a value typed as the wrapped actor (not the opaque TimeoutProxy), so forwarded calls resolve the wrapped class's real return types. A timeout raises rather than returning, so method return types are unchanged. An unknown selector on a statically-known actor gets the same knowledge-graded ADR 0100 diagnostic as a local send — the process boundary is invisible to the checker:

    (db withTimeout: 30000) query: sql    // resolves query:'s real return, not Dynamic
    
    logger := Logger spawn
    logger logg: "hi"                     // ⚠️ Logger does not understand 'logg:' — did you mean 'log:'?
    

See ADR 0104 for the full rationale, prior art, and the metaclass-aware constructor inference (ADR 0083) that types spawn / spawnWith:.

BEAM Mapping

BeamtalkBEAM
. send (sync)gen_server:call — blocks until reply
! send (async cast)gen_server:cast — returns immediately
Timeoutgen_server:call default 5000ms timeout
withTimeout:Proxy wrapping gen_server:call/3 with custom timeout
performLocally:withArguments:Direct in-process call bypassing gen_server

Caller-Process Class Method Dispatch

Class methods normally execute inside the class object's gen_server process. For long-running class methods (batch processing, report generation) that would block all other messages to the class, use performLocally:withArguments: to execute in the caller's process instead:

// Normal dispatch — runs in MyClass gen_server process
MyClass computeReport

// Local dispatch — runs in caller's process, doesn't block the class
MyClass performLocally: #computeReport withArguments: #()

// With arguments
MyClass performLocally: #add:to: withArguments: #(3, 7)

Limitations: Local dispatch calls the method directly on the target class module — it does not walk the superclass chain. Class variable mutations are discarded (the call runs outside the class gen_server's state). Use this only for stateless or read-only class methods.

Actor-to-Actor Coordination

Because . sends are synchronous, when an actor method calls another actor internally, the caller waits for the nested call to complete before continuing. The sync barrier pattern (explicit round-trip queries) is generally no longer needed:

// With sync-by-default: bus notify: calls receive: on each subscriber
// synchronously. When notify: returns, all subscribers have processed it.
bus notify: "hello".
col eventCount          // => 1 (already processed)

The sync barrier pattern is only needed when using ! (cast) sends internally:

// If bus uses `!` internally to forward to subscriber:
bus notify: "hello".    // bus sends subscriber ! receive: "hello" internally
col events.             // barrier: ensures col processed the cast message
col eventCount          // => 1 (now correctly reflects the event)

Server — OTP Interop (ADR 0065)

Server is an abstract subclass of Actor for BEAM-level OTP interop. The class hierarchy expresses the abstraction boundary:

Object
  └── Actor           # Beamtalk objects — messages, state, Timer
        └── Server    # BEAM processes — handleInfo:, raw OTP interop (abstract)

Defining a Server

Use Server subclass: when you need to receive raw Erlang messages (timer events, monitor DOWN tuples, system messages). All existing Actor methods continue to work — Server inherits everything from Actor.

Server subclass: PeriodicWorker
  state: count = 0

  initialize =>
    Erlang erlang send_after: 1000 dest: (self pid) msg: #tick

  handleInfo: msg =>
    msg match: [
      #tick -> [
        self.count := self.count + 1.
        Erlang erlang send_after: 1000 dest: (self pid) msg: #tick
      ];
      _ -> nil
    ]

  getValue => self.count

handleInfo: Semantics

Migration: Actor to Server

Promoting an Actor to a Server is a one-word change. All existing methods continue to work:

// Before
Actor subclass: MyThing
  // ...

// After — all existing methods still work, handleInfo: now available
Server subclass: MyThing
  handleInfo: msg => ...

Timer Lifecycle

Timer processes (Timer every:do: and Timer after:do:) are linked to the calling process via spawn_link. This means:

Actor subclass: Ticker
  state: count = 0

  initialize =>
    Timer every: 1000 do: [self tick!]   // async cast — MUST use ! not .

  tick => self.count := self.count + 1
  getValue => self.count

No state: for the timer reference, no terminate: cleanup needed — the link handles it.

BEAM Mapping

BeamtalkBEAM
Server subclass:gen_server with handle_info/2 dispatch
handleInfo: msghandle_info(Msg, State) callback
Actor subclass:gen_server with handle_info/2 ignore stub
Timer spawn_linkTimer process linked to calling process

Supervision Trees (ADR 0059)

Beamtalk provides declarative OTP supervision trees via Supervisor subclass: and DynamicSupervisor subclass:. This is the Beamtalk idiom for "let it crash" fault tolerance — define which actors should be restarted automatically, and how.

Static Supervisor

Subclass Supervisor and override class children to return a list of actor classes (or SupervisionSpec values for per-child configuration). The supervisor starts all children at startup using OTP one_for_one strategy by default.

Important: class children, class strategy, class maxRestarts, and class restartWindow are called during supervisor startup from the OTP init/1 callback — before the class gen_server is available. These methods must be pure (return literal values only). Do not send messages to self, call other class methods via dispatch, or read class variables from within these methods.

Supervisor subclass: WebApp
  class children => #(DatabasePool HTTPRouter MetricsCollector)

Start the supervisor with supervise. It registers under its class name so it can be found from anywhere. supervise and terminate: both return Result values (ADR 0080) — use unwrap at boot / in the REPL, or ifOk:ifError: / andThen: for recoverable flows:

// Boot-style: crash on failure (application boot, test setup, REPL exploration)
app := (WebApp supervise) unwrap
// => Supervisor(WebApp, _)

// Idempotent — second call also returns a successful Result wrapping the
// already-running supervisor, without restarting
(WebApp supervise) isOk
// => true

// Recoverable form — branch on the Result explicitly
(WebApp supervise)
  ifOk:    [:sup | sup count]
  ifError: [:e | Logger error: e message]

// Find the running instance by class name (no reference needed — returns the
// bare supervisor or nil, unchanged from pre-ADR-0080 semantics)
WebApp current
// => Supervisor(WebApp, _)

Inspect and manage children:

app count                                 // => 3  (number of running children)
app children                              // => ["DatabasePool","HTTPRouter","MetricsCollector"]  (child ids)
(app which: DatabasePool) unwrap           // => Actor(DatabasePool, _)  (running child instance)
(app terminate: HTTPRouter) unwrap        // gracefully stop a single child; Result(Nil, Error)
app stop                                  // stop the supervisor and all children (unchanged — Nil)

// After stop:
WebApp current                            // => nil

terminate: is idempotent — terminating a child that is already gone returns Result ok: nil, not an error (see idempotent-startup convention below).

Class-Side Configuration Defaults

Override these class methods in your subclass to customise restart behaviour:

MethodDefaultDescription
class strategy#oneForOneOTP restart strategy (#oneForOne, #oneForAll, #restForOne)
class maxRestarts10Max restarts before supervisor gives up
class restartWindow60Time window (seconds) for maxRestarts
Supervisor subclass: CriticalApp
  class children => #(Database Cache)
  class strategy => #oneForAll       // restart all if any child crashes
  class maxRestarts => 3             // give up after 3 crashes in 60 seconds

Actor Supervision Policy

Each actor class declares its OTP restart policy via class supervisionPolicy:

Actor subclass: DatabasePool
  class supervisionPolicy => #permanent   // always restart on crash

Actor subclass: RequestHandler
  class supervisionPolicy => #transient   // restart only on abnormal exit

Actor subclass: BackgroundJob
  class supervisionPolicy => #temporary   // never restart (default)

SupervisionSpec — Per-Child Overrides

Use SupervisionSpec when you need to override a child's restart policy, provide startup arguments, or set a custom shutdown timeout:

Supervisor subclass: WebApp
  class children =>
    #(DatabasePool
      HTTPRouter supervisionSpec withRestart: #transient
      (MetricsCollector supervisionSpec withId: #metrics withArgs: #{#port => 9090}))

Use withShutdown: to set a graceful shutdown timeout (in milliseconds) for children that need time to drain connections or flush state. The default is 5000ms for workers and infinity for nested supervisors.

HttpServer supervisionSpec withShutdown: 30000   // 30s graceful shutdown

Use withName: (and the withName:withRestart: / withName:withArgs: / withName:withRestart:withArgs: combinators) to have the supervisor register the child atomically under a Symbol name on each restart. Named specs emit a #spawnAs: / #spawnWith:as: startFn so re-registration happens in the same OTP call that starts the process — held Actor named: references survive restarts (see ADR 0079 and the Actor Named Registration section below). name and classMethod cannot be combined on the same spec.

Supervisor subclass: WebApp
  class children =>
    #((Counter supervisionSpec withName: #counter withRestart: #permanent))

Dynamic Supervisor

Subclass DynamicSupervisor to manage pools of actors started at runtime. Override class childClass to declare which actor class the pool manages.

DynamicSupervisor(Worker) subclass: WorkerPool
  class childClass => Worker
pool := (WorkerPool supervise) unwrap
// => DynamicSupervisor(WorkerPool, _)

// Start children dynamically — startChild returns Result(C, Error) where C is
// the DynamicSupervisor's child class parameter (Worker here)
w1 := pool startChild unwrap        // => Actor(Worker, _)
w2 := pool startChild unwrap        // => Actor(Worker, _)
pool count                          // => 2

// Recoverable variant — useful when a failing init should not abort the caller
pool startChild
  ifOk:    [:w | w process: 21]
  ifError: [:e | Logger warn: e message]

// Terminate a specific child — idempotent (Ok(nil) even if already gone)
(pool terminateChild: w1) unwrap    // => nil
pool count                          // => 1

// Stop the whole pool (unchanged — Nil, let-it-crash teardown)
pool stop
WorkerPool current                  // => nil

Nested Supervisors

Supervisors can be nested — include another supervisor class in children:

Supervisor subclass: AppRoot
  class children => #(DatabaseSupervisor WebTierSupervisor MetricsSupervisor)

Nested supervisor children are identified by isSupervisor => true and started via OTP start_link/0, ensuring they are correctly linked into the supervision tree. The outer supervisor shuts down inner supervisors (and all their children) gracefully on stop.

root := (AppRoot supervise) unwrap
root count                          // => 3
(root which: DatabaseSupervisor) unwrap  // => Supervisor(DatabaseSupervisor, _)

Lifecycle API returns Result (ADR 0080)

Supervisor lifecycle methods that can fail at a startup / registry boundary return a Result:

MethodSignatureError kinds
Supervisor class>>supervise-> Result(Self, Error)#supervisor_start_failed, #stale_handle
Supervisor>>terminate: aClass-> Result(Nil, Error)#terminate_failed, #stale_handle
Supervisor>>which: aClass-> Result(Object, Error)#stale_handle
DynamicSupervisor class>>supervise-> Result(Self, Error)#supervisor_start_failed, #stale_handle
DynamicSupervisor>>startChild / startChild: args-> Result(C, Error)#child_start_failed, #stale_handle
DynamicSupervisor>>terminateChild: child-> Result(Nil, Error)#terminate_failed, #stale_handle

stop, current, children, and count are unchanged — they are teardown / lookup / inspection operations over an already-valid handle and follow let-it-crash semantics (teardown) or nil-on-miss (lookup), matching the rules established in ADR 0079 for the parallel Actor surface.

This mirrors Actor spawnAs: / Class named: from the Actor Named Registration section — both APIs speak Result at registry / lifecycle boundaries so call sites that chain actor spawns and supervisor operations stay on a single error idiom.

Errors carry structured beamtalk_error values (ADR 0015) with a Symbol kind and a human-readable message. REPL display shows them as Result error: (beamtalk_error <kind>) so they are greppable in logs and aggregatable in metrics.

Idempotent-startup convention

Across every supervisor lifecycle method, an operation returns a successful Result when the caller's target end state is already in effect, regardless of whether this call or a prior one established it. The rule is "does the target state hold now?" — not "did this call change the input?"

MethodTarget stateIdempotent case
supervise"this supervisor is running"OTP {already_started, Pid}Result ok: sup
startChild / startChild:"a child of the configured class is running"fresh start → Result ok: child
terminate: / terminateChild:"this child is not running"OTP {error, not_found}Result ok: nil

This matters in practice: you can call WebApp supervise at every entry point of your application without branching on "is this the first call?" — the second caller gets the already-running supervisor back in the ok branch. Similarly, a cleanup path that calls app terminate: StaleChild succeeds whether the child was still alive or already gone, so you never have to swallow a raise to express "stop it if it's running."

Error is reserved for outcomes the caller cannot trivially ignore:

Call-site patterns

Boot-style: crash on failure. Use unwrap at application boot, test setup, and in the REPL — the resulting exception carries the structured error payload.

app  := (WebApp supervise) unwrap
pool := (WorkerPool supervise) unwrap
w    := pool startChild unwrap

Recoverable: branch on the Result. Use ifOk:ifError: (or andThen: / mapError:) when a failure should be logged or retried rather than crashing the caller.

(WebApp supervise)
  ifOk:    [:sup | Logger info: "app started with " , sup count asString , " children"]
  ifError: [:e   | Logger error: e message]

pool startChild
  ifOk:    [:worker | worker process: job]
  ifError: [:e      | Logger warn: "worker start failed: " , e message]

Idempotent terminate. terminate: / terminateChild: naturally express "make sure this child is gone" — no special-casing required.

// Before ADR 0080 — had to swallow Error because not_found raised
[app terminate: Counter] on: Error do: [:_e | nil]

// After — idempotent: returns Result ok: nil whether fresh terminate or already gone
(app terminate: Counter) unwrap
// real failures still surface as Result error: (beamtalk_error terminate_failed)
(app terminate: Counter) ifError: [:e | Logger warn: e message]

See ADR 0080 §Migration Path for the full mechanical rewrite guide and common gotchas (chained sends on the return value, type-narrowing in tests, REPL display changes).

BEAM Mapping

BeamtalkBEAM
Supervisor subclass:-behaviour(supervisor) with one_for_one
DynamicSupervisor(C) subclass:-behaviour(supervisor) with simple_one_for_one
supervisesupervisor:start_link({local, Module}, Module, [])
currentwhereis(Module)
countsupervisor:count_children/1 (active count)
childrensupervisor:which_children/1 (running child ids)
which: Classfind child by module in which_children result
withShutdown:shutdown field in child spec (default 5000ms workers, infinity supervisors)
stopgen_server:stop/1

Actor Named Registration (ADR 0079)

Actors can be registered under a Symbol name so they can be looked up without passing a reference around, and so supervised restarts stay addressable. Named lookups are class-checkedCounter named: #counter only returns the registered process if it is a Counter (or subclass).

// Atomic spawn + register — prefer this when the name is known up front
c := (Counter spawnAs: #counter) unwrap
c := (Counter spawnWith: #{#count => 10} as: #counter) unwrap

// Register an already-spawned actor (not atomic w.r.t. spawn)
(c registerAs: #counter) onSuccess: [:c | c increment]

// Typed lookup — Result(Self, Error), so `Counter named:` narrows to Counter
engine := (WorkflowEngine named: #engine) unwrap
(Logger named: #counter)   // => Result error: (beamtalk_error wrong_class)

// Instance queries
c registeredName    // => #counter  (or nil if unnamed)
c isRegistered      // => true

// Idempotent release
c unregister        // => #ok

// Discover all currently-registered Beamtalk actors
Actor allRegistered       // => #(an Actor(Counter), an Actor(Logger))

Restart survival. When a named actor is started under a supervisor (via SupervisionSpec withName:), the runtime dispatches sends through the registered name, not the snapshot pid. Held Counter named: #counter references continue to work after a supervisor restart because the name is re-registered atomically in the restarted process's gen_server:start_link({local, Name}, ...) call.

Errors are surfaced as Result values (or raised as #beamtalk_error{} on direct send):

KindWhen
name_registeredanother process is already registered under the name
name_not_registerednamed: lookup found nothing
wrong_classnamed: lookup found a process of a different class
reserved_namename is in the OTP kernel / stdlib blocklist
no_such_processsend through a {registered, Name} proxy whose name has vanished

See ADR 0079 for the full design and exposure table.

Introspecting the Live Supervision Tree (ADR 0092)

Where supervision syntax declares a tree, Workspace processes lets you walk the live one — the dynamic counterpart to Workspace actors, and the process-structure twin of SystemNavigation. It returns a navigable SupervisionTree snapshot of the running OTP supervision tree, built as a thin wrapper over supervisor:which_children (no new bookkeeping process).

tree := Workspace processes        // == ProcessNavigation default tree
tree root                          // => the snapshot root SupervisionNode
tree size                          // => total node count
tree do: [:node | Transcript showLine: node printString]
tree select: [:node | node isSupervisor]
tree findClass: Counter            // => every running Counter as SupervisionNodes
tree nodesOfKind: #beamtalkActor   // => List(SupervisionNode)

Each SupervisionNode is an immutable record:

node pid               // => a Pid | nil   (nil for a child mid-restart)
node registeredName    // => Symbol | nil
node kind              // => #beamtalkSupervisor | #beamtalkActor
                       //    | #otpSupervisor | #otpProcess | #restarting
node behaviourClass    // => Class | nil   (nil for foreign OTP processes)
node childCount        // => Integer       (live children; supervisors only)
node strategy          // => Symbol | nil  (#oneForOne … ; supervisors only)
node restartIntensity  // => Dictionary | nil  (configured #{#maxRestarts, #window})
node children          // => List(SupervisionNode)
node parent            // => SupervisionNode | nil
node isSupervisor      // => Boolean
node isBeamtalk        // => Boolean
node status            // => Dictionary | nil   (LAZY — see below)

The kind field drives rendering: a Beamtalk class badge for #beamtalkActor / #beamtalkSupervisor (with behaviourClass populated), a foreign-process badge for #otpSupervisor / #otpProcess. A child OTP is currently restarting carries kind => #restarting and pid => nil — the snapshot never crashes on a process caught mid-restart.

Snapshot semantics. Construction freezes the tree once; iterating it never re-enters OTP, so a walk is internally consistent and can never deadlock or block on a busy process. Construction itself is not atomic, so the snapshot is a best-effort point-in-time view — re-call Workspace processes to refresh. A node whose pid has since died is detected lazily: node status returns nil rather than raising.

Lazy state. node status is not captured at snapshot time. Calling it issues a timeout-guarded sys:get_status against the node's pid then — returning a Dictionary for an alive, sys-compliant process, or nil for one that is dead, timed out, or not sys-compliant.

Scopes and scale. ProcessNavigation default (the Workspace processes alias) filters runtime plumbing; ProcessNavigation system shows everything, including runtime internals — a privileged view (ADR 0091). A from: constructor roots a walk at a Supervisor handle or a Pid, returning a Result (the root may be dead). A simple_one_for_one DynamicSupervisor with more children than the cap is reported truncated with its childCount instead of materialising every child; opt into full expansion with ProcessNavigation from: aSup limit: n.

ProcessNavigation system tree size                 // everything, incl. infra
(ProcessNavigation from: aSup) unwrap tree         // a rooted subtree
(ProcessNavigation from: aStoppedSup)              // => Result error: (beamtalk_error stale_handle)

See ADR 0092 for the full design.


Named Actor Registration (ADR 0079)

Named actor registration gives a process a stable identity (a Symbol) that survives supervised restarts. A name-resolving proxy re-resolves the name on every message send, so a held reference keeps working even when the underlying actor is restarted with a fresh pid.

Under the hood this maps directly to OTP's process registry (erlang:register/2, gen_server:start_link({local, Name}, ...)), so registered Beamtalk actors show up in observer, recon, and erlang:registered/0 with the names you chose.

API Surface

MethodKindReturnsSemantics
Class spawnAs: nameclass-sideResult(Self, Error)Atomic spawn + register. Equivalent to gen_server:start_link({local, Name}, ...) — the name is registered during process startup.
Class spawnWith: initArgs as: nameclass-sideResult(Self, Error)Same as spawnAs: but with initialization arguments.
Class named: nameclass-sideResult(Self, Error)Look up a registered actor. Self resolves to the receiver class at the call site, so Counter named: returns a Counter.
Actor allRegisteredclass-sideList(Actor)Enumerates currently-registered Beamtalk actors. Excludes raw OTP-registered processes (kernel_sup, logger, …).
actor registerAs: nameinstanceResult(Self, Error)Register an already-spawned actor. Non-atomic — prefer spawnAs: when the name is known up front.
actor unregisterinstanceSymbol#ok. Idempotent — unregistering an unregistered actor is not an error.
actor registeredNameinstanceSymbol or nilCurrently-registered name, or nil.
actor isRegisteredinstanceBooleanWhether the actor currently has a registered name.

Supervised children gain naming through SupervisionSpec withName:, which tells the runtime to start the child with {local, Name} registration so the name is re-established every time the supervisor restarts the child:

EventStore supervisionSpec withName: #eventStore withRestart: #permanent

Errors

Registration returns Result(Self, Error) rather than raising — callers branch explicitly on outcome:

ConditionResult
spawnAs: / registerAs: — duplicate registrationResult error: (beamtalk_error name_registered)
spawnAs: / registerAs: — name is in the reserved listResult error: (beamtalk_error reserved_name)
spawnAs: / registerAs: — non-Symbol nameResult error: (beamtalk_error type_error)
Class named: — no actor registered under this nameResult error: (beamtalk_error name_not_registered)
Class named: — name is registered but the actor is not a Class or subclassResult error: (beamtalk_error wrong_class)
Send to a proxy whose name is not currently registered (the target died or was unregistered after lookup)Raises beamtalk_error no_such_process

The asymmetry is deliberate: named: returns a Result because name-absence is an expected outcome the caller must branch on; sending to a vanished proxy raises because the caller has already committed to a send.

Worked Example — Migrating from Supervisor which:

Before named registration

The pre-ADR pattern uses a supervisor-local lookup (which:) and an initialize: hook to re-wire dependencies after each restart:

typed Supervisor subclass: ExduraSupervisor
  class strategy -> #oneForOne | #oneForAll | #restForOne => #restForOne
  class children -> List(SupervisionSpec) =>
    storeSpec := EventStore supervisionSpec withRestart: #permanent
    poolSpec := ActivityWorkerPool supervisionSpec withRestart: #permanent
    engineSpec := WorkflowEngine supervisionSpec withRestart: #permanent
    #(storeSpec, poolSpec, engineSpec)

  // Re-runs after every restart to rebuild cached pids.
  class initialize: sup :: Supervisor -> Nil =>
    store := (sup which: EventStore) unwrap
    pool := (sup which: ActivityWorkerPool) unwrap
    engine := (sup which: WorkflowEngine) unwrap
    engine initWithStore: store pool: pool
    nil

After named registration

Naming each child eliminates the initialize: hook, and the supervisor strategy is freed from the rewire-on-restart constraint:

typed Supervisor subclass: ExduraSupervisor
  class strategy -> #oneForOne | #oneForAll | #restForOne => #oneForOne
  class children -> List(SupervisionSpec) => #(
    EventStore supervisionSpec withName: #eventStore withRestart: #permanent,
    ActivityWorkerPool supervisionSpec withName: #workerPool withRestart: #permanent,
    WorkflowEngine supervisionSpec withName: #workflowEngine withRestart: #permanent
  )
  // No initialize: hook — WorkflowEngine looks up its dependencies by name.

WorkflowEngine now calls (Actor named: #eventStore) unwrap at use-time — automatically picking up the current pid across restarts — and cross-tree consumers (HTTP handlers, REPL workspaces, tests) can reach supervised actors directly without routing through the supervisor.

Proxy Semantics

Class named: returns a lightweight name-resolving proxy. The proxy does not cache a pid; each message send re-resolves the name via the Erlang runtime. This is the key restart-survival property:

engine := (WorkflowEngine named: #workflowEngine) unwrap
engine runWorkflow: w1    // resolves #workflowEngine, sends to that pid
// (#workflowEngine crashes; the supervisor restarts it under the same name)
engine runWorkflow: w2    // re-resolves #workflowEngine, sends to the NEW pid

A few caveats the proxy intentionally does not paper over:

Reserved Names

A static blocklist of OTP-kernel atoms is rejected at registration time, regardless of whether the corresponding process is currently running. Attempting spawnAs: #logger or spawnAs: #kernel_sup returns Result error: (beamtalk_error reserved_name).

The reserved set covers:

See beamtalk_actor:reserved_name/1 in the runtime for the authoritative list and the policy rationale (ADR 0079 §Errors).

Scope

This release covers local (per-node) registration. Cluster-wide (#global) and pluggable ({via, Module, Term}) scopes are deferred to a future ADR — the API is designed to admit them additively via a scope: keyword. Users who need cluster registration today can call the Erlang global module via FFI.

BEAM Mapping

BeamtalkBEAM
Class spawnAs: #foogen_server:start_link({local, foo}, Module, #{})
Class spawnWith: args as: #foogen_server:start_link({local, foo}, Module, args)
actor registerAs: #fooerlang:register(foo, Pid)
actor unregisterBeamtalk-wrapped idempotent erlang:unregister(foo) — Beamtalk catches the badarg raw Erlang raises when the name is absent and returns ok, so repeated/unnecessary unregisters are safe
Class named: #fooerlang:whereis(foo) + Beamtalk class check via '$beamtalk_actor' process-dict marker
Actor allRegisterederlang:registered/0 filtered by the process-dict marker
Proxy send (proxy foo)gen_server:call(foo, ...) — name-resolved per send
SupervisionSpec withName:Child MFA uses {beamtalk_actor, spawnAs, [Name, Module, ...]} so the supervisor re-registers the name on every restart

Pattern Matching

Smalltalk lacks pattern matching - this is a major ergonomic addition.

Match Expression

The match: keyword message takes a block of pattern arms separated by ;:

// Basic match with literals
x match: [1 -> "one"; 2 -> "two"; _ -> "other"]

// Variable binding in patterns
42 match: [n -> n + 1]
// => 43

// Symbol matching
status match: [#ok -> "success"; #error -> "failure"; _ -> "unknown"]

// String matching
greeting match: ["hello" -> "hi"; _ -> "huh?"]

// Guard clauses with when:
x match: [
  n when: [n > 100] -> "big";
  n when: [n > 10] -> "medium";
  _ -> "small"
]

// Negative number patterns
temp match: [-1 -> "minus one"; 0 -> "zero"; _ -> "other"]

// Match on computed expression
(3 + 4) match: [7 -> "correct"; _ -> "wrong"]

// Array destructuring in match arms (BT-1296)
#[10, 20] match: [
  #[h, t] -> h + t;
  _ -> 0
]
// => 30

// Dict/map destructuring in match arms (BT-1296)
#{#event => "click", #x => 5} match: [
  #{#event => evName} -> evName;
  _ -> "unknown"
]
// => "click"

// Nested array patterns
#[#[1, 2], 3] match: [
  #[#[a, b], c] -> a + b + c;
  _ -> 0
]
// => 6

// Constructor patterns (Result ok:/error: only in this release)
(Result ok: 42) match: [
  Result ok: v    -> v;
  Result error: _ -> 0
]
// => 42

Supported pattern types:

PatternExampleDescription
Wildcard_Matches anything
Literal integer42Exact integer match
Literal float3.14Exact float match
Literal string"hello"Exact string match
Literal symbol#okExact symbol match
Literal character$aExact character match
Negative number-1Negative integer/float match
VariablexBinds matched value to name
Tuple{a, b}Destructure tuple in assignment and match arms
Array#[a, b]Match and destructure an Array by exact size; nested arrays supported
Array rest#[a, ...rest]Destructure first elements, bind remaining to a sub-array (destructuring assignment only)
Dict/Map#{#k => v}Match a Dictionary containing key #k, bind value to v; partial match (other keys ignored)
ConstructorResult ok: vMatch sealed type by constructor (Phase 1: Result only)

Exhaustiveness checking (BT-1299): match: on a sealed type with constructor patterns must cover all known variants or include a wildcard _ arm, or the compiler emits an error:

// Compile error: missing error: arm
r match: [Result ok: v -> v + 1]

// Fine: all variants covered
r match: [Result ok: v -> v + 1; Result error: _ -> 0]

// Fine: wildcard suppresses the check
r match: [Result ok: v -> v + 1; _ -> 0]

Advisory singleton-union exhaustiveness (BT-2745, ADR 0102): when the type checker knows a match: scrutinee is a closed union of #symbol singletons, it emits a warning (never an error) for any uncovered members:

// direction :: #north | #south | #east | #west
direction match: [
  #north -> 0;
  #south -> 180;
  #east  -> 90
]
// ⚠ Warning: non-exhaustive match: `#west` is not handled (residual type: `#west`)

This check is advisory — it fires only when the scrutinee type is a union of pure #symbol singletons (not Dynamic, open Symbol, or mixed unions). An unguarded _ -> wildcard silences the warning; guarded arms do not count as coverage.

Asserted exhaustiveness — matchExhaustive: (BT-2763, ADR 0106): matchExhaustive: is an opt-in, stricter variant of match: that asserts exhaustiveness. It parses identically to match: (same patterns, guards, and destructuring), but the check runs at error severity instead of warning:

// direction :: #north | #south | #east | #west

// Compile error: matchExhaustive: proves this is NOT exhaustive
direction matchExhaustive: [
  #north -> 0;
  #south -> 180;
  #east  -> 90
]
// ⛔ Error: non-exhaustive matchExhaustive: `#west` is not handled (residual type: `#west`)

// Fine: all four members covered — silent, no diagnostic
direction matchExhaustive: [
  #north -> 0;
  #south -> 180;
  #east  -> 90;
  #west  -> 270
]

// Fine: an unguarded wildcard is still full coverage
direction matchExhaustive: [
  #north -> 0;
  _      -> -1
]

If the scrutinee's type is not a closed union of #symbol singletons (Dynamic, a bare/open Symbol, or a mixed union), matchExhaustive: cannot verify the assertion and fails loudly rather than staying silent:

x matchExhaustive: [#ok -> 1; _ -> 0]
// ⛔ Error: cannot verify `matchExhaustive:` is exhaustive — scrutinee type
//    `Dynamic` is not a closed union of symbol singletons

Plain match:'s advisory warning behaviour is unchanged by matchExhaustive: — the two checks are independent, and only the keyword you write selects between them.

Guard expressions support: >, <, >=, <=, =:=, =/=, /=, +, -, *, /

Destructuring in Match Arms

Pattern matching can bind variables in match arms:

// Variable captures the matched value
42 match: [x -> x + 1]
// => 43

// Variable binding with guard
10 match: [x when: [x > 100] -> "big"; x when: [x > 5] -> "medium"; _ -> "small"]
// => "medium"

// Tuple destructuring in match arms
t := Erlang erlang list_to_tuple: #(#ok, 42)
t match: [{#ok, v} -> v; {#error, _} -> 0]
// => 42

Rest Patterns in Destructuring (BT-1251)

The ...identifier syntax in array destructuring captures remaining elements:

#[first, ...rest] := #[1, 2, 3, 4, 5]
// first = 1, rest = #[2, 3, 4, 5]

#[a, b, ...tail] := #[10, 20, 30, 40]
// a = 10, b = 20, tail = #[30, 40]

#[...all] := #[1, 2, 3]
// all = #[1, 2, 3]

#[head, ..._] := #[1, 2, 3]
// head = 1 (rest discarded)

The rest element must be the last in the pattern. Rest patterns are supported in destructuring assignment only — they are not yet supported in match: arms.

Note: Tuple destructuring works in both assignment ({x, y} := expr) and match: arms. collect: with pattern blocks is not yet supported.


Live Patching

Hot code reload via message sends — no dedicated patch syntax needed.

// Canonical Counter (already running in the workspace)
Actor subclass: Counter
  state: value = 0
  increment => self.value := self.value + 1
  getValue => self.value

// Replace a single method — existing instances pick it up immediately
Counter >> increment =>
  Telemetry log: "incrementing"
  self.value := self.value + 1

// Redefine the class to add state — new instances get the updated shape
Actor subclass: Counter
  state: value = 0
  state: lastModified = nil
  increment =>
    self.value := self.value + 1
    self.lastModified := DateTime now
  getValue => self.value

Live patching works on the class side too (ADR 0084): ClassName class >> sel => body installs or replaces a class method on a registered class, and class-side dispatch resolves the new method immediately.

Object subclass: Registry
  class current => nil

// Live-edit the class method — subsequent class-side sends pick it up
Registry class >> current => "live"
Registry current        // => "live"

// Add a brand-new class-side selector
Registry class >> reset => 0
Registry reset          // => 0

The >> live-edit path recompiles the class's recorded source, so it applies to classes defined in source (inline Object subclass: or :loaded files). Purely-programmatic ClassBuilder classes have no recorded source; supply their class methods up front via classMethods: / addClassMethod:body: instead.

Saving live edits back to disk — compile:source:, ChangeLog, and flush (ADR 0082)

Live patches go into memory; they reach the .bt source file only when you flush. Between the patch and the flush, every in-memory mutation is recorded in the workspace ChangeLog — the pending-changes view, dirty-state tracker, and undo store rolled into one (ADR 0082).

The model has three layers: in-memory class state (hot-reloaded BEAM), the ChangeLog (per-workspace append-only log; persists across workspace restart), and the .bt files on disk. Every successful live patch updates memory and appends a ChangeEntry. Workspace flush walks pending entries and splices each patched body back into its source file via byte-span replacement — no AST re-print, no whole-file reformat — atomically (<file>.tmp + rename) with external-edit conflict detection.

The patcher primitives

MethodIntentLogs?Used by
aClass >> sel => body (parser sugar)durableyeshumans at the REPL
aClass compile: #sel source: "body"durableyesMCP save_method, browser "Save", REPL editor
aClass tryCompile: #sel source: "body"ephemeral (auto-prunes)yesMCP try_method, agent spikes
Workspace newClass: source at: pathdurable, kind: #'new-class'yesMCP save_class, browser "New File"

>> and compile:source: are equivalent in effect — both install the new method and append a durable ChangeEntry. The keyword form takes the body as a String value so tools (MCP, LSP, browser editors) don't have to escape quotes or multi-line bodies back into source. tryCompile:source: installs in memory like compile:source: but tags the entry as ephemeral — successful spikes are promoted by re-calling compile:source: with the same body. Every successful in-memory mutation logs unconditionally, including spikes and patches against stdlib / dependency classes (which are not flushable). The audit trail is exhaustive on purpose.

Canonical patch → changes → flush round trip

> Counter >> increment => self.value := self.value + 1
=> Counter                          // memory patched
> Workspace changes notEmpty
=> true
> Workspace changes dirtyMethods
=> #{#Counter => #{#increment}}     // per-class set of dirty selectors
> Workspace flush
=> _                                // FlushResult; quiet on success
> Workspace changes isEmpty
=> true                             // flushed entries drop out of the active view

Workspace changes returns a ChangeLog object (see below). Workspace flush returns a FlushResult summary with #flushed, #files, #newClasses, and #conflicts. A non-empty #conflicts list means the listed entries remain pending and require manual reconciliation.

Targeted flush

Workspace flush                                   // every durable + flushable entry
Workspace flush: Counter                          // entries targeting one class
Workspace flush: #'new-class'                     // entries of one kind
Workspace flush: #{ #file => "src/counter.bt" }   // entries against one file
Workspace changes flushKinds: #{#agent}           // only agent-authored entries
Workspace changes flushKinds: #{#agent, #'new-class'}  // both filters AND together

External-edit conflict — patch, edit on disk, flush

> Counter >> increment => self.value := self.value + 2   // memory patched
=> Counter
// ... another editor (or `git pull`) modifies examples/counter.bt on disk ...
> Workspace flush
=> _   // FlushResult with #conflicts: [#{#file => "examples/counter.bt",
       //                                  #reason => #external_edit, ...}]
       // The patch stays pending; memory is still ahead of disk.

When flush detects an external edit, the offending entries stay in the log and the user picks the recovery path:

Workspace changes clear                           // drop the pending ChangeLog entries
                                                  //   (already-installed patches stay in memory
                                                  //    until workspace restart — use `revert:` to
                                                  //    actually re-install the prior method body)
Workspace changes revert: anEntry                 // undo one patch (re-install prior body)
// or open the file, reconcile by hand, then:
Workspace flush                                   // retry once disk matches expectations

Ephemeral spike → promote → flush

> Counter tryCompile: #doubled source: "doubled => self.value * 2"
=> Counter                          // memory patched, ChangeEntry logged as ephemeral
> (Counter spawn) doubled
=> 0                                // works — agent decides to keep it
> Counter compile: #doubled source: "doubled => self.value * 2"
=> Counter                          // promoted: durable ChangeEntry layered on top
> Workspace flush
=> _                                // disk gains the new method

The earlier ephemeral entry remains in the log for audit and is auto-pruned on the next workspace restart.

Creating a brand-new class file

> Workspace newClass: "Object subclass: Greeter\n  greet => 'hello'" at: "src/greeter.bt"
=> [Greeter]                        // compiled and installed in memory
> Workspace flush
=> _                                // writes src/greeter.bt

newClass:at: raises a loud, specific error (no silent fallback) if path already exists, lies outside the project tree, the declared class name does not match the path basename (ADR 0040 one-class-per-file convention), or a class of that name is already loaded.

autoflush

For users who want write-through editor semantics, flip a single workspace setting:

Workspace autoflush       // => false  (default)
Workspace autoflush: true // => true   (every successful durable patch flushes immediately)

Autoflush persists across workspace restarts. It is best-effort, not transactional — a flush failure under autoflush (external-edit conflict, write error) leaves memory ahead of disk and the entry pending in the log. The BEAM module install is not rolled back because live actors may hold references to the new closures. The error surfaces with a "memory ahead of disk" warning.

Ephemeral patches via tryCompile:source: are never autoflushed.

Flushability — what flush writes

A class is flushable iff its sourceFile is non-nil and lies inside the current project's source tree. Workspace flush writes only entries where intent = durable AND flushable = true. Other entries are reported under #conflicts (for external-edit / target-exists errors) or simply skipped:

ChangeLog

Workspace changes returns a ChangeLog object (analogous to Pharo's Smalltalk changes). All pending-state queries live on this object, not on the Workspace facade itself.

MethodReturnsDescription
sizeIntegerActive (live, re-appliable) entries
isEmpty / notEmptyBoolean"Is anything dirty?" is Workspace changes notEmpty
do: blockNilIterate active entries
select: blockListFilter all entries (reaches orphans, prior-epoch, and shadowed entries too)
dirtyMethodsDictionary#{Class => Set(selectors)} for the active set
revert: anEntryclassRe-install prev_source for that entry (itself a durable patch)
clearChangeLogDiscard every pending entry without writing to disk (memory keeps the patches until restart)
flushKinds: kindsFlushResultFlush only entries matching a Set of #instance / #class / #'new-class' / #human / #agent symbols (both dimensions AND together)
allEntriesList(ChangeEntry)Every logged entry, including prior-epoch, orphan, shadowed, and clean entries
activeEntriesList(ChangeEntry)The default view: current-epoch, non-orphaned entries, collapsed to the latest entry per (class, selector) and filtered to those still differing from disk — one row per method that has a net change

Each ChangeEntry carries the patch's body, prior body, byte span, class, selector, intent (durable / ephemeral), flushable flag, authorKind (#human / #agent), and source-file reference. Bodies are stored as plain .bt files under <workspace>/changes/sources/; metadata lives in <workspace>/changes/changes.jsonl. cat, less, diff, and bt fmt all work on the source files directly.

Repeated patches to one method — or a patch followed by a revert:, which is itself a patch (ADR 0082 "Undo") — append multiple entries for the same (class, selector). The default view keeps only the latest (the one Workspace flush would apply) and marks the rest shadowed; e isShadowed identifies them, and select: still reaches them for audit.

The latest entry is also compared against the current on-disk body: if it matches (the method was reverted back to its on-disk state) the entry is clean (e isClean) and drops out of the default view — there is no net change to flush. Each entry that does differ carries e diff, the net on-disk→in-memory unified diff (lines prefixed / - / + ). So Workspace changes answers "what differs from disk", not "everything I touched this session"; the audit trail of every entry stays in allEntries / select:.

The ChangeLog persists across workspace restart. On restart, the workspace assigns a fresh epoch and excludes prior-epoch entries from the active view (their memory state is gone). The underlying audit log keeps them; reach them via Workspace changes select: [:e | e isOrphan].

REPL and tooling shortcuts

Every operation above is reachable via the REPL meta-commands, MCP tools, LSP executeCommand handlers, and browser actions. These are all thin front-ends over the Beamtalk language — see REPL shortcuts below and the Tooling guide for the surface tables.


Extension Methods (Open Classes)

The >> syntax adds methods to existing classes without redefining them (ADR 0066). Extensions work on any class including built-in value types.

// Instance method
String >> shout => self uppercase ++ "!"

// Class-side method
String class >> fromJson: s => // ...parse JSON string

// Keyword method with typed parameter
Array >> chunksOf: n :: Integer => // ...split into n-sized chunks

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

Type annotations on extensions

Extensions support the same -> ReturnType annotation as regular methods. Additionally, extensions accept :: -> ReturnType as a visual separator between the selector and return type — especially useful on unary methods where there are no parameters to carry :: annotations.

// Standard return type syntax (same as inside a class)
String >> reversed -> String => self reverse

// Extension-style: `:: ->` separates selector from return type
Integer >> factorial :: -> Integer =>
  self <= 1
    ifTrue: [1]
    ifFalse: [self * (self - 1) factorial]

String >> words :: -> Array => self split: " "

// Typed parameters with :: -> return type
Map >> at: key :: String put: value :: Integer :: -> Map => // ...

Both forms are equivalent — the return type flows to the type checker identically. The :: -> form is preferred for unary extensions; the -> form is preferred when parameters already have :: annotations (to avoid consecutive :: tokens).

Cross-file extensions

Extensions can target classes defined in other files or in stdlib. The compiler registers each foreign extension at module load, so it dispatches at runtime just like a same-file extension. Class-side extensions register under the metaclass tag (String class).

// In helpers.bt — String is defined in stdlib, not this file
String >> shoutIt => super printString uppercase ++ "!"

// Class-side foreign extension
String class >> banner => "=== String ==="

Workspace and Reflection API

Beamtalk exposes workspace operations and system reflection as typed message sends (ADR 0040). Two singleton objects provide the primary interface:

Beamtalk — System reflection (BeamtalkInterface)

Provides access to the class registry, documentation, and system namespace. Analogous to Pharo's Smalltalk image facade.

MethodReturnsDescription
versionStringBeamtalk version string
allClassesListAll registered classes (class objects)
classNamed: #NameObject or nilLook up a class by name
globalsDictionarySnapshot of system namespace (class names → class objects)
help: aClassStringClass documentation: name, superclass, method signatures
help: aClass selector: #selStringDocumentation for a specific method
Beamtalk version
// => "0.4.0"

Beamtalk allClasses includes: Integer
// => true

Beamtalk classNamed: #Counter
// => Counter (or nil if not loaded)

(Beamtalk globals) at: #Integer
// => Integer

(Beamtalk help: Integer)
// => "== Integer < Number ==\n..."

(Beamtalk help: Integer selector: #+)
// => "Integer >> +\n..."

Workspace — Project operations (WorkspaceInterface)

Provides file loading, testing, and actor introspection. Scoped to the running workspace. Analogous to Pharo's Smalltalk project facade.

MethodReturnsDescription
load: "path"nil or ErrorCompile and load a .bt file or directory
newClass: source at: pathList(Behaviour)Create a brand-new class from source at path; logs a kind: #'new-class' ChangeEntry (ADR 0082)
classesListAll loaded user classes (those with a recorded source file)
testClassesListLoaded classes that inherit from TestCase
globalsBindingsViewLive, write-through view of the workspace-globals layer: singletons + bind:as: entries (see Sessions and binding layers)
currentSessionSession or nilThe calling process's REPL session (same value as Session current); nil outside a REPL eval
sessionsList(Session)All live REPL sessions as Session values
testTestResultRun all loaded test classes
test: AClassTestResultRun a specific test class
actorsListAll live actors as object references
actorAt: pidStrObject or nilLook up a live actor by pid string
actorsOf: AClassListAll live actors of the given class
bind: value as: #NameNilRegister a value in the workspace namespace
unbind: #NameNilRemove a registered name from the namespace
changesChangeLogPending in-memory changes (ADR 0082) — see Saving live edits back to disk
flushFlushResultWrite every durable + flushable ChangeEntry back to its source file (ADR 0082)
flush: filterFlushResultFlush a subset (Class / Symbol kind / #{#file => path})
autoflushBooleanWorkspace setting (default false); persists across restarts
autoflush: enabledBooleanToggle write-through: every durable patch immediately flushes (best-effort)
(Workspace load: "examples/counter.bt")
// => nil  (Counter is now registered)

(Workspace classes) includes: Counter
// => true

(Workspace testClasses) includes: CounterTest
// => true

(Workspace test: CounterTest) failed
// => 0  (all tests pass)

(Workspace actors) size
// => 3  (number of live actors)

Class-based reload via Behaviour >> reload

Every class records the source file it was compiled from. You can reload a class directly via a message send — no file path needed:

MethodReturnsDescription
sourceFileString or nilPath the class was compiled from; nil for stdlib/dynamic classes
reloadselfRecompile from sourceFile, hot-swap BEAM module
Counter sourceFile
// => "examples/counter.bt"

Counter reload
// => Counter  (recompiled and hot-swapped)

Integer sourceFile
// => nil  (stdlib built-in, no source file)

Integer reload
// => Error: Integer has no source file — stdlib classes cannot be reloaded

Hot-swap semantics follow BEAM conventions: live actors running the old code continue their current message; the next dispatch uses the new code.

SystemNavigation — Cross-class code queries

SystemNavigation provides Smalltalk-style live-image queries over the loaded class registry. Reach the singleton via SystemNavigation default.

MethodReturnsDescription
allClassesList(Behaviour)All registered classes (class objects)
actorClassesList(Behaviour)Classes whose superclass chain includes Actor, sorted alphabetically
dnuHandlersList(Behaviour)Classes that locally override doesNotUnderstand:args:, sorted alphabetically
extendersOf: aClassList(Package)Packages contributing extension methods to aClass
extensionsBy: aPackageList(Dictionary)#{#class, #selector} for each extension method aPackage contributes
implementorsOf: #selList(Behaviour)Classes that define the given selector
sendersOf: #selList(Dictionary)#{#class, #selector, #line} for every method body that sends #sel
messagesSentBy: aMethodList(Dictionary)#{#selector, #line} for every message send in aMethod's body — the outgoing-call dual of sendersOf:. aMethod must be a CompiledMethod (e.g. Counter >> #increment). Excludes Erlang FFI sends
referencesTo: aClassList(Dictionary)#{#class, #selector, #line} for every method body that references the class name
announcementsSentBy: aClassList(Behaviour)Distinct Announcement subclasses that aClass statically emits via announce: / announceAndWait: / announceAndWait:timeout:, sorted by name — the publisher-side dual of AnnouncementNavigation. Advisory: only constructor-call arguments (announce: (PriceChanged newPrice: 42)) resolve; Dynamic/indirect arguments are skipped
announcementSitesSentBy: aClassList(Dictionary)#{#class, #selector, #line, #announcementClass} for every resolvable announcement emission within aClass — the site-level form of announcementsSentBy:
ffiSitesFor: aSpecList(Dictionary)#{#class, #selector, #line} for every method body that calls Erlang module:function (optionally arity-qualified, e.g. "lists:reverse/1")
fieldReadersOf: #slot in: aClassList(Dictionary)#{#class, #selector, #line} for every method that reads field/class var #slot while scanning aClass + subclasses on instance and class sides
fieldWritersOf: #slot in: aClassList(Dictionary)#{#class, #selector, #line} for every method that writes field/class var #slot while scanning aClass + subclasses on instance and class sides
methodsMatching: aRegexList(Dictionary)#{#class, #selector} for every method whose source matches the regex
selectorsMatching: patternList(Symbol)Selectors matching a case-insensitive substring (e.g., "print")
selectorsForClass: aClassList(Symbol)All selectors defined on a class (instance + class + extension)
classesInPackage: aPackageList(Behaviour)Class objects belonging to package aPackage (Symbol or String; ADR 0070)
subclassesOf: aClass in: aPackageList(Behaviour)Subclasses of aClass that live in package aPackage (allSubclasses filtered by package)
unimplementedSelectorsList(Dictionary)Selectors sent but defined nowhere — a typo-finder lint
unusedSelectorsList(Dictionary)Selectors defined but sent nowhere — dead-method candidates

Body-based queries (sendersOf:, referencesTo:, ffiSitesFor:, methodsMatching:, announcementsSentBy:, announcementSitesSentBy:, and the selector-lint queries) scan instance-side, class-side, and extension method bodies. fieldReadersOf:in: and fieldWritersOf:in: scan aClass + subclasses on instance/class sides (not extension methods). Each result's #class field is the class object for an instance-side hit and the metaclass object (Counter class) for a class-side hit.

announcementsSentBy: is a deliberately advisory static analysis of a dynamic language — the publisher-side dual of AnnouncementNavigation's runtime subscription queries. It resolves an announce: argument to its Announcement subclass only when the argument is a constructor call on a bare class reference (announce: (PriceChanged newPrice: 42) or announce: PriceChanged new). A Dynamic-typed or indirect argument (announce: someVar, perform:-style dispatch) is unresolvable by construction and is silently skipped, so the result is discoverability, never a sound or exhaustive emission contract.

nav := SystemNavigation default

nav implementorsOf: #printString
// => [Object, Integer, String, ...]

nav sendersOf: #increment
// => [#{#class => CounterTest, #selector => #testIncrement, #line => 12}, ...]

nav referencesTo: Counter
// => [#{#class => CounterTest, #selector => #setUp, #line => 5}, ...]

nav methodsMatching: (Regex from: "printString") unwrap
// => [#{#class => Object, #selector => #printString}, ...]

nav selectorsMatching: "print"
// => [#printString, #printOn:, ...]

nav messagesSentBy: (Counter >> #increment)
// => [#{#selector => #+, #line => 2}, ...]

nav announcementsSentBy: PriceTracker
// => [PriceChanged, ...]  (distinct Announcement subclasses PriceTracker emits)

nav announcementSitesSentBy: PriceTracker
// => [#{#class => PriceTracker, #selector => #notify, #line => 4, #announcementClass => PriceChanged}, ...]

nav unimplementedSelectors
// => []  (empty = no typos in the loaded registry)

nav unusedSelectors
// => [#{#class => MyLib, #selector => #helperNoOneCalls}, ...]

nav actorClasses
// => [Actor, ClassBuilder, MyActor, ...]

nav dnuHandlers
// => [ErlangModule, ProtoObject, TimeoutProxy, ...]

nav extendersOf: String
// => [Package(my_lib v1.0.0), ...]

nav extensionsBy: (Package named: "my_lib")
// => [#{#class => String, #selector => #asJson}, ...]

nav classesInPackage: #stdlib
// => [Actor, Array, ...]

nav subclassesOf: Number in: #stdlib
// => [Float, Integer]

nav fieldReadersOf: #value in: Counter
// => [#{#class => Counter, #selector => #getValue, #line => 5}, ...]

nav fieldWritersOf: #value in: Counter
// => [#{#class => Counter, #selector => #increment, #line => 3}, ...]

nav ffiSitesFor: "lists:reverse"
// => [#{#class => MyList, #selector => #reversed, #line => 7}, ...]

Sessions and binding layers (ADR 0081)

The REPL resolves a bare name (x, Transcript, Counter) against two binding layers, each owned by a different object:

LayerOwnerSourceAccessor
Session localsthe session (per connection)x := 42 typed in the shellSession current bindings
Workspace globalsthe workspace (shared)singletons (Transcript, Beamtalk, Workspace) + bind:as: entriesWorkspace globals

Locals are checked first, so a local shadows a global of the same name. Names not found in either layer fall through to the class registry (Counter, Integer), then raise undefined_variable.

Session — a first-class session value

Session is a factory, mirroring Date today / Smalltalk current: two class-side methods return a session value you then message. There is no class-side operation mirror (no Session bindings) and no globals accessor on Session — globals are workspace state, reached via Workspace globals.

Class methodReturnsDescription
Session currentSession or nilThe calling process's session; nil outside a REPL eval (compiled code has no session)
Session withId: anIdSession or nilLook up a session by its protocol id; nil if unknown or no longer alive
Instance methodReturnsDescription
bindingsBindingsViewLive view of this session's locals (the x := 42 layer)
resolve: #nameObjectResolve a name the way bare-name lookup does (locals → globals → classes). Shares the one resolver with bare-name lookup, so it raises undefined_variable for a name that resolves nowhere — exactly as typing the bare name would
clearnilClear this session's locals (globals remain)
idStringStable session identifier (matches the protocol session id)
x := 42
// => 42

Session current bindings keys
// => #(#x)

Session current bindings at: #x
// => 42

Session current resolve: #Transcript
// => the Transcript singleton

Session current resolve: #notDefinedAnywhere
// => Error: Undefined variable: notDefinedAnywhere

Session current clear
// => nil

Outside a REPL eval (e.g. in a .bt file run via beamtalk run), Session current returns nil. Guard with ifNotNil: rather than a predicate:

Session current ifNotNil: [:s | s clear]

BindingsView — a live, write-through Dictionary view

Both Session current bindings and Workspace globals return a BindingsView: a small Dictionary-protocol value (at:, at:put:, removeKey:, includesKey:, keys, values, size, do:) backed by live state. at:put: returns the value put; removeKey: returns nil.

// Session-local write — DEFERRED to end of eval, visible on the NEXT line:
Session current bindings at: #y put: 99
// => 99
y
// => 99

// Workspace-global write — SYNCHRONOUS (routes through bind:as:),
// visible immediately on the next line:
Workspace globals at: #answer put: 42
// => 42
answer
// => 42

One documented asymmetry under the shared type: session-local writes are deferred to the end of the current eval (the eval worker holds a state snapshot), so a same-expression read-back sees the old value; workspace-global writes hit shared ETS immediately. Writing a protected system name through the globals view raises the same conflict as Workspace bind:as::

Workspace globals at: #Workspace put: nil
// => Error: Workspace is a system name and cannot be shadowed

Cross-session access (read-only)

Session withId: returns another session by id — used by tooling (LSP, VS Code) to read the user's session from a separate completion session. Cross-session reads are allowed; writes raise cross_session_mutation_unsupported:

// Pick a session that is NOT this one — session ordering is not guaranteed, so
// `sessions first` could be the current session, where a write would succeed
// (self-session) rather than raise. Tooling normally already knows the target id.
myId    := Session current id
otherId := (Workspace sessions collect: [:s | s id]) detect: [:each | each /= myId]
other   := Session withId: otherId

other bindings keys          // => cross-session READ, allowed
other bindings at: #x put: 9 // => Error: Cannot mutate another session's bindings

REPL shortcuts (: commands) are thin wrappers

The REPL : commands are convenience aliases that desugar to the native message sends:

REPL shortcutBeamtalk native equivalent
:syncWorkspace sync
:load pathWorkspace load: "path"
:reload CounterCounter reload
:testWorkspace test
:test CounterTestWorkspace test: CounterTest
:help CounterBeamtalk help: Counter
:help Counter incrementBeamtalk help: Counter selector: #increment
:changesWorkspace changes
:dirtyWorkspace changes dirtyMethods
:flushWorkspace flush
:flush CounterWorkspace flush: Counter
:flush #'new-class'Workspace flush: #'new-class'
:flush #{ #file => "path" }Workspace flush: #{ #file => "path" }

The native forms work from compiled code, scripts, and actor methods — not just the REPL.


Actor Observability and Tracing (ADR 0069)

The Tracing class provides actor observability and performance telemetry. It is a sealed, class-only facade (like System and Logger) — all methods are class-side, there are no instances. See ADR 0069 for the full design.

Two levels of instrumentation are available:

Tracing Lifecycle

// Enable detailed trace capture
Tracing enable
// => nil

// Check if tracing is active
Tracing isEnabled
// => true

// Disable trace capture (aggregates continue)
Tracing disable
// => nil

// Clear all trace events and aggregate stats
Tracing clear
// => nil

Aggregate Stats (Always-On)

Aggregate stats are collected for every actor dispatch, even when trace capture is disabled. They include call counts, total duration, min/max/average times, and error counts.

// All per-actor, per-method stats
Tracing stats
// => #{...}  (Dictionary keyed by actor/selector)

// Stats for a specific actor
Tracing statsFor: myCounter
// => #{...}

Trace Event Queries

When trace capture is enabled, individual call events are recorded to a ring buffer. These are available for querying even after the actor has stopped.

// All captured events (newest first)
Tracing traces
// => #(...)

// Events for a specific actor
Tracing tracesFor: myCounter
// => #(...)

// Events for a specific actor + method
Tracing tracesFor: myCounter selector: #increment
// => #(...)

Analysis Methods

Analysis methods compute rankings from aggregate stats. Each takes a limit parameter for the number of results.

// Top N methods by average duration (slowest first)
Tracing slowMethods: 10
// => #(...)

// Top N methods by call count (most called first)
Tracing hotMethods: 10
// => #(...)

// Top N methods by error + timeout rate
Tracing errorMethods: 5
// => #(...)

// Top N actors by message queue length (live snapshot)
Tracing bottlenecks: 5
// => #(...)

Live Health

Health methods provide point-in-time snapshots of actor and VM state.

// Per-actor health: queue depth, memory, reductions, status
Tracing healthFor: myCounter
// => #{queue_len => 0, memory => 1234, status => #waiting, ...}

// VM overview: schedulers, memory, process count, run queues
Tracing systemHealth
// => #{scheduler_count => 8, process_count => 42, ...}

Configuration

The trace event ring buffer has a configurable capacity (default 10,000 events). When full, the oldest events are evicted.

// Query current buffer capacity
Tracing maxEvents
// => 10000

// Set buffer capacity
Tracing maxEvents: 50000
// => nil

Typical Workflow

// 1. Create and exercise an actor
c := Counter spawn
10 timesRepeat: [c increment]

// 2. Check always-on aggregates (no enable needed)
Tracing statsFor: c
// => #{increment => #{count => 10, avg_us => 42, ...}, ...}

// 3. Enable trace capture for detailed events
Tracing enable

// 4. Exercise the actor some more
5 timesRepeat: [c increment]

// 5. Query detailed traces
Tracing tracesFor: c selector: #increment
// => #(#{selector => #increment, duration_us => 38, ...}, ...)

// 6. Find bottlenecks
Tracing slowMethods: 5
// => #(...)

// 7. Clean up
Tracing disable
Tracing clear

Propagated Context (Advanced)

Actor messages automatically carry a propagated context map across boundaries. This is invisible to Beamtalk code — no user action is required. The context enables distributed tracing when OpenTelemetry is added as a project dependency: parent/child span correlation across actor calls works immediately with no Beamtalk changes. See ADR 0069 for details.

Relationship to Logging (ADR 0064)

Tracing and Logger address complementary observability concerns:

ConcernAPIADR
What is happening — log messages, debug outputLogger info:, Beamtalk enableDebug:ADR 0064
How fast is it happening — timing, call counts, bottlenecksTracing stats, Tracing slowMethods:ADR 0069

Announcements — Typed Events (ADR 0093)

Announcements are Beamtalk's typed publish/subscribe substrate — a first-class Observer pattern. One part of a program (or the runtime itself) says "X happened" by announcing a typed event; other parts react by subscribing to that event's class. It is Pharo's Announcements + SystemAnnouncer, adapted to the BEAM: subscriptions are process-rooted and cleaned up by monitor, dispatch runs caller-side off concurrent ETS reads (no central bottleneck), and crashing handlers are isolated. See ADR 0093 for the full design.

The substrate lives in the core image (stdlib + runtime), not an optional package, because the system publishes through it — so it is always available, no dependency to add.

Events — subclass Announcement

An announcement is an immutable, typed payload describing a fact. Subclass Announcement and add field: slots for the event's data. Because Announcement is a Value, you get keyword-constructor ergonomics and field: accessors for free:

Announcement subclass: PriceChanged
  field: newPrice :: Number = nil

event := PriceChanged newPrice: 42
event newPrice    // => 42
event class       // => PriceChanged

Announcer — a per-instance dispatcher

Announcer new mints a fresh dispatcher handle (an opaque identity handle, like Pid). Subscribe with when:do:, publish with announce::

a := Announcer new

// Subscribe: returns a Subscription token. The handler block receives the event.
sub := a when: PriceChanged do: [:e | Transcript showLine: "now " ++ e newPrice printString]
sub class       // => Subscription
sub isActive    // => true

// Publish asynchronously (fire-and-forget). Every matching subscriber runs.
a announce: (PriceChanged newPrice: 42)    // prints "now 42"

// Stop listening.
sub unsubscribe
sub isActive    // => false

Subscription protocol

MessageMeaning
when: aClass do: aBlockEvaluate aBlock with the event on each announcement of aClass (or a subclass).
when: aClass send: sel to: receiverSend sel to receiver with the event as the sole argument.
when: aClass doOnce: aBlockDeliver exactly once, then auto-unsubscribe. Consumed atomically under concurrent announcers.
announce: anEventPublish asynchronously — returns immediately, handlers run fire-and-forget.
announceAndWait: anEventPublish synchronously — block until every handler completes (default 5 s timeout).
announceAndWait: anEvent timeout: msSynchronous publish with a custom per-handler timeout in milliseconds.
unsubscribe: receiverRemove every subscription receiver holds on this announcer.

Each when:… returns a distinct Subscription — a process may hold several to the same class, and re-subscribing never silently replaces an earlier one.

Synchronous vs asynchronous

announce: is asynchronous: it returns immediately and each handler runs in its own transient process, so a slow or crashing handler never blocks the publisher or its siblings. announceAndWait: is synchronous — it waits for every handler, with per-handler fault isolation and a timeout, so a wedged handler can never hang the caller:

a announceAndWait: (PriceChanged newPrice: 99)
// returns only after all handlers have run (or timed out)

A crashing handler is logged and isolated — other subscribers still run, and the announcer is unaffected:

a when: PriceChanged do: [:e | e boom]   // this handler will crash
a announce: (PriceChanged newPrice: 1)
// other subscribers still run; the crash is logged, not propagated

MRO matching — subscribe to a superclass

Dispatch walks the event's superclass chain at announce time, so subscribing to a superclass receives every subclass event. Delivery is de-duplicated per subscription:

Announcement subclass: UIEvent
UIEvent subclass: ButtonClicked
  field: buttonId :: String = ""

a when: UIEvent do: [:e | Transcript showLine: "ui event"]
a announce: (ButtonClicked buttonId: "submit")   // matches — "ui event"

SystemAnnouncer — watch the runtime live

SystemAnnouncer current is the singleton bus the runtime itself publishes onto. System facilities announce well-known discrete events; a tool subscribes once and filters by event class instead of wiring bespoke notification channels:

SystemAnnouncer current when: ActorSpawned do: [:e |
  Transcript showLine: e actorClass asString
]
Counter spawn    // the subscription fires: prints "Counter"

The system event classes (all Announcement subclasses):

EventFieldsAnnounced when
ActorSpawnedactorClass, pida Beamtalk actor starts
ActorStoppedactorClass, pid, reasonan actor stops
ClassLoadedclassNamea class is loaded into the image
ClassRemovedclassNamea class is removed
BindingChangedname, value, sessionIda workspace variable is assigned
FlushCompletedfilesWorkspace flush finishes writing source files
ObjectStateChangedpid, actorClass, changedSlotsa watched actor commits a state write (opt-in via beamtalk_object_watch)
SupervisionChildAdded(see ADR 0092)a supervised child is added
SupervisionChildCrashed(see ADR 0092)a supervised child crashes

SystemAnnouncer is async-only: announceAndWait: raises UnsupportedOperation, because the shared system bus can have many subscribers and a synchronous gather would be an unbounded process storm under rapid system events. Use announce: for the system bus, or a per-instance Announcer when you need synchronous dispatch.

[SystemAnnouncer current announceAndWait: anEvent] on: Error do: [:e | e kind]
// => unsupported_operation

Introspection — the third navigation sibling

The bus is navigable, alongside SystemNavigation (static classes) and ProcessNavigation (the live supervision tree). There are two levels.

Object-knows-itself — a live Announcer inspects its own subscriptions:

a subscriptions          // => a List of SubscriptionNode snapshots
a subscribersOf: PriceChanged   // => subscriptions to exactly PriceChanged
a subscriptionCount      // => total live subscriptions on the bus

Navigator-discovers-systemAnnouncementNavigation queries the graph:

AnnouncementNavigation default subscribersOf: ActorSpawned
AnnouncementNavigation default announcedClasses    // => distinct event types in use
AnnouncementNavigation of: anAnnouncer             // scope to one announcer

Each query returns a read-only snapshot of immutable SubscriptionNode value records (announcementClass, announcer, subscriber, handlerKind, once). To act on a subscription you cross back to the live Subscription token or the Announcer — the read-vs-mutate rule shared by all three navigators:

node := (a subscribersOf: PriceChanged) first
node announcementClass   // => PriceChanged
node subscriber          // => a Pid
node handlerKind         // => #do      (one of #do | #send | #doOnce)
node once                // => false

Announcements vs telemetry (ADR 0069)

Beamtalk has two event buses; reach for the right one. Measure with telemetry (spans, counters, durations — Actor Observability and Tracing); react with Announcements (typed domain events you subscribe to in app logic):

telemetry (ADR 0069)Announcements (ADR 0093)
PurposeMeasurement — spans, countersTyped domain events you react to
Event identitystring list [beamtalk, actor, dispatch]Announcement subclass (typed, MRO)
Deliverysync, fire-and-forgetasync or sync; isolated; monitored
Livenessnone (module-fun handlers)monitor-based per subscriber

Liveness note. A subscription is bound to the subscribing process and auto-removed when that process dies — no manual cleanup leaks. In the REPL each turn evaluates in a fresh worker process, so a subscription made at one prompt is gone by the next; subscribe, announce, and observe within a single expression (or from a long-lived actor) when you need a subscription to persist.

Per-instance isolation. Each Announcer new mints an independent dispatcher — subscriptions on announcer A are never matched by an announce: on announcer B, even for the same event class. SystemAnnouncer is the canonical multi-subscriber bus for system-wide events. Cross-node delivery to a connected node works; partition tolerance, replay, and the RecordingAnnouncer/telemetry-bridge extras live in the optional beamtalk-announcements package (BT-2454).


Namespace and Class Visibility

Within a package (and the REPL workspace), Beamtalk uses a flat namespace — all classes are visible to each other, with no import or export declarations. Across packages, dependencies are declared in beamtalk.toml and a dependency's classes are referenced by their short name, with qualified package@Class names available to resolve collisions (ADR 0070). The original v0.1 flat-global-namespace decision is ADR 0031 (superseded by ADR 0070).

How loading works

When you load a file — using :load path/to/file.bt or Workspace load: "path/to/file.bt":

  1. The file is compiled to a BEAM module named bt@class_name (ADR 0016)
  2. The module's on_load hook registers each class with the class registry
  3. If a class with the same name already exists (from a previous load), the new definition hot-reloads the class — existing actors continue to run with the new code on their next message
  4. The class records its source file path for future reload calls
// Via : shortcut
:load examples/counter.bt
// => Loaded: Counter

// Via native message send (works from compiled code too)
(Workspace load: "examples/counter.bt")

c := Counter spawn
c increment
// => 1

// Reload by class name (class-based, not file-based)
Counter reload
// => Counter

// Or via : shortcut (desugars to Counter reload)
:reload Counter
// => Counter

Class collision warnings

If two files from different packages define the same class name, the BEAM module atoms differ (e.g. bt@counter vs bt@other_pkg@counter), and Beamtalk emits a warning to alert you to the collision:

:load my_app/counter.bt
// => Loaded: Counter

:load other_pkg/counter.bt
// => Loaded: Counter
// warning: Class 'Counter' redefined (was bt@counter, now bt@other_pkg@counter)

The second definition wins — the class is hot-reloaded with the new implementation.

Naming conventions

To avoid collisions, use package-specific prefixes for classes that might conflict:

// ❌ Too generic — likely to collide with other packages
Object subclass: Logger ...

// ✓ Package-scoped name — unlikely to collide
Object subclass: MyAppLogger ...

Protected stdlib class names

Beamtalk's standard library classes (e.g., Integer, String, Array, Actor, Object, Boolean) are protected against redefinition in user code. There are two layers of protection:

Compile-time warning — fires for all stdlib class names (both stdlib/src/*.bt classes and runtime-only built-ins like Future):

// ❌ Compile-time warning: Class name `Integer` conflicts with a stdlib class.
//    Loading will fail because stdlib class names are protected.
Value subclass: Integer
  field: x = 0

Runtime load-time error — fires for fully-featured stdlib classes that are backed by stdlib/src/*.bt source files and loaded under the bt@stdlib@* module prefix. Attempting to load user code that redefines one of these returns a structured error:

:load my_integers.bt
// => Error: Cannot redefine stdlib class 'Integer'
//    Hint: Choose a different name. `Integer` is a protected stdlib class name.

If you need to customise stdlib behaviour, subclass instead of redefining:

// ✓ Subclass is fine
Integer subclass: SafeInteger
  divSafe: divisor =>
    divisor == 0 ifTrue: [^0]
    self / divisor

Namespace

Within a package, class names must be unique. Across packages, the namespace and dependency system (ADR 0070) makes each declared dependency's classes available by their short name; collisions between dependencies are a compile error, resolved with qualified package@Class names. See the Package Management guide for beamtalk.toml, dependencies, and qualified names. ADR 0031 (superseded by ADR 0070) records the original v0.1 flat-namespace decision.

Visibility and Access Control (ADR 0071)

Beamtalk classes and methods are public by default — visible to any package. The internal modifier restricts visibility to the defining package only. Enforcement is compile-time only, with zero runtime overhead.

Core principle: Visibility controls dependency, not knowledge. Internal classes and methods are fully visible to browsing, reflection, and documentation tools — you just cannot name them in compiled code from outside the package. The REPL's :browse, :doc, and :source commands work on internal items normally.

Class-Level internal

Mark a class as internal to hide it from other packages:

// Public (default) — available to any package
Actor subclass: HttpClient
  get: url => ...

// Internal — only visible within this package
internal Actor subclass: ConnectionPool
  state: connections = #{}

  acquire => ...
  release: conn => ...

Cross-package references to internal classes produce a compile error:

error[E0401]: Class 'ConnectionPool' is internal to package 'http' and cannot be referenced from 'my_app'
  --> src/app.bt:5:12
   |
 5 |     http@ConnectionPool new
   |          ^^^^^^^^^^^^^^
   |
   = note: 'ConnectionPool' is declared 'internal' in package 'http'

Method-Level internal

Mark individual methods as internal to hide implementation helpers on public classes:

Actor subclass: HttpClient
  state: config = #{}

  // Public — part of the package API
  get: url => ...
  post: url body: body => ...

  // Internal — implementation details, not callable from outside the package
  internal buildHeaders: request => ...
  internal retryWithBackoff: block maxAttempts: n => ...

When the compiler can determine the receiver type (via type annotations, literal class references, or type inference), cross-package sends to internal methods produce a compile error:

error[E0403]: Method 'buildHeaders:' is internal to package 'http' and cannot be called from 'my_app'
  --> src/app.bt:10:5
   |
10 |     client buildHeaders: req
   |            ^^^^^^^^^^^^^^

For untyped dynamic sends where the receiver type is unknown, no enforcement — the message send succeeds at runtime, consistent with the "visibility controls dependency, not knowledge" principle.

Combining Modifiers

internal composes with all existing class modifiers in any order:

// Internal abstract base — must be subclassed within the package
internal abstract Actor subclass: InternalAbstractBase
  state: label = "base"
  getLabel => self.label
  compute => self subclassResponsibility

// Internal sealed — cannot be subclassed, even within the package
internal sealed Actor subclass: InternalSealedCache
  state: data = 0
  store: val => self.data := val
  retrieve => self.data

// Internal typed — type annotations required on methods
internal typed Actor subclass: InternalTypedConfig
  state: setting :: Integer = 0
  getSetting -> Integer => self.setting
  setSetting: val :: Integer -> Integer => self.setting := val

// Modifier order is flexible
abstract internal Actor subclass: AlsoValid
  ...
CombinationValid?Notes
internal sealedYesPrevents subclassing even within the package
internal abstractYesInternal base class, must be subclassed within the package
internal typedYesInternal class with type annotation requirements
Stacking orderAnyinternal can appear anywhere in the modifier list

Library Author Patterns

A typical package exposes a few public classes and hides implementation details:

// json/src/parser.bt — Public API
Object subclass: Parser
  /// Parse a JSON string into a Beamtalk value.
  parse: input :: String => ...

// json/src/parser_state.bt — Internal implementation
internal Value subclass: ParserState
  field: position = 0
  field: buffer = ""

// json/src/token_buffer.bt — Internal implementation
internal Value subclass: TokenBuffer
  field: tokens = #()

Leaked visibility — if an internal class appears in the public signature of a public method, the compiler emits a hard error. This prevents accidentally exposing implementation types:

error[E0402]: Internal class 'TokenBuffer' appears in public signature of 'Parser >> tokenize:'
  --> src/parser.bt:12:3
   |
12 |   tokenize: input :: String -> TokenBuffer =>
   |                                ^^^^^^^^^^^
   |
   = note: 'TokenBuffer' is declared 'internal' — make it public, or change the return type

All methods on an internal class are effectively internal. The method-level modifier is only meaningful on public classes.

Metadata

Visibility is recorded in __beamtalk_meta/0 as a compile-time constant atom (public or internal). Tooling (LSP, REPL completions) uses this field to filter internal items from cross-package suggestions while still showing them in :browse and :doc output.

__beamtalk_meta/0 also carries toolchain provenance keys (beamtalk_version and otp_release as binary strings) when the module was compiled by a known toolchain (ADR 0098). Workspace attach and tooling use these to detect stale modules without re-reading the on-disk build stamp.

See ADR 0071 for the full design, including edge cases (subclassing, protocol conformance, extension methods, perform: dynamic sends) and the enforcement model.


Smalltalk + BEAM Mapping

Smalltalk/Newspeak ConceptBeamtalk/BEAM Mapping
Value objectValue subclass: with field: — plain Erlang map (no process)
ActorActor subclass: with state: — BEAM process (gen_server)
Module/utility classObject subclass: — no Beamtalk-managed data; class methods or runtime-backed instances
ClassModule + constructor function
Instance variable (immutable)field: — value map field
Instance variable (mutable)state: — gen_server state map field
Field access (self.x)maps:get('x', State)
Field write (self.x := v)maps:put('x', v, State) (Actor only; compile error on Value)
. message sendgen_server:call — sync, blocks for result
! message sendgen_server:cast — async fire-and-forget
BlockErlang fun (closure)
ImageRunning node(s)
WorkspaceConnected REPL to live node (Workspace singleton)
Class browserREPL introspection: Beamtalk allClasses, Beamtalk help: Class

Standard Library

76 classes implemented and tested. For detailed API documentation, see API Reference.

Core types:

ClassDescription
Integer, Float, NumberArbitrary precision arithmetic
String, Symbol, CharacterUTF-8 text (String is a subclass of Binary), interned symbols, Unicode characters
Boolean, True, FalseBoolean values with control flow
Nil (UndefinedObject)Null object pattern
BlockFirst-class closures

Collections:

ClassDescription
BinaryByte-level data — Collection subclass, parent of String (ADR 0086)
ArrayFixed-size indexed collection — O(log n) at:/at:put:, canonical value equality regardless of edit history (ADR 0090)
ListLinked list with fast prepend (#() syntax)
DictionaryKey-value map
SetUnordered unique elements
BagMultiset — allows duplicate elements, counts occurrences
TupleFixed-size heterogeneous container
QueueO(1) amortised FIFO queue
IntervalArithmetic sequence (1 to: 10, 1 to: 10 by: 2)
StreamLazy, closure-based sequences (ADR 0021)
EtsShared in-memory tables (BEAM ETS wrapper)

Actors and concurrency:

ClassDescription
ActorBase class for all actors (BEAM processes)
ServerAbstract Actor subclass for BEAM-level OTP interop (handleInfo:) (ADR 0065)
Supervisor, DynamicSupervisorOTP supervision trees (ADR 0059)
AtomicCounterLock-free shared counter
TimerPeriodic and one-shot timers (linked to calling process via spawn_link)
Pid, Reference, PortBEAM primitive types

Error handling:

ClassDescription
ResultTyped success/error for expected failures (ADR 0060)
Error, RuntimeError, TypeErrorError hierarchy
BEAMError, ExitError, ThrowErrorBEAM exception wrappers
ExceptionBase exception type

I/O and system:

ClassDescription
File, FileHandleFile system operations
Subprocess, ReactiveSubprocessOS process execution (ADR 0051)
OS, SystemPlatform info and system operations
Json, YamlData serialisation
RegexRegular expression matching
DateTime, TimeDate/time operations
RandomRandom number generation

Networking (in beamtalk-http):

ClassDescription
HTTPServer, HTTPClientHTTP server and client
HTTPRouter, HTTPRoute, HTTPRouteBuilderDeclarative HTTP routing
HTTPRequest, HTTPResponseRequest/response objects

Observability:

ClassDescription
TracingActor observability and performance telemetry — always-on aggregates + opt-in trace capture (ADR 0069)

Reflection and meta:

ClassDescription
Class, Metaclass, ClassBuilderClass reflection and dynamic class creation
BehaviourShared behaviour protocol
CompiledMethodMethod introspection
StackFrameStack trace inspection
TestCase, TestResult, TestRunnerBUnit test framework — TestCase is a Value subclass with functional setUp (ADR 0014)

Binary — Byte-Level Data

Binary is a sealed Collection subclass for byte-level data. String is a subclass of Binary that adds grapheme-aware text operations. The class hierarchy is Collection > Binary > String (ADR 0086).

On BEAM, Beamtalk binaries map directly to Erlang binaries (binary()). All strings are binaries at runtime — the type system uses the subclass relationship so that String is accepted wherever Binary is expected (e.g. File writeBinary:contents: accepts strings without type warnings).

// Construction
bin := Binary fromBytes: #(104, 101, 108, 108, 111)
bin := Binary fromIolist: #("hello", " ", "world")

// Byte access (1-based, Collection protocol)
bin := Binary fromBytes: #(104, 101, 108)
bin at: 1                    // => "h" (grapheme — runtime dispatches via String)
bin size                     // => 3

// Byte access (0-based, Erlang-compatible)
bin byteAt: 0                // => 104 (byte value)
bin byteSize                 // => 3 (byte count)

// Zero-copy slicing
bin := Binary fromBytes: #(1, 2, 3, 4, 5)
bin part: 1 size: 3          // => Binary (bytes 2, 3, 4)

// Concatenation
a := Binary fromBytes: #(1, 2)
b := Binary fromBytes: #(3, 4)
a concat: b                  // => Binary (1, 2, 3, 4)

// Byte list conversion
bin toBytes                   // => #(1, 2, 3, 4, 5)
Binary fromBytes: #(65, 66)  // => Binary

// UTF-8 decoding (Binary → String)
(Binary fromBytes: #(104, 101, 108, 108, 111)) asString           // => "hello"
(Binary fromBytes: #(104, 101, 108, 108, 111)) asStringUnchecked  // => "hello"

// Serialization (class methods)
etf := Binary serialize: #(1, 2, 3)
Binary deserialize: etf               // => #(1, 2, 3)
Binary deserializeWithUsed: etf       // => #(value, bytesConsumed)

// Collection protocol — Binary is a collection of bytes
bin := Binary fromBytes: #(65, 66, 67, 68, 69)
bin collect: [:ch | ch]       // => "ABCDE" (via String species)
bin select: [:ch | ch /= "C"]  // => "ABDE"
bin includes: "B"             // => true
bin isEmpty                   // => false

Method override table (Binary vs String):

MethodOn BinaryOn String
at: indexgrapheme (1-based, via String at runtime)grapheme (1-based)
sizeelement count (via String at runtime)grapheme count
byteAt: offsetbyte value (0-based)inherited — byte value (0-based)
byteSizebyte countinherited — byte count
do: blockiterate elements (via String at runtime)iterate graphemes
part: offset size: nbyte-level slice, returns Binaryinherited — byte-level slice, returns Binary
concat:byte concatenation, returns Binaryinherited — byte concatenation, returns Binary
asStringUTF-8 validation, returns Stringno-op, returns self
asStringUncheckedunchecked cast to Stringno-op, returns self

Interval — Arithmetic Sequences

An Interval represents an arithmetic sequence of integers without materialising a list. Create one with to: or to:by: on any Integer:

1 to: 10                    // => (1 to: 10) — 10 elements: 1, 2, ..., 10
1 to: 10 by: 2             // => (1 to: 10 by: 2) — 5 elements: 1, 3, 5, 7, 9
10 to: 1 by: -1            // => (10 to: 1 by: -1) — 10 elements: 10, 9, ..., 1

(1 to: 10) size            // => 10
(1 to: 10) first           // => 1
(1 to: 10) last            // => 10
(1 to: 10) includes: 5     // => true

// Interval supports the full Collection protocol:
(1 to: 5) inject: 0 into: [:sum :x | sum + x]   // => 15
(1 to: 10) select: [:x | x isEven]              // => #(2, 4, 6, 8, 10)
(1 to: 5) collect: [:x | x * x]                 // => #(1, 4, 9, 16, 25)

Bag(E) — Multisets

Bag(E) is an unordered collection that allows duplicate elements. It is backed by a Dictionary(E, Integer) mapping elements to occurrence counts. Like other collections, Bag is immutable — mutating operations return a new Bag.

Bag new class                           // => Bag
(Bag new add: 1) occurrencesOf: 1      // => 1

b := Bag withAll: #(1, 2, 1, 3, 1)
b size                                  // => 5 (total occurrences)
b occurrencesOf: 1                      // => 3
b includes: 2                           // => true
b includes: 9                           // => false

// Bag mutating operations return new Bags:
b2 := b add: 2                         // one more occurrence of 2
b2 occurrencesOf: 2                    // => 2
b3 := b add: 4 withCount: 5           // add 5 occurrences of 4
b4 := b remove: 1                      // remove one occurrence of 1
b4 occurrencesOf: 1                    // => 2

// do: iterates each element once per occurrence:
(Bag withAll: #(1, 1, 2)) inject: 0 into: [:sum :x | sum + x]  // => 4

Stream — Lazy Pipelines

Stream is Beamtalk's universal interface for sequential data. A single, sealed, closure-based type that unifies collection processing, file I/O, and generators under one protocol.

Operations are either lazy (return a new Stream) or terminal (force evaluation and return a result). Nothing computes until a terminal operation pulls elements through.

Constructors

// Infinite stream starting from a value, incrementing by 1
Stream from: 1                     // 1, 2, 3, 4, ...

// Infinite stream with custom step function
Stream from: 1 by: [:n | n * 2]   // 1, 2, 4, 8, ...

// Stream from a collection (List, String, Set)
Stream on: #(1, 2, 3)             // wraps collection lazily

// Collection shorthand — List, String, and Set respond to `stream`
#(1, 2, 3) stream                  // same as Stream on: #(1, 2, 3)
"hello" stream                     // Stream over characters
(Set new add: 1) stream            // Stream over set elements

// Dictionary iteration — use doWithKey: instead of stream
#{#a => 1} doWithKey: [:k :v | Transcript show: k]

// File streaming — lazy, constant memory
File lines: "data.csv"            // Stream of lines
File open: "data.csv" do: [:handle |
  handle lines take: 10           // block-scoped handle
]

Lazy Operations

Lazy operations return a new Stream without evaluating anything:

MethodDescriptionExample
select:Filter elements matching predicates select: [:n | n > 2]
collect:Transform each elements collect: [:n | n * 10]
reject:Exclude elements matching predicates reject: [:n | n isEven]
drop:Skip first N elementss drop: 5
// Build a pipeline — nothing computes yet
s := Stream from: 1
s := s select: [:n | n isEven]
s := s collect: [:n | n * n]
// s is still a Stream — no values computed

Terminal Operations

Terminal operations force evaluation and return a concrete result:

MethodDescriptionExample
take:First N elements as Lists take: 5[2,4,6,8,10]
asListMaterialize entire stream to Lists asList[1,2,3]
do:Iterate with side effects, return nils do: [:n | Transcript show: n]
inject:into:Fold/reduce with initial values inject: 0 into: [:sum :n | sum + n]
detect:First matching element, or nils detect: [:n | n > 10]
anySatisfy:True if any element matchess anySatisfy: [:n | n > 2]
allSatisfy:True if all elements matchs allSatisfy: [:n | n > 0]
// Terminal forces computation through the pipeline
((Stream from: 1) select: [:n | n isEven]) take: 5
// => [2,4,6,8,10]

(Stream on: #(1, 2, 3, 4)) inject: 0 into: [:sum :n | sum + n]
// => 10

printString — Pipeline Inspection

Stream's printString shows pipeline structure, not values — keeping the REPL inspectable even for lazy data:

(Stream from: 1) printString
// => Stream(from: 1)

(Stream on: #(1, 2, 3)) printString
// => Stream(on: [...])

((Stream from: 1) select: [:n | n isEven]) printString
// => Stream(from: 1) | select: [...]

Eager vs Lazy — The Boundary

Collections keep their eager methods (select:, collect:, do:, etc.) for simple cases. The stream message is the explicit opt-in to lazy evaluation:

// Eager — List methods return a List immediately
#(1, 2, 3, 4, 5) select: [:n | n > 2]
// => [3,4,5]  (a List)

// Lazy — stream methods return a Stream (unevaluated)
(#(1, 2, 3, 4, 5) stream) select: [:n | n > 2]
// => Stream  (unevaluated — call asList or take: to materialize)

The receiver makes the boundary visible: you always know whether you're working with a Collection (eager) or a Stream (lazy).

File Streaming

File lines: returns a lazy Stream of lines — constant memory, safe for large files:

// Read lines lazily
(File lines: "data.csv") do: [:line | Transcript show: line]

// Pipeline composition
(File lines: "app.log") select: [:l | l includes: "ERROR"]

// Block-scoped handle for explicit lifecycle control
File open: "data.csv" do: [:handle |
  handle lines take: 10
]
// handle closed automatically when block exits

Cross-process constraint: File-backed Streams must be consumed by the same process that created them (BEAM file handles are process-local). To pass file data to an actor, materialize first: (File lines: "data.csv") take: 100 returns a List that can be sent safely. Collection-backed Streams have no such restriction.

File I/O and Directory Operations

File provides class methods for reading, writing, and managing files and directories. Both relative and absolute paths are accepted; security relies on OS-level permissions (ADR 0063).

MethodReturnsDescription
File exists: pathBooleanTest if a file exists
File readAll: pathStringRead entire file contents
File writeAll: path contents: textnilWrite text to file (create/overwrite)
File isFile: pathBooleanTest if path is a regular file
File isDirectory: pathBooleanTest if path is a directory
File mkdir: pathnilCreate a directory (parent must exist)
File mkdirAll: pathnilCreate directory and all parents
File listDirectory: pathListList entry names in a directory
File delete: pathnilDelete a file or empty directory
File deleteAll: pathnilRecursively delete a directory tree
File rename: from to: tonilRename/move a file or directory
File absolutePath: pathStringResolve path to absolute
File tempDirectoryStringOS temporary directory path
File writeAll: "output.txt" contents: "hello"
File readAll: "output.txt"              // => "hello"
File mkdirAll: "target/data/logs"
File listDirectory: "target/data"       // => ["logs"]
File rename: "output.txt" to: "target/data/output.txt"
File delete: "target/data/output.txt"
File deleteAll: "target/data"

Side-Effect Timing ⚠️

Side effects in lazy pipelines run at terminal time, not at definition time:

// This prints NOTHING — the pipeline is just a recipe
s := (Stream on: #(1, 2, 3)) collect: [:n | Transcript show: n. n * 2]

// This is when printing actually happens
s asList
// Transcript shows: 1, 2, 3
// => [2,4,6]

If you need immediate side effects, use the eager collection method (List do:) or call a terminal operation right away.

Diagnostic Suppression (@expect)

The @expect directive suppresses a specific category of diagnostic on the immediately following expression. It is a first-class language construct (not a comment) parsed as an expression in any expression list.

@expect dnu
someObject unknownMessage   // DNU hint suppressed

@expect type
42 + "hello"                // type warning suppressed

@expect type
42 unknownMethod            // also suppresses method-not-found (DNU) hints

@expect unused
x := computeSomething       // unused-variable warning suppressed

@expect all
anything                    // any diagnostic suppressed (discouraged — use a specific category)

Suppression categories:

CategorySuppresses
dnuDoes-not-understand hints
typeType mismatch warnings and method-not-found (DNU) hints
unusedUnused variable warnings
type_annotationMissing or redundant type annotation warnings in typed classes
inheritanceSealed-class/sealed-method constraint errors
allAny diagnostic on the following expression (discouraged — use a specific category)

@expect type for method-not-found diagnostics:

@expect type suppresses DNU hints unconditionally. A common use-case is type-erasure boundaries where Result.unwrap (or any other method returning Object) causes the type system to lose track of the concrete type:

// Result.unwrap returns Object — the type system cannot verify 'size' exists
@expect type
self assert: someResult unwrap size equals: 10

This is preferred over @expect dnu at type-erasure boundaries because it communicates why the diagnostic appears: a type-system limitation, not intentional dynamic dispatch.

Declaration-level @expect: In addition to suppressing diagnostics on expressions, @expect can be placed before state:/field: declarations and method definitions inside a class body. This suppresses diagnostics that fire on the declaration itself:

typed Object subclass: Collection(E)
  @expect type
  first => (Erlang erlang) hd: self asErlangList   // polymorphic return — suppress missing-annotation warning

Declaration-level @expect supports the same categories and stale-directive rules as expression-level @expect.

Unknown categories are parse errors: Writing an unknown category (e.g. @expect selfcapture) is rejected at parse time with an error listing the valid names. This prevents typos from silently suppressing nothing.

Stale directives: If @expect does not suppress any diagnostic (because no matching diagnostic exists on the following expression or declaration), the compiler emits a warning to prevent directives from silently becoming out of date.

@expect works inside method bodies, inside block bodies (e.g., ifTrue: [...], collect: [...], whileTrue: [...]), on declarations in class definitions, and at module scope (BT-2010).

Pragma Annotations (@primitive and @intrinsic)

The standard library uses pragma annotations to declare methods whose implementations are provided by the compiler or runtime rather than written in Beamtalk code.

There are two pragma forms:

PragmaSyntaxPurpose
@primitiveBare (no selector)Selector-based dispatch, selector inferred from the enclosing method. Equivalent to @primitive 'sel' where sel is the method's own selector. Preferred form.
@primitive 'selector'Quoted selectorSame selector-based dispatch, but with an explicit selector override — used when the runtime function name differs from the method selector (e.g. class-side aliases like signal => @primitive 'classSignal').
@intrinsic nameUnquoted identifierStructural intrinsic — the compiler generates specialized code inline. Used for spawning, block evaluation, control flow, reflection, etc.

Both forms are semantically equivalent at the compiler level (they produce the same AST node), but the naming convention distinguishes their intent:

@primitive (bare or quoted) — runtime-dispatched method implementations. A bare @primitive infers its selector from the method, so the explicit string is only needed for genuine renames:

// In stdlib/src/Integer.bt — bare form, selector inferred ('+' and 'asString')
+ other => @primitive
asString => @primitive

// In stdlib/src/Exception.bt — explicit override (method 'signal' → runtime 'classSignal')
class signal => @primitive 'classSignal'

@intrinsic (unquoted) — compiler structural intrinsics:

// In stdlib/src/Block.bt
value => @intrinsic blockValue
whileTrue: bodyBlock => @intrinsic whileTrue

// In stdlib/src/Actor.bt
sealed spawn => @intrinsic actorSpawn

// In stdlib/src/Object.bt
new => @intrinsic basicNew
hash => @intrinsic hash

The full list of structural intrinsics: actorSpawn, actorSpawnWith, blockValue, blockValue1blockValue3, whileTrue, whileFalse, repeat, onDo, ensure, timesRepeat, toDo, toByDo, basicNew, basicNewWith, hash, respondsTo, fieldNames, fieldAt, fieldAtPut, dynamicSend, dynamicSendWithArgs, error.

Relationship to native: (ADR 0101). @primitive and @intrinsic cover native BEAM value types and compiler substrate. A third mechanism, the class-level native: declaration with => self delegate bodies, covers whole-class delegation to a single Erlang module (a stateless Object such as Stream, or an Actor gen_server). Pick by what the method needs: guarded dispatch + the open-world extension registry → @primitive; the dispatch act itself (==, class, perform:, actor lifecycle) → @intrinsic; pure pass-through to one module → native:. See native: for stateless Objects.


Ets — Shared In-Memory Tables

Ets wraps OTP ets tables for sharing mutable state between actors without message-passing overhead. Tables are named and public by default, so any process can read and write them.

Creating a Table

// Create a named public table
cache := Ets new: #myCache type: #set

// Table types
Ets new: #t1 type: #set          // one entry per key (unordered)
Ets new: #t2 type: #orderedSet   // one entry per key (sorted keys)
Ets new: #t3 type: #bag          // multiple entries per key, values differ
Ets new: #t4 type: #duplicateBag // multiple identical entries per key

// Look up an existing named table from another actor
cache := Ets named: #myCache

Reading and Writing

cache at: "key" put: 42         // insert or update
cache at: "key"                 // => 42
cache at: "missing"             // => nil (not an error)
cache at: "missing" ifAbsent: [0]  // => 0 (evaluate block when absent)
cache includesKey: "key"        // => true
cache includesKey: "other"      // => false

Other Operations

cache size                      // => number of entries
cache keys                      // => List of all keys (order unspecified for #set)
cache removeKey: "key"          // delete entry; no-op if key is absent
cache delete                    // destroy the table; frees all memory

Cross-Actor Sharing

ETS tables are process-owned but publicly readable and writable. Create a named table in one actor, then retrieve it from another:

// Actor A — create the table
cache := Ets new: #requestCache type: #set
cache at: "token" put: "abc123"

// Actor B — retrieve by name
cache := Ets named: #requestCache
cache at: "token"               // => "abc123"

Table Lifecycle

The owning process (the one that called Ets new:type:) holds the table. When that process terminates, the table is automatically deleted by the OTP runtime. Only the owning actor may call delete on the table — other actors should request deletion by messaging the owner. Call delete explicitly in the owner to release memory before the process exits.


Queue — O(1) Amortised FIFO Queue

Queue wraps Erlang's :queue module, providing O(1) amortised enqueue and dequeue. It is a value type: each mutation returns a new Queue rather than modifying the receiver. Use Queue instead of List when O(1) amortised head/tail access matters.

Creating a Queue

q := Queue new        // empty queue

Enqueueing and Dequeueing

q2 := q enqueue: 1
q3 := q2 enqueue: 2
result := q3 dequeue         // => {1, <Queue with [2]>}
value := result at: 1       // => 1
rest := result at: 2        // => Queue containing [2]

dequeue returns a Tuple of {value, newQueue}. Raises empty_queue if the queue is empty.

Other Operations

q peek                        // => front element without removing (raises empty_queue if empty)
q isEmpty                     // => true or false
q size                        // => number of elements (O(n))

AtomicCounter — Lock-Free Shared Counter

AtomicCounter provides a named integer counter backed by ets:update_counter. Increments and decrements are atomic and safe for concurrent access from multiple actors without message-passing overhead.

Creating a Counter

c := AtomicCounter new: #requests     // create named counter starting at 0
c := AtomicCounter named: #requests   // look up existing counter from another actor

Atomic Operations

c increment                    // atomically add 1, return new value
c incrementBy: 5               // atomically add N, return new value
c decrement                    // atomically subtract 1, return new value
c decrementBy: 3               // atomically subtract N, return new value
c value                        // instantaneous read; may observe a stale value under concurrent updates or reset
c reset                        // set to 0, return nil (not atomic with concurrent increments/decrements)
c delete                       // destroy the backing ETS table

Cross-Actor Sharing

// Actor A — create
c := AtomicCounter new: #hits

// Actor B — look up by name and increment
c := AtomicCounter named: #hits
c increment

Counter Lifecycle

Each AtomicCounter owns its own named ETS table. When delete is called, the table is destroyed and the counter is stale. Any subsequent operations on a deleted counter raise stale_counter.


TestCase — BUnit Testing

TestCase is a Value subclass: — setUp returns a new self with fields set (functional pattern), matching Erlang's EUnit and Elixir's ExUnit. The test runner threads the setUp return value to each test method. Each test gets a fresh copy, so tests cannot corrupt state for each other.

TestCase subclass: CounterTest
  field: counter = nil

  setUp => self withCounter: (Counter spawn)

  testIncrement =>
    self.counter increment.
    self assert: (self.counter getValue) equals: 1

For multiple fields, with*: calls chain via cascades:

TestCase subclass: IntegrationTest
  field: db = nil
  field: cache = nil

  setUp =>
    (self withDb: (DB connect)) withCache: (Cache spawn)

  testLookup =>
    self.cache at: "key" put: "value".
    self assert: (self.cache at: "key") equals: "value"

Key points:

Suite-Level Setup — setUpOnce / tearDownOnce

For expensive fixtures shared across all tests in a class (database connections, ETS tables, supervisor trees), override setUpOnce and tearDownOnce. These run once per class, not once per test.

setUpOnce returns a fixture value accessible in each test method via self suiteFixture:

TestCase subclass: DatabaseTest
  field: conn = nil

  setUpOnce => Database connect: "test_db"
  tearDownOnce => self suiteFixture close

  setUp => self withConn: self suiteFixture

  testQuery =>
    result := self.conn query: "SELECT 1"
    self assert: result equals: 1

  testInsert =>
    self.conn execute: "INSERT INTO t VALUES (1)"
    self assert: (self.conn query: "SELECT count(*) FROM t") equals: 1

Lifecycle order: setUpOnce → (setUp → test → tearDown)* → tearDownOnce

Key points:

Parallel Test Execution

By default, beamtalk test runs test classes concurrently (--jobs 0 = auto, uses BEAM scheduler count). Each class runs in its own process.

Test classes that touch global state (persistent_term, registered process names, global ETS tables) must opt out by overriding serial:

TestCase subclass: TracingTest

  class serial -> Boolean => true

  setUp => Tracing clear. Tracing disable
  testEnable => self assert: Tracing enable equals: nil

Serial classes run alone after all concurrent classes complete.

Use --jobs 1 for fully sequential execution, or --jobs N to limit concurrency.

From the REPL, use TestRunner runAll: maxJobs to control concurrency programmatically:

TestRunner runAll: 4        // run up to 4 classes concurrently
TestRunner runAll            // sequential (default from REPL)

See Tooling for CLI tools, REPL, VS Code extension, and testing framework.