ADR 0076: Convert Erlang ok/error Tuples to Result at FFI Boundary

Status

Accepted (2026-04-03)

Context

Erlang's most common return pattern — {ok, Value} | {error, Reason} — currently passes through the FFI proxy as a raw Beamtalk Tuple. Users must call Tuple's isOk/unwrap instance methods on the result, losing inner type information and access to Result's combinator methods (map:, andThen:, mapError:, ifOk:ifError:).

ADR 0075 (Erlang FFI Type Definitions) identifies this as the single largest gap in typed FFI coverage:

"A codebase that is fully typed in Beamtalk loses all type information the moment it touches Erlang."

The gate evaluation (BT-1845, PR #1869) confirmed that 86.5% of specced functions in the top-20 OTP modules have useful types — meaning spec-driven Result type inference is viable for the vast majority of FFI calls.

Current state

Today, calling an Erlang function that returns ok/error requires manual tuple unwrapping:

// Current: returns Tuple, no type information
result := Erlang file readFile: "/tmp/hello.txt"
result isOk
  ifTrue: [result at: 2]    // manual positional extraction
  ifFalse: [result at: 2]   // error reason, also positional

The Tuple class provides isOk, isError, unwrap, and unwrapOr: as convenience methods specifically for this pattern, but they cannot provide:

Precedent: charlist coercion (BT-1127)

The charlist → String coercion in beamtalk_erlang_proxy.erl established the pattern for transparent boundary conversion:

  1. Beamtalk strings are UTF-8 binaries; Erlang functions may expect charlists
  2. On badarg, the proxy retries with unicode:characters_to_list/2 conversion
  3. Charlist results are converted back to binary for consistency
  4. Users see native Beamtalk strings throughout — conversion is invisible

This ADR follows the same philosophy: convert at the boundary so users work with native Beamtalk types.

Constraints

Decision

1. Runtime: auto-convert ok/error tuples to Result in the FFI proxy

All {ok, V} and {error, R} tuples returned from Erlang calls via direct_call/3 in beamtalk_erlang_proxy.erl are automatically converted to Result objects using the existing beamtalk_result:from_tagged_tuple/1 helper.

Conversion rules:

Erlang return valueBeamtalk valueRationale
{ok, Value}Result ok: ValueStandard two-element ok tuple
{error, Reason}Result error: ReasonStandard two-element error tuple (reason wrapped via ensure_wrapped/1)
ok (bare atom)Result ok: nilCommon in OTP (file:write_file/2, application:start/1)
error (bare atom)Result error: nilRare, but symmetric with bare ok
{ok, V1, V2, ...} (3+ elements)Tuple (no conversion)Not a standard ok/error pattern; semantically different
Any other tupleTuple (no conversion)Only {ok, _} and {error, _} are recognized
Non-tuple valuesUnchangedIntegers, lists, maps, etc. pass through as today

Implementation in the proxy:

%% In beamtalk_erlang_proxy.erl, after direct_call/3 gets a result:
coerce_result(Result) ->
    case Result of
        {ok, Value} ->
            beamtalk_result:from_tagged_tuple({ok, Value});
        {error, Reason} ->
            beamtalk_result:from_tagged_tuple({error, Reason});
        ok ->
            beamtalk_result:ok(nil);
        error ->
            beamtalk_result:make_error(nil);
        Other ->
            Other
    end.

Note: bare ok/error atoms use ok/1 and make_error/1 directly rather than from_tagged_tuple/1, because from_tagged_tuple({error, nil}) would wrap nil through ensure_wrapped/1 producing a wrapped Exception object. For bare error with no reason, we want a literal nil error reason, not a wrapped exception.

This integrates into the existing coercion pipeline alongside charlist coercion — the result of coerce_charlist_result/1 feeds into coerce_result/1.

Conversion scope — where this applies and where it does not:

Code pathConverts?Rationale
Erlang module fn: args (FFI call via direct_call/3)YesThis is the FFI boundary — the single integration point
Messages received from Erlang processes (receive, actor mailbox)NoMessages travel through OTP message passing, not the proxy. An Erlang process sending {ok, Data} delivers a raw Tuple
ETS reads (Ets at:, Ets select:)N/AEts class methods call (Erlang beamtalk_ets) which does go through direct_call/3, but beamtalk_ets returns clean Beamtalk values (Value, nil, tagged maps), never ok/error tuples. If a user stores an ok/error tuple as a value in ETS and reads it back, the value passes through the proxy — but it was stored as a tagged map (Result) or a Tuple, not as {ok, V}, so no double-conversion occurs
Values inside converted ResultsNoOnly the outermost return value is checked. {ok, {ok, "nested"}} would become Result ok: #(ok, "nested") — the inner tuple is not recursively converted. Note: no OTP public API returns nested ok/error tuples; this case is theoretical
Outbound arguments to ErlangNoConversion is return-value only. Passing a Result to an Erlang function does not auto-convert it back to a tuple

This scope matches charlist coercion, which also only applies at the direct_call/3 boundary.

The message asymmetry is deliberate: converting messages would require hooking into OTP's message delivery, which is neither feasible nor desirable. Users receiving ok/error tuples from Erlang messages use Tuple isOk/unwrap as they do today — or call Result fromTuple: tuple to explicitly convert. This is a narrow inconsistency (FFI calls return Result, received messages return Tuple) but the alternative — converting in some message paths but not others — would be worse.

No toTuple method is provided. Erlang functions take values as arguments, not ok/error-wrapped tuples — the ok/error convention is a return pattern, not an input pattern. In the rare case where an Erlang function expects a tagged tuple as input, construct it directly:

// Extract the value to pass to another Erlang function:
result := Erlang file open: path with: #(#read)
result andThen: [:fd | Erlang file read: fd with: 1024]

// If you genuinely need an ok/error tuple (unusual):
#(#ok, result value)

2. Type system: map Erlang specs to Result(T, E) in auto-extract

The spec reader from ADR 0075 already parses Erlang type specs and maps them to Beamtalk types. This ADR adds a recognition rule: when the return type of an Erlang spec is a union containing {ok, T} and/or {error, E}, map it to Result(T, E).

Spec mapping rules:

Erlang spec return typeBeamtalk typeNotes
{ok, binary()} | {error, posix()}Result(String, Symbol)Full typed Result
{ok, pid()} | {error, term()}Result(Pid, Dynamic)Error type falls back to Dynamic
{ok, T} | {error, E} | OtherResult(T, E) | OtherUnion preserved for non-ok/error branches
ok | {error, E}Result(Nil, E)Bare ok atom maps to ok value of Nil
{ok, T} (no error branch)Result(T, Dynamic)Conservative: error type unknown
{error, E} (no ok branch)Result(Dynamic, E)Rare but handled consistently
term() / any()DynamicNo ok/error structure visible — no conversion

This feeds directly into ADR 0075's type signature generation pipeline. The spec reader recognizes the ok/error union pattern and emits Result(T, E) in the generated type stub, so the type checker and LSP completions show precise Result types.

Example — file:read_file/1:

%% Erlang spec:
-spec read_file(Filename) -> {ok, Binary} | {error, posix()} when
    Filename :: name_all(),
    Binary :: binary().
// Generated type signature (ADR 0075 auto-extract):
// Erlang file readFile: filename :: String -> Result(String, Symbol)

// User code:
result := Erlang file readFile: "/tmp/hello.txt"
result map: [:content | content size]  // Result(Integer, Symbol)

3. REPL and user experience

// Reading a file — ok path
result := Erlang file readFile: "/tmp/hello.txt"
result
// => Result ok: "Hello, world!\n"

result map: [:content | content size]
// => Result ok: 14

result value
// => "Hello, world!\n"

// Reading a file — error path
result := Erlang file readFile: "/nonexistent"
result
// => Result error: (ErlangError reason: #enoent)

result isError
// => true

result mapError: [:e | "File not found: " ++ e reason asString]
// => Result error: "File not found: enoent"

// Chaining FFI calls with combinators
(Erlang file readFile: "/tmp/config.json")
  andThen: [:content | Erlang json decode: content]
  mapError: [:e | "Config load failed: " ++ e reason asString]
// => Result ok: #{"key" -> "value"}

// Bare ok atom (file:write_file/2)
Erlang file writeFile: "/tmp/out.txt" with: "data"
// => Result ok: nil

// Timer returns — fully typed from spec
Erlang timer sendAfter: 1000 with: #timeout
// => Result ok: #<TimerRef>

4. Error examples

// Calling map: on an error Result — safe, returns the error unchanged
(Erlang file readFile: "/nonexistent") map: [:c | c size]
// => Result error: (ErlangError reason: #enoent)

// Calling value on an error Result — raises
(Erlang file readFile: "/nonexistent") value
// => ERROR: Result is error: (ErlangError reason: #enoent)

// Non-ok/error tuples are still Tuples
Erlang erlang timestamp
// => #(1712, 150000, 0)  — Tuple, not Result

Prior Art

Gleam (BEAM)

Gleam's Result(value, error) type compiles directly to {ok, Value} / {error, Reason} tuples — zero conversion needed because the representations are identical. When calling Erlang via @external, the programmer declares the return type; Gleam trusts the annotation at compile time. No runtime coercion. Gleam is not a precedent for automatic coercion — it avoids the problem entirely through representation choice.

What we adopt: The insight that ok/error is so pervasive on BEAM that it deserves first-class Result treatment, and that users expect Result semantics on FFI returns. Why Gleam's approach doesn't work for Beamtalk: Beamtalk's Result is a tagged map (ADR 0060), not a bare tuple. This enables rich method dispatch (map:, andThen:) but means the representations differ. We cannot use identical representation without giving up the object model — hence runtime conversion at the boundary.

Elixir (BEAM)

Entirely manual and convention-based. {:ok, value} | {:error, reason} is a community pattern; the with macro chains ok-path matching but provides no automatic conversion. Each library may have slightly different error shapes.

What we learn: Manual wrapping is boilerplate-heavy and error-prone. The with macro shows that chaining ok-path operations is a common need — our andThen: combinator serves the same role with better composability.

Swift / Objective-C interop

The strongest precedent for automatic FFI result conversion. Objective-C methods following the (BOOL)doThing:(NSError **)error convention are automatically imported as func doThing() throws. The compiler recognizes the rigid error convention and synthesizes the conversion.

What we adopt: The core idea — a rigid, well-known error convention can be reliably recognized and automatically converted at the boundary. What we adapt: Swift does this at compile time; we do it at runtime (simpler, works for dynamically-loaded modules).

Rust FFI

Entirely manual. C functions return error codes; Rust wrappers convert to Result<T, E> by hand. No automatic conversion.

What we learn: Manual boundary wrapping is the norm when conventions aren't rigid. Erlang's convention is rigid enough to automate.

Kotlin / Java interop

Kotlin removes Java's checked exception requirement but doesn't convert to a Result type. runCatching { } is opt-in.

What we learn: Ignoring the problem pushes burden to users. Our approach is more helpful.

User Impact

Newcomer (from Python/JS/Ruby)

Positive. Pattern matching on tuples is unfamiliar; Result with map:, value, and ok/isError feels like working with Optional or Promise. Error handling via combinators is more intuitive than positional tuple extraction. The REPL shows Result ok: "..." which is self-documenting.

Smalltalk developer

Mostly positive. Result is a proper object with methods — more aligned with message-passing philosophy than raw tuples. Smalltalk traditionally uses exceptions for errors, so Result is a pragmatic departure, but the combinator API (map:, andThen:) follows familiar block-passing patterns.

Erlang/Elixir developer

Mixed. They know ok/error tuples intimately and may initially wonder where their tuples went. However, Result provides the same information with better ergonomics. The type system benefit (precise Result(String, Symbol) instead of untyped Tuple) is compelling. If they need a raw tuple for forwarding to Erlang code, they can destructure: #(#ok, value).

Production operator

Positive. Consistent Result objects mean consistent error handling patterns. Wrapped errors via ensure_wrapped/1 provide structured error information for logging and monitoring. No change to BEAM-level observability — Result is a tagged map, visible in Observer like any other term.

Tooling developer (LSP, IDE)

Positive. Result(T, E) in type signatures enables precise completions after . — the LSP can offer map:, andThen:, value, etc. with correct generic types. Without this, FFI return values show only Tuple methods.

Steelman Analysis

Option A: Universal Auto-Conversion (Chosen — same runtime as Option D)

Option B: Opt-in via Type Annotation (Rejected)

Option C: Spec-Dependent Conversion (Rejected)

Option E: Explicit asResult on Tuple (Rejected)

This is the strongest rejected alternative. It loses on two arguments: (1) consistency — charlist coercion is automatic, so ok/error coercion should be too; and (2) the FFI boundary argument — Beamtalk has a real representation boundary with Erlang (unlike TypeScript/JavaScript where values are shared). Languages with real FFI boundaries (Kotlin/JVM, Swift/Obj-C) convert at the boundary automatically, not on demand. Asking users to manually bridge every FFI return would be like asking Kotlin users to call Integer.valueOf() on every Java int.

Tension Points

Alternatives Considered

Alternative: Opt-in Conversion via Type Annotation

Conversion only triggers when the user declares Result as the expected type:

result :: Result(String, Symbol) := Erlang file readFile: "/tmp/hello.txt"
// vs
raw := Erlang file readFile: "/tmp/hello.txt"  // stays Tuple

Rejected because: Adds ceremony to every FFI call. Newcomers won't know to add the annotation. Inconsistent with charlist coercion (which is automatic). Forces users to maintain two mental models for the same underlying pattern.

Alternative: Spec-Dependent Conversion

Only auto-convert when the Erlang module has a -spec returning {ok, T} | {error, E}. Fall back to Tuple for unspecced functions.

Rejected because: Creates confusing inconsistency — the same {ok, "hello"} value becomes Result from one module and Tuple from another, depending on whether the author wrote a spec. Spec presence affects type precision (Result(T,E) vs Result(Dynamic,Dynamic)), not whether conversion happens at all.

Alternative: Explicit asResult on Tuple (opt-in conversion)

Keep Tuple as the FFI return type but add an asResult method for explicit, user-controlled conversion:

result := Erlang file readFile: "/tmp/hello.txt"
result asResult map: [:content | content size]  // explicit conversion

// Or without conversion:
result isOk ifTrue: [result at: 2]  // still works

The LSP could suggest asResult when the Erlang spec indicates an ok/error return type.

Rejected because: Adds ceremony to every FFI call site — the most common Erlang return pattern requires an extra method call everywhere. Breaks the charlist coercion precedent (charlist conversion is transparent, not opt-in). Newcomers won't discover asResult without documentation. However, this is the strongest alternative: it preserves transparent interop fully and gives BEAM veterans control. The deciding factor is consistency with the charlist coercion decision — if charlist conversion is automatic, ok/error conversion should be too, since both are high-frequency boundary patterns.

Alternative: Keep Tuple with Better Methods

Enhance Tuple's API to provide combinator-like methods (map:, andThen:) directly:

result := Erlang file readFile: "/tmp/hello.txt"
result map: [:v | v size]  // on Tuple, not Result

Rejected because: Duplicates the Result API on Tuple. Creates a parallel error-handling idiom. Tuple is documented as "an interop artifact, not a general-purpose data structure." Adding combinator methods contradicts that design intent and bloats the Tuple interface.

Coercion Policy

This is the second automatic coercion at the FFI boundary (after charlist → String, BT-1127). To prevent ad-hoc coercion creep, this ADR establishes criteria for when boundary coercion is acceptable:

A coercion is justified when ALL of the following hold:

  1. Rigid convention: The source pattern is a well-defined, universally-recognized convention on BEAM (not a structural coincidence). Charlists and ok/error tuples both qualify — they are documented OTP conventions used by essentially all Erlang libraries.
  2. High frequency: The pattern appears in the vast majority of FFI interactions. ok/error is the most common Erlang return pattern; charlists appear whenever string-accepting functions are called.
  3. Lossless: The conversion preserves all information. {ok, V}Result ok: V loses nothing; result value recovers V and result error recovers the reason. Charlist ↔ binary is similarly lossless for valid Unicode.
  4. Single boundary point: The conversion happens at exactly one code path (direct_call/3), not scattered across the runtime. This keeps the coercion auditable and debuggable.
  5. Escape hatch exists: Users can bypass the coercion when needed (result value / #(#ok, v) for Result, explicit Erlang unicode charactersToBinary: for charlists).

Evaluated patterns that do NOT qualify:

PatternFails criteriaDetail
Property lists [{key, value}]Rigid, LosslessStructurally ambiguous with regular lists of tuples — [{x, 1}, {y, 2}] could be a proplist or a list of coordinates. Also largely legacy: OTP migrated public APIs from proplists to maps (OTP 17, 2014). Modern OTP specs use module-local option() union types, not proplists:proplist() — only 1 OTP module (ssh) references the proplist type in its specs.
Erlang records {record_name, ...}RigidNot a universal convention. Structure varies per module, field positions are compile-time only (no runtime metadata). Cannot be recognized structurally — a record is just a tuple with an atom first element, same as {ok, V} but without universal semantics.
Pid → ActorRigid, LosslessPids are already native BEAM values that pass through cleanly. The "mismatch" is at the protocol level (a raw pid can't respond to Beamtalk messages), not the representation level. Converting a pid to an Actor would require knowing which Actor class it is — information not available from the pid alone. This is a feature request, not a coercion.
undefinednilRigidNot a universal convention — Erlang functions variously use undefined, none, false, error, and not_found for absence. Converting only undefined would be arbitrary. Also lossy: can't distinguish "function returned the atom undefined" from "value is absent."
Erlang maps #{key => value}N/AAlready pass through as Beamtalk Dictionaries via the object model — no coercion needed.

We believe two coercions (charlists, ok/error) are the complete set. These are the two Erlang conventions that survived OTP's modernization while creating a genuine representation mismatch with Beamtalk's object model. Charlists exist because io_lib:format and friends predate binaries. ok/error tuples exist because there is no better BEAM-native alternative (unlike proplists, which maps cleanly replaced). Everything else is either already compatible (atoms, pids, maps, binaries, integers, lists) or too ambiguous to convert reliably.

If a future proposal seeks a third coercion, it must satisfy all five criteria and reference this policy.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1: Runtime Conversion (core change)

Phase 2: Type Mapping (integrates with ADR 0075)

Phase 3: Documentation and Migration

Migration Path

Code using Tuple methods on FFI returns

Before:

result := Erlang file readFile: path
result isOk ifTrue: [
  content := result unwrap.
  content asString
] ifFalse: [
  "Error: " ++ (result at: 2) asString
]

After:

result := Erlang file readFile: path
result
  map: [:content | content asString]
  mapError: [:e | "Error: " ++ e reason asString]

// Or more directly:
result ifOk: [:content |
  content asString
] ifError: [:e |
  "Error: " ++ e reason asString
]

// Or simply:
result value  // raises on error, returns content on success

Code forwarding results to Erlang

Erlang functions take values as arguments, not ok/error-wrapped tuples. Extract the value from the Result and pass it directly:

// Extract the value to pass on:
result := Erlang file open: path with: #(#read)
result andThen: [:fd | Erlang file read: fd with: 1024]

A codebase audit found zero production instances of constructing ok/error tuples to pass to Erlang. If genuinely needed, construct the tuple directly: #(#ok, value).

Tests using erlang:list_to_tuple/1 to create ok/error tuples

~15 test cases in stdlib/test/ and docs/learning/fixtures/ use Erlang erlang list_to_tuple: #(#ok, 42) to construct ok/error tuples for destructuring and pattern-matching tests. After this change, list_to_tuple returns a Result (since it's an FFI call returning {ok, 42}), breaking these tests.

Before:

// Creates a Tuple, used for destructuring tests
t := Erlang erlang list_to_tuple: #(#ok, 42)
{#ok, value} := t  // destructures Tuple

After:

// Use Tuple withAll: instead — not an FFI call, no conversion
t := Tuple withAll: #(#ok, 42)
{#ok, value} := t  // destructures Tuple as before

Tuple withAll: goes through stdlib dispatch, not the FFI proxy, so it is unaffected by the conversion. This is the recommended pattern for constructing tuples in Beamtalk regardless of this ADR — list_to_tuple was always an unnecessary Erlang detour.

Affected files:

Implementation Tracking

Epic: BT-1863 Status: Planned

PhaseIssueTitleSizeBlocked by
1BT-1864Runtime: coerce ok/error tuples to Result in FFI proxyS
1BT-1865Stdlib: add Result fromTuple: class methodSBT-1864
1BT-1866Migrate tests from list_to_tuple to Tuple withAll:SBT-1864
2BT-1867Spec reader: recognize ok/error unions as Result(T, E)MBT-1864
3BT-1868E2E btscript tests for FFI Result conversionSBT-1864, BT-1865
3BT-1869Documentation: FFI Result conversion and migration guideSBT-1864, BT-1868

References