ADR 0104: Typed Actor Protocols — the Class Interface Is the Protocol

Status

Accepted (2026-07-07) — implemented via Epic BT-2747 (Phases 1–4, BT-2749…BT-2752).

Implementation Tracking

Epic: BT-2747 Issues:

PhaseIssueTitleSizeBlocked by
1BT-2749Pin sync-send typing + retype cast ! as Nil + migration auditS
2BT-2750spawnWith: key checking via MapLiteral inspection + typo suggestionsM
3BT-2751withTimeout: return-type transparency + cross-process DNU wordingS
4BT-2752Diagnostic integration tests + docs + status flipSBT-2749, BT-2750, BT-2751

Sibling epic BT-2748 (ADR 0103): BT-2750 shares the spawnWith: MapLiteral call-site inspection with BT-2755 (related-to, either order).

Status: Implemented — all four phases (BT-2749…BT-2752) landed: sync-send typing + cast→Nil (Phase 1), spawnWith: key checking (Phase 2), withTimeout: transparency + cross-process DNU wording (Phase 3), and integration tests + docs + this status flip (Phase 4).

Context

Problem statement

Nobody on the BEAM types the process boundary well. Erlang/Elixir GenServer funnels every message through handle_call/3 — the compiler cannot relate a call site to the clause that handles it. Gleam gets typed messaging only by inventing Subject(msg), a parallel, non-OTP-standard actor layer.

Beamtalk's model — actor = class, message = selector, sync send = gen_server:call — means the actor's class interface already is its protocol type. docs/internal/type-system-design.md names this Option A ("messages typed by actor's interface") and recommends it, but no ADR ratifies it or defines the async edges. Meanwhile the checker already infers through sync actor sends as ordinary method sends — by accident of representation rather than by decision.

What is undefined today

  1. Cast (!). worker doThing! uses gen_server:cast and returns nil immediately (beamtalk-language-features.md §Explicit Async Cast). What is its static type — and should a cast to a method whose return value matters be flagged?
  2. spawn / spawnWith:. Class-side constructors returning an actor reference; spawnWith: takes an initial-state map that should check against declared state: slots.
  3. Timeouts. db withTimeout: 30000 returns a TimeoutProxy that forwards everything — the proxy should be transparent to typing (same interface as the wrapped actor), and a timed-out call raises rather than returning, so return types are unchanged.
  4. Cross-process DNU. An unknown selector raises does_not_understand in the callee process. Open-world policy (ADR 0100) applies with the same knowledge grading as local sends.

Constraints

Decision

Ratify Option A and define the four edges. An Actor subclass:'s public method set is its message protocol; the checker types actor sends exactly as method sends, with these rules:

1. Sync send — already works; now guaranteed

counter :: Counter := Counter spawn
counter increment      // :: Integer — the method's declared/inferred return

2. Cast (!) types as Nil — the misuse case is already a parse error

counter increment!          // :: Nil — fire-and-forget, bare statement
x := counter getCount!      // ✗ already a PARSE ERROR today:
                            //   cast_in_expression_error — a cast cannot be
                            //   assigned, returned, or passed as an argument

The grammar already enforces more than a warning could: casts are legal only as bare statements (cast_in_expression_error, enforced in the parser and pinned by parser tests — x := foo bar! and ^foo bar! are rejected outright). For avoidance of doubt: the postfix statement-cast is the only ! form — there is no infix actor ! expr in Beamtalk (raw Erlang ! is not exposed as surface syntax), so there is exactly one async-send construct for the type rule and for ADR 0103's sendability checks to cover. So no "discarded reply" dataflow analysis is needed or possible — the only remaining rule is the type: a cast statement's value is Nil (today it is Dynamic).

