ADR 0121: Lone ok/error Atom Return Specs Map to Result in FFI Type Inference

Status

Accepted (2026-09-14)

Context

ADR 0076 established that the FFI proxy (beamtalk_erlang_proxy.erl) unconditionally converts any bare ok/error atom returned from an Erlang call into Result ok: nil / Result error: nil, and that the spec reader (beamtalk_spec_reader.erl, from ADR 0075) mirrors this at the type level: when an Erlang -spec's return type is a union containing ok/error — e.g. ok | {error, E} or {ok, T} | {error, E} — it emits Result(T, E) instead of a plain type.

Current state

The union-recognition machinery lives in map_union/2 → map_union_result/1 → classify_union_branches/1 (beamtalk_spec_reader.erl, ~lines 1005–1206). It correctly classifies ok/error branches (bare atom, {ok, T}, or {error, E}) out of a union, resolves the ok/error inner types, and formats Result(T, E) (or the Result(T) shorthand when the error side is Dynamic, or bare Result when both sides are Dynamic).

This machinery is only reached when the return type is a {type, _, union, Branches} node. A lone, non-union return type — -spec f(...) -> ok. or -spec f(...) -> error. — never reaches map_union/map_union_result at all. It falls through the general map_type/1 clause list to:

map_type({atom, _, _}) ->
    <<"Symbol">>.

So beamtalk_actor:unregister/1, specced as:

