ADR 0025: Gradual Typing and Protocols

Status

Accepted (2026-02-15) — Phases 1 and 2 complete; Phase 3 (Protocols) planned

Context

Beamtalk is a dynamically-typed, Smalltalk-inspired language targeting the BEAM VM. Today, all type errors are caught at runtime — sending an unknown message crashes with doesNotUnderstand:, and passing the wrong type to a primitive crashes with a BEAM exception.

However, the compiler already knows substantial type information at compile time:

Despite all this infrastructure, the compiler discards type information — it doesn't check message sends, doesn't infer types from assignments, and doesn't generate Erlang -spec attributes. Every typo and type mismatch is a runtime surprise.

Meanwhile, the IDE tooling vision (ADR 0024) depends on type information for quality completions, hover, and diagnostics. Without types, the language service can only offer syntactic suggestions.

The Core Tension

Beamtalk must serve two masters:

  1. Interactive-first development — REPL exploration, live coding, hot reload. Types must never block experimentation.
  2. Production safety — Catch bugs before deployment. Types should catch what they can without impeding development velocity.

The question is not whether to add types, but how — in a way that preserves Smalltalk's dynamism while providing TypeScript-level tooling quality.

Decision

Beamtalk adopts gradual typing with structural protocols, implemented in four phases:

Design Principles

  1. Types are always optional — Untyped code compiles and runs exactly as today. No existing code breaks.
  2. Warnings, not errors — Type mismatches always produce compiler warnings, never errors. Code always compiles. There is no "strict mode" — the language has one behavior everywhere.
  3. Compile-time only — Type checking happens entirely at compile time. No runtime cost, no type tags, no overhead.
  4. Structural, not nominal — Type compatibility is determined by what messages an object responds to (its "shape"), not its class hierarchy. A Duck and a Person that both have walk are both valid where "something that walks" is expected.
  5. Infer first, annotate for precision — The compiler infers what it can from class definitions and assignments. Annotations add precision where inference falls short.
  6. BEAM integration — Type annotations generate Dialyzer -spec attributes in Core Erlang, giving Erlang-level type checking for free at the BEAM boundary.
  7. Per-class typing contracts — Classes can opt into thorough type checking with the typed modifier. This is a contract with the compiler, not a language mode — semantics are identical.

Phase 1: Type Inference from Known Classes (Zero New Syntax)

The compiler uses existing class definitions to infer types and check message sends.

Actor subclass: Counter
  state: value = 0
  increment => self.value := self.value + 1
  getValue => self.value

c := Counter spawn
c increment              // ✅ Counter has 'increment'
c getValue               // ✅ Counter has 'getValue'
c decrement              // ⚠️ Warning: Counter does not respond to 'decrement'
                         //    Hint: Did you mean 'increment'?

3 + "hello"              // ⚠️ Warning: Integer '+' expects Number, got String

Inference rules:

Ambiguous control flow defaults to Dynamic:

// Control flow producing different types → Dynamic (no checking)
x := condition ifTrue: [Counter spawn] ifFalse: [Timer spawn]
x increment    // No warning — x is Dynamic (could be either type)

// Single-branch or same-type → inferred
y := condition ifTrue: [42] ifFalse: [0]
y + 1          // ✅ y is Integer (both branches return Integer)

Phase 4 adds union types and type narrowing for control flow. In Phase 1, the rule is simple: if the compiler can't determine a single type, it falls back to Dynamic and stops checking. No false positives from conservative inference.

Block/closure typing — inferred from context:

Blocks are ubiquitous in beamtalk (control flow, iteration, callbacks). The type checker infers block parameter and return types from the message they're passed to:

// collect: expects a block that takes an element and returns a value
// The compiler knows List>>collect: takes Block<E, R>
items collect: [:x | x + 1]     // x inferred as element type, result as Integer

// Unknown context → block parameters are Dynamic
myBlock := [:x | x + 1]         // x is Dynamic (no context yet)
items collect: myBlock           // Now context exists, but too late to check x

// Literal blocks in known positions are fully checked
3 timesRepeat: [counter increment]  // ✅ counter checked as Counter

The key insight: literal blocks at message-send sites can be typed from context (the compiler knows what collect: expects). Stored blocks assigned to variables lose context and become Dynamic. This matches the existing control-flow vs stored-block distinction (ADR for block semantics).

What the compiler already knows:

Boundaries of inference:

Phase 2: Optional Type Annotations

Note: The state and parameter type syntax described in this section (using the : Type separator, e.g. state: balance: Integer and amount: Integer) has been superseded by ADR 0053, which adopted :: as the type annotation delimiter. The correct syntax is field :: Type for state declarations and param :: Type for parameters (for example, state: balance :: Integer = 0 and amount :: Integer), not field: Type / param: Type. The examples below are preserved for historical context; all current code and documentation use ::.

Developers can annotate state fields, method parameters, and return types for extra precision.

// Phase 2 syntax (superseded — see ADR 0053 for current :: syntax)
Actor subclass: BankAccount
  state: balance: Integer = 0           // State type annotation
  state: owner: String                  // Required — no default

  deposit: amount: Integer =>           // Parameter type annotation
    self.balance := self.balance + amount

  getBalance -> Integer =>              // Return type annotation
    self.balance

  transfer: amount: Integer to: target: BankAccount =>
    self withdraw: amount
    target deposit: amount

Parameter type syntax (historical Phase 2 design): Type followed the parameter name with a : separator, mirroring the state declaration syntax used at that time. This legacy syntax was later superseded by :: (see ADR 0053) to avoid visual ambiguity with keyword selector colons.

// Phase 2 syntax (superseded — current syntax uses ::)
// Keyword message with typed parameters
deposit: amount: Integer =>  ...

// Multiple keywords, each typed
transfer: amount: Integer to: target: BankAccount => ...

// Unary — no parameters, just return type
getBalance -> Integer => ...

// Binary — type on operand
+ other: Number -> Number => ...

AST change required: MethodDefinition.parameters currently uses Vec<Identifier> (names only). Phase 2 must change this to Vec<ParameterDefinition> with both name and optional TypeAnnotation, mirroring how StateDeclaration already stores type_annotation: Option<TypeAnnotation>.

Return type syntax: -> Type before the => body separator. The AST field (MethodDefinition.return_type: Option<TypeAnnotation>) already exists but the parser does not yet populate it.

Codegen: Annotations generate Erlang -spec entries in Core Erlang module attributes. The spec is encoded as an abstract type representation in the module attributes list — the same format Dialyzer reads from .beam debug info:

% Generated Core Erlang module attributes (abstract spec representation)
attributes ['spec' = [{'deposit', {type, fun, [{type, product, [{type, integer, []}]}, 
                                                {type, integer, []}]}}],
            'behaviour' = ['gen_server']]

This enables Dialyzer to perform additional checking at the BEAM level — two layers of type safety.

Phase 2b: Typed Classes (Per-Class Typing Contract)

The typed modifier declares that a class opts into thorough type checking. This is purely a compile-time concept — the generated BEAM bytecode is identical whether a class is typed or not. Same gen_server, same dispatch, same runtime behavior. typed is erased during compilation, just like all other type information.

// Regular class — inference warnings only
Actor subclass: Counter
  state: value = 0
  increment => self.value := self.value + 1

// Typed class — compiler checks everything
typed Actor subclass: BankAccount
  state: balance :: Integer = 0
  state: owner :: String

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

  withdraw: amount =>                    // ⚠️ Warning: untyped parameter in typed class
    self.balance := self.balance - amount

  getBalance -> Integer => self.balance

What typed means:

What typed does NOT change:

Inheritance: typed is sticky — subclasses of a typed class are automatically typed:

typed Actor subclass: BankAccount
  state: balance :: Integer = 0
  ...

// SavingsAccount is automatically typed (inherits from typed class)
BankAccount subclass: SavingsAccount
  state: interestRate :: Float = 0.05   // ✅ Typed — compiler checks

  accrue => ...                          // ⚠️ Warning: missing return type in typed class

This parallels how sealed is inherited — if the base class makes a contract, subclasses honor it.

Escape hatch for false positives:

When the type checker is wrong — and it will be — developers need a way to say "trust me." The asType: message casts a value to a known type:

typed Actor subclass: MessageRouter
  state: handlers :: Dictionary = Dictionary new

  dispatch: message =>
    handler := (self.handlers at: message class) asType: Handler  // "I know this is a Handler"
    handler handle: message                                        // No warning — handler is Handler

asType: is a compile-time type assertion — it tells the type checker to treat the value as the given type. It generates no runtime code (type erasure). If the assertion is wrong, the runtime error is the same as today. (Note: as: was considered but conflicts with the proposed workspace registration API in ADR 0004.)

For inline suppression of specific warnings, a comment directive works:

  processRaw: data =>
    // @type: Dynamic
    result := self dangerousReflectiveThing: data   // No type checking on this line
    result

Use cases:

