Beamtalk Language Features

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

Status: v0.3.1 — implemented features are stable, including generics, protocols, union types, and control flow narrowing. 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

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.

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 (abstract)
       │    │    └─ Set, Bag, 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

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

Message Sends

// Unary message
counter increment

// Binary message (standard math precedence: 2 + 3 * 4 = 14)
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 on and/or: These 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.

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
Object subclass: File
  class readAll: path :: String -> String =>
    (Erlang beamtalk_file) readAll: path

Keyword mapping: Beamtalk keyword selectors map to Erlang function names by joining with underscores. Erlang maps merge: a with: b calls maps:merge_with(A, B). 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:

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: Errors from Erlang calls are wrapped as BEAMError, ExitError, or ThrowError — catchable with on:do::

[Erlang erlang error: #badarg] on: BEAMError do: [:e | e message]
// => "badarg"

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 -> 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

Current Semantics


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 protocols
=> #(Printable, Comparable)

> Printable requiredMethods
=> #(#asString, #printString)

> Printable conformingClasses
=> #(Integer, Float, String, Boolean, Symbol, Array, ...)

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)

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 ("a ClassName") 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})"

The related display methods on Object are:

MethodBehaviour
asStringHuman-readable string (override per class)
printStringDeveloper-readable string (REPL/inspector uses this)
displayStringUser-facing display; defaults to printString
inspectInspection; defaults to 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.


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: UndefinedObject does not respond to 'size'

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

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

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: [^...] ifFalse: [...]x is non-nil in false blockFalse block

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

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.

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:

app := WebApp supervise
// => #Supervisor<WebApp,_>

// Idempotent — returns the already-running instance if called again
app2 := WebApp supervise
// => #Supervisor<WebApp,_>

// Find the running instance by class name (no reference needed)
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    // => #Actor<DatabasePool,_>  (running child instance)
app terminate: HTTPRouter  // gracefully stop a single child
app stop                   // stop the supervisor and all children

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

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

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
// => #DynamicSupervisor<WorkerPool,_>

// Start children dynamically
w1 := pool startChild          // => #Actor<Worker,_>
w2 := pool startChild          // => #Actor<Worker,_>
pool count                     // => 2

// Terminate a specific child
pool terminateChild: w1        // => nil
pool count                     // => 1

// Stop the whole pool
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
root count                          // => 3
root which: DatabaseSupervisor      // => #Supervisor<DatabaseSupervisor,_>

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

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]

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

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).


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 class names (symbols)
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.3.1"

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
classesListAll loaded user classes (those with a recorded source file)
testClassesListLoaded classes that inherit from TestCase
globalsDictionaryProject namespace: singletons + loaded user classes
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
(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.

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

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

Namespace and Class Visibility

Beamtalk uses a flat global namespace (ADR 0031). All classes are globally visible — no import, export, or namespace declaration is needed or available.

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

Class names must be globally unique. A package namespace and dependency system is designed (ADR 0070) but not yet implemented. See Known Limitations and ADR 0031 for details.

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.

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 0069)
ArrayFixed-size indexed collection
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 0069).

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 — Multisets

A Bag is an unordered collection that allows duplicate elements. It is backed by a Dictionary 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
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:

Actor subclass: Orchestrator
  @expect type
  state: deps :: OrchestratorDeps    // factory-required, no sensible default — suppress uninitialized warning

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, on declarations in class definitions, and at module scope.

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
@primitive 'selector'Quoted selectorSelector-based dispatch — routes through runtime dispatch modules (beamtalk_primitive.erl → type-specific modules). Used for arithmetic, comparison, string operations, etc.
@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 (quoted) — runtime-dispatched method implementations:

// In stdlib/src/Integer.bt
+ other => @primitive '+'
asString => @primitive 'asString'

@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.


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.