-spec unregister(#beamtalk_object{}) -> ok.

is typed as Symbol by the spec reader, even though beamtalk_erlang_proxy:coerce_result/1 (ADR 0076, §1) converts its ok return value to Result ok: nil unconditionally, with no dependence on whether the originating spec was a union. The static type (Symbol) and the runtime value (Result) disagree.

This surfaced concretely in stdlib/src/actor.bt, Actor>>unregisterName:

/// Deregisters this actor's registered name; callers do not need to call
/// `unregister` from `terminate:`. Returns bare `ok` which auto-converts
/// to `Result ok: nil` per ADR 0079.
internal unregisterName -> Symbol => (Erlang beamtalk_actor) unregister: self

Declaring the honest -> Result(Nil, Dynamic) fails to compile (declares return type Result(Nil, Dynamic), but body returns Symbol), forcing the method to keep the misleading -> Symbol annotation — which misrepresents the runtime shape to callers, the type checker, and LSP completions.

Constraints

Decision

Introduce a map_return_type/1 entry point in beamtalk_spec_reader.erl that is used only at the return-type position of a function clause (never for nested/recursive type positions), and route a lone literal ok or error atom through the existing ADR-0076 recognition machinery as a one-branch pseudo-union, rather than through the general map_type/1 atom clause:

%% Return-type entry point — the only call sites that may trigger ADR-0076
%% ok/error-atom Result recognition for a *lone* (non-union) atom. Nested
%% type positions keep calling map_type/1 directly and keep mapping a lone
%% `ok`/`error` atom to Symbol, unchanged.
-spec map_return_type(tuple()) -> binary().
map_return_type({type, Line, union, Branches}) ->
    map_union(Branches, Line);
map_return_type({atom, _, ok} = RetType) ->
    map_union_result([RetType]);
map_return_type({atom, _, error} = RetType) ->
    map_union_result([RetType]);
map_return_type(RetType) ->
    map_type(RetType).

The two call sites that currently call map_type(RetType) directly on a function clause's return type switch to map_return_type/1:

All other call sites of map_type/1 (tuple elements, list elements, user-type/remote-type body resolution, union-branch resolution) are untouched — a bare ok/error appearing in any of those positions still maps to Symbol, exactly as today.

Because map_union_result([{atom, _, ok}]) reuses classify_union_branches/1, a lone ok classifies into OkTypes = [nil], ErrTypes = [], which resolve_ok_type/1 / resolve_err_type/1 / format_result_type/2 already turn into the shorthand Result(Nil) — the same string a real union branch {ok, T} with no error arm produces today. A lone error is not the mirror image: it classifies into OkTypes = [], ErrTypes = [nil], so resolve_ok_type([]) yields Dynamic (nothing informs the ok side) and resolve_err_type([nil]) yields Nil. format_result_type/2 only shortens the error side when it is Dynamic (format_result_type(OkType, <<"Dynamic">>) -> Result(OkType)); it has no matching shorthand for a Dynamic ok side, so this pair formats as the full two-argument Result(Dynamic, Nil), not Result(Nil). No new formatting rule is introduced; a lone atom is treated as the one-branch case of the union machinery that already exists.

Beamtalk-facing effect:

%% beamtalk_actor.erl
-spec unregister(#beamtalk_object{}) -> ok.
// Before (misleading — compiles, but disagrees with the runtime value):
internal unregisterName -> Symbol => (Erlang beamtalk_actor) unregister: self

// After (honest — matches ADR-0076's runtime coercion):
internal unregisterName -> Result(Nil) => (Erlang beamtalk_actor) unregister: self

REPL session:

counter unregister
// => Result ok: nil

Error example — what a caller sees if they treat the old Symbol contract as still valid:

counter unregisterName == #ok
// Before: true (Symbol equality)
// After:  ERROR: does_not_understand: Result does not understand '=='
//         with a Symbol argument in the way this comparison was written —
//         callers must migrate to `result isOk` / `result ok`, matching
//         every other ADR-0076 FFI Result consumer.

Prior Art

This ADR vs. ADR 0075/0076

This is not a new design — it is closing a gap ADR 0076 opened but did not fully cover: it establishes unconditional runtime coercion of bare ok/error, but its type-side counterpart (ADR 0075's spec reader) only recognized the pattern inside unions. The "prior art" for this decision is therefore internal: the union-branch recognition rules in map_union_result/1 are the precedent, extended to a domain (lone atom returns) they were always meant to imply but didn't yet cover.

Swift/Objective-C (via ADR 0076)

ADR 0076 already adopted the idea that a rigid, universally-recognized convention on the boundary language can be reliably recognized and converted — Objective-C's NSError ** outparam convention → Swift throws. The gap this ADR closes is the same idea applied consistently: if the runtime coercion doesn't care whether the source spec was a union, the static recognition of that coercion shouldn't either.

Gleam (BEAM)

Not directly applicable — Gleam avoids this problem entirely by using {ok, V}/{error, R} as its native Result representation, so there is no separate "was this spec a union" question. Beamtalk's tagged-map Result (ADR 0060) requires the boundary conversion that creates this gap in the first place; Gleam's approach was already considered and distinguished in ADR 0076.

User Impact

Newcomer

No visible change to the REPL value (Result ok: nil today and after — the runtime behavior was already correct). The improvement is behind the scenes: a newcomer writing a .bt wrapper around a bare-ok-specced Erlang function can now declare -> Result(Nil) and have it compile, instead of hitting a confusing "declares Result, but body returns Symbol" error that offers no clue that the spec reader — not their code — is the source of the mismatch.

Smalltalk developer

Reinforces that FFI-wrapped methods should expose a proper object (Result) with real messages (ok, isOk, map:), not a bare Symbol that happens to be #ok, closing a small crack where a "primitive-looking" value leaked through the type system.

Erlang/BEAM developer

Matches their existing expectation from ADR 0076: any Erlang function whose contract is "returns ok, raises on failure" behaves identically whether it's specced as -> ok. or -> ok | {error, _}. at the value level. This ADR makes the type level track that same equivalence, removing a case where the spec's shape (not its meaning) determined the declared Beamtalk type.

Production operator

No runtime change at all — this is a compile-time type-inference fix. Nothing to test in production; beamtalk_erlang_proxy:coerce_result/1 is unchanged.

Tooling developer (LSP, IDE)

Auto-extract (ADR 0075) and LSP hover/completions for any Erlang function specced as a lone -> ok./-> error. now show Result(Nil) / Result(Dynamic, Nil) instead of Symbol, so completions after . on such a call offer map:, andThen:, ok, etc. instead of nothing useful.

Steelman Analysis

Option A: Reuse union machinery for lone atom returns at the return-type entry point (Chosen)

Option B: Require an explicit type override annotation instead of extending inference (Rejected)

This is a real alternative but is rejected for the same reason ADR 0076 rejected "opt-in via type annotation" for the runtime coercion: it adds ceremony (a per-function override entry) to close a gap that has one clear, mechanical, unconditional rule — "the runtime coerces every bare ok/error, so the type inference should too." An override table would need one entry per affected function today and silently miss every future bare-ok-specced function someone adds, reproducing this exact bug on a rolling basis. Fixing the inference rule once closes the whole class.

Option C: Make runtime coercion spec-dependent instead (Rejected)

This is ADR 0076's own "Alternative: Spec-Dependent Conversion," already rejected there: it would make the identical ok atom returned by two Erlang functions convert to Result or stay Symbol/Tuple purely based on whether one author happened to write a union spec and the other didn't — "confusing inconsistency" in ADR 0076's own words. Re-opening it here would also require reverting shipped, implemented runtime behavior to fix a type-checker gap, which is a much larger and riskier change than fixing the type checker.

Tension Points

Alternatives Considered

Alternative: Make map_type/1's atom clause itself Result-aware

Change map_type({atom, _, ok}) -> <<"Symbol">>. to unconditionally return Result(Nil), without introducing a separate return-type entry point.

Rejected because: map_type/1 is called recursively for every nested type position — tuple elements, list elements, resolved user-type/remote-type bodies, individual union branches (map_union_result/1 itself calls map_type/1 on inner ok/error payload types). Making the atom clause Result-aware globally would, for example, turn a -spec f() -> {ok, T}.'s tuple-element mapping of the ok tag itself into Result(Nil) nested inside a Tuple(..), or turn a param type literally named ok into Result(Nil) where a param can never receive ADR-0076 coercion (coercion applies to return values only). Keeping the recognition at the return-type entry point only, and threading it through the existing per-clause call sites, avoids this collateral damage.

Consequences

Positive

Negative

Neutral

Implementation

Migration Path

Actor>>unregisterName callers comparing against Symbol

Before:

(counter unregisterName) == #ok
  ifTrue: [...]

After:

(counter unregisterName) isOk
  ifTrue: [...]

A grep of stdlib/src/*.bt and stdlib/test/*.bt for unregisterName found only the internal call from Actor>>unregister (actor.bt:253), which discards the result (self unregisterName.) and is unaffected by the type change.

References