Stdlib note: Standard library classes (stdlib/src/*.bt) should be fully annotated (parameter types, return types) in Phase 2 to generate complete Dialyzer specs and enable precise LSP completions. However, they do not need the typed modifier — their method bodies are primitive-dispatched (@primitive), so there is no Beamtalk logic for the type checker to verify. Annotations are the valuable part; typed is for classes where the compiler can verify that the implementation matches the contract.

Phase 3: Protocols (Structural Typing)

Protocols define named sets of messages that types must support.

// Protocol definition — a named "shape"
Protocol define: Printable
  requiring: [asString]

Protocol define: Comparable
  requiring: [<, >, <=, >=]

Protocol define: Collection
  requiring: [size, do:, collect:, select:]

Note on naming convention: This ADR uses bare identifiers for protocol names and required methods (Printable, asString). The existing language spec (beamtalk-language-features.md) uses symbol syntax (#Stringable, #asString). The final surface syntax for protocol definitions will be resolved during Phase 3 implementation — bare identifiers are used here for readability.

Protocol type syntax: Protocol types use angle brackets (<ProtocolName>) to distinguish them from concrete class types. Plain identifiers (Integer, Counter) denote specific classes; angle brackets (<Printable>) denote "any object conforming to this protocol." This makes the structural vs. nominal distinction visible at the call site.

Conformance is structural and automatic:

// Counter has 'asString' (inherited from Object) → conforms to Printable
// No "implements" declaration needed

// Use protocol type constraint (angle brackets = structural type)
printAll: items :: <Printable> =>
  items do: [:each | Transcript show: each asString]

// Concrete type constraint (no brackets = nominal type)
deposit: amount :: Integer => ...

// Error when shape doesn't match
printAll: #(1, 2, 3)          // ✅ Integer conforms to Printable
printAll: someOpaqueValue      // ⚠️ Warning: cannot verify Printable conformance

Protocol query in REPL:

> Counter conformsTo: Printable
=> true
> Counter protocols
=> #(Printable)
> Printable requiredMethods
=> #(asString)

Phase 4: Advanced Type Features (Future)

// Union types
state: result :: Integer | Error

// Singleton/enum types
state: direction :: #north | #south | #east | #west

// False-or pattern (Option/Maybe)
state: cache :: Integer | False = false

// Generic types (parametric polymorphism)
Protocol define: Stack<T>
  requiring: [push: <T>, pop -> <T>, isEmpty -> Boolean]

// Type narrowing in control flow
x class = Integer ifTrue: [
  x + 1       // Compiler knows x is Integer here
]

Prior Art

Strongtalk (Primary Influence)

Approach: Optional, structural, protocol-based typing for Smalltalk. Designed by Gilad Bracha (who later designed Newspeak and co-designed the JVM spec).

Key decisions we adopt:

Key decisions we adapt:

TypeScript (Structural Inference Model)

Approach: Gradual, structural typing layered on JavaScript with aggressive inference.

What we adopt:

What we adapt:

Gleam (Full Static on BEAM)

Approach: Mandatory Hindley-Milner typing with no dynamic escape.

What we reject:

What we learn:

Dylan (Gradual + Dispatch Optimization)

Approach: Optional types with dispatch optimization when types are known.

What we adopt:

User Impact

Newcomer (from Python/JS/Ruby)

Smalltalk Developer

Erlang/Elixir Developer

Production Operator

Tooling Developer (LSP)

Steelman Analysis

For Full Static (Option C — Rejected)

For Pure Inference Only (No Annotations — Rejected)

For Global Strict Mode (--strict flag — Rejected)

Tension Points

Alternatives Considered

Alternative A: Do Nothing (Status Quo)

Keep the compiler fully dynamic — no type checking, no inference, no warnings.

// Today: typos are only caught at runtime
c := Counter spawn
c decremet                // No warning — crashes at runtime with doesNotUnderstand:

Rejected because:

Alternative B: Mandatory Static Typing (Gleam-style)

Full Hindley-Milner type system with no dynamic escape.

// Every binding must be typed
Actor subclass: Counter
  state: value :: Integer = 0
  increment -> Integer => self.value := self.value + 1

c := Counter spawn      // Error if Counter.spawn return type unknown
c increment              // Checked at compile time
c foo                    // Compile ERROR (not warning)

Rejected because:

Alternative C: Nominal Typing (Java-style)

Type compatibility based on declared class hierarchy, not message sets.

// Must explicitly declare interface conformance
Counter implements: Incrementable   // Explicit declaration required

Rejected because:

Alternative D: Global Strict Mode Flag

A --strict compiler flag that promotes type warnings to errors, like TypeScript's strict: true.

Rejected because:

Consequences

Positive

Negative

Neutral

Implementation

Phase 1: Type Inference (M-L effort)

Components:

Inference approach:

  1. Walk AST top-down through assignments
  2. Track variable types in scope (extend existing Scope)
  3. On message send: look up receiver type in ClassHierarchy, check method exists
  4. On binary op: check operand types against known operator signatures
  5. For literal blocks at message-send sites: look up callee method signature to infer block parameter/return types from context; stored blocks assigned to variables become Dynamic
  6. Emit warnings for mismatches, not errors

Phase 2: Optional Annotations (M effort)

Components:

Phase 3: Protocols (L effort)

Components:

Phase 4: Advanced Types (XL effort)

Union types, generic types, singleton types, type narrowing. Deferred to future ADR.

Implementation Tracking

PhaseIssueDescriptionSizeStatus
1BT-587, BT-671, BT-672Type inference from class definitions; argument/return/state checksM-LDone
2BT-673Optional type annotations syntax + user-facing coverage (stdlib/docs/examples); Dialyzer spec generation pending. Note: parameter type syntax updated to :: per ADR 0053 (BT-1134)MDone
3TBDProtocol definitions and structural conformanceLPlanned
4TBDAdvanced types (union, generic, singleton, narrowing)XLFuture

Migration Path

No migration needed for this ADR. This is purely additive — all existing code compiles and runs exactly as before. Type checking introduces new warnings only; no existing behavior changes.

Note on annotation syntax migration: After this ADR was accepted, ADR 0053 changed the type annotation delimiter from : to :: (e.g. amount: Integeramount :: Integer). That was a one-time syntax migration applied atomically across the stdlib and documentation (BT-1134). Code written before ADR 0053 using : Type syntax would need updating to :: Type.

References