That retype is not free. Typing casts as Nil activates a currently dormant check: check_return_type skips Dynamic bodies, so a method declared -> SomeType whose body ends in a bare cast produces no diagnostic today but would newly warn ("body returns Nil"). That is usually a genuine catch (the method doesn't return what it declares), but it is a new-diagnostic wave on existing code — see Migration Path.

One deliberate non-feature: there is no "you cast to a method with a meaningful return" warning. Fire-and-forget casts to result-returning methods (self tick!, counter increment! where increment returns the new count) are idiomatic — intent is not statically detectable, and unannotated mutators infer concrete return types (there is no implicit-Self convention to key off), so any such warning would mostly fire on correct code.

3. Constructors check state

Counter spawnWith: #{count => 0}     // :: Counter — keys checked against
                                     //    declared `state:` slots and types
Counter spawnWith: #{cuont => 0}     // ⚠️ unknown state key `cuont` —
                                     //    did you mean `count`?

spawn / spawnWith: on Meta{C} return Known{C} (instance type), using ADR 0083's metaclass-aware inference.

4. Proxy return-type transparency; DNU follows ADR 0100

Getting the severity right costs nothing — TimeoutProxy overrides doesNotUnderstand: to forward, and ADR 0100 already makes unresolved selectors on DNU-overriding receivers silent, so there are no false diagnostics today. What is actually missing is positive inference: withTimeout: is hardcoded to return TimeoutProxy (generated_builtins.rs), so slowDb query: sql types as Dynamic instead of the wrapped class's real return type. The rule: withTimeout: on a receiver of type C returns a value typed as C (a timeout raises rather than returning, so method return types are unchanged). Unknown selectors on a statically-known actor class get the same knowledge-graded diagnostic as any send (ADR 0100) — the process boundary adds no new severity rule.

Error example

logger := Logger spawn
logger logg: "hi"
// ⚠️ Logger does not understand `logg:` — did you mean `log:`?
//    (same open-world grading as a local send; the process boundary is
//     invisible to the diagnostic)

Prior Art

SystemApproachTake / leave
Gleam gleam_otpSubject(msg) — typed channel, parallel to OTPProves demand for typed actors on BEAM; rejects the parallel layer. Beamtalk's dispatch is the typed surface, and stays OTP-standard.
Erlang/Elixir GenServerUntyped handle_call funnelThe baseline being improved on; Beamtalk actors remain callable as plain gen_servers.
Akka Typed (Scala)Behavior[Msg] — protocol as message ADTSame goal via sum types; Beamtalk gets it via selectors, which also gives per-message docs/completion for free.
PonyBehaviours (async methods) are typed and return nothingValidates "casts type as no-reply"; Pony makes it structural (be methods), we make it call-site (!).
Session types (research)Type message sequences, not just signaturesDeliberately out of scope: per-message typing is the 80%; protocol-state typing is research-grade and fights liveness.

User Impact

Steelman Analysis

Tension point

Whether spawnWith: unknown-key checking should be an error (it is a certain runtime problem) or stay advisory — and how Warning squares with ADR 0100, whose Rule 1 caps a single closed-complete receiver's unresolved selector at Hint. The reconciliation: an unknown spawnWith: key is not an unresolved selector but a provably failing construction — the class's state: slots are a closed set declared in the same program, and a key outside it cannot succeed — which is the tier ADR 0100 does grade Warning. Proposed as Warning on that basis (not as a silent escalation); revisit with strict mode.

Alternatives Considered

Typed channels (Subject-style)

A parallel typed messaging layer à la Gleam. Rejected: duplicates dispatch, breaks "messages all the way down", and abandons OTP-standard call semantics — the exact trade Beamtalk exists to avoid.

Protocol objects per actor

Generate a separate protocol type from each actor class and require it in annotations (:: CounterProtocol). Rejected: the class name already serves; a second name per actor is ceremony without information.

Session/typestate protocols

Type legal message sequences. Rejected for now (see steelman) — research grade, poor fit with hot reload; per-message typing delivers most value.

Consequences

Positive

Negative

Neutral

Implementation

  1. Phase 1 (~S): ratify sync-send typing with tests (it works today — pin it); ! retypes as Nil behind the migration audit (below). (The beamtalk-language-features.md cast-syntax fix — prefix c ! increment → postfix c increment! — is already landed in the PR that carries this ADR; no Phase 1 work remains for it.)
  2. Phase 2 (~M): spawnWith: key checking — AST-level MapLiteral pair inspection at the call site, unknown-key Warning (provably-failing tier), typo suggestions; slot-value checking where ADR 0102's algebra permits.
  3. Phase 3 (~S): TimeoutProxy return-type transparency rule (replace the hardcoded TimeoutProxy return); cross-process DNU wording unified with ADR 0100 diagnostics.

Affected: type checker (inference + validation), diagnostics, one language-features doc fix; not runtime, codegen, or the wire protocol.

Migration Path

The only behaviour change on existing code is the cast retype: methods with a declared return type whose body ends in a bare cast currently escape check_return_type (Dynamic bails out) and will newly warn that the body returns Nil. This is usually a genuine mismatch worth surfacing, but it is a new-diagnostic wave: before shipping Phase 1, audit the stdlib and test corpus for bare-cast method tails and fix or annotate them in the same change. Everything else (spawnWith: checking, proxy transparency) is purely additive and advisory.

References