ADR 0060: Result Type — Hybrid Error Handling for Expected Failures

Status

Accepted (2026-03-08)

Context

The Problem

Beamtalk currently uses exception-based error handling throughout, with structured #beamtalk_error{} records and the on:do: block syntax (ADR 0015). This works well for programming errors (does_not_understand, arity_mismatch, immutable_value) and aligns with BEAM's "let it crash" supervision model.

However, some operations have expected failure modes that aren't bugs — file I/O, parsing, network operations, type coercion. Using exceptions for these conflates "something went wrong in the program" with "this operation might not succeed."

Current Pain Points

1. FFI impedance mismatch: Every Erlang FFI wrapper that calls functions returning {ok, V} | {error, R} must translate these structured tuples into Beamtalk exceptions. There are ~50+ such translation sites across beamtalk_file.erl, beamtalk_http.erl, beamtalk_regex.erl, beamtalk_datetime.erl, beamtalk_subprocess.erl, and beamtalk_reactive_subprocess.erl. Each new FFI wrapper (FileHandle — BT-1188, ReactiveSubprocess — BT-1187) adds more.

2. Three incompatible error conventions emerged in the Symphony orchestrator (~1000 lines of real Beamtalk application code):

// Convention 1: Exceptions (stdlib)
content := [File readAll: path] on: Exception do: [:e | ^#missing_workflow_file]

// Convention 2: HTTPResponse.ok (HTTP-specific, not composable)
resp ok ifTrue: [resp bodyAsJson] ifFalse: [^#linear_api_status]

// Convention 3: Error symbols (ad-hoc, manual propagation)
result := self graphql: query variables: vars
(result class) =:= Symbol ifTrue: [^result]   // manual — noisy and error-prone

3. The chain problem: Multi-step fallible pipelines require manual sentinel checking at every step:

// Current: manual error checking at each layer
load: path =>
  content := [File readAll: path] on: Exception do: [:e | ^#missing_workflow_file]
  parsed  := [Yaml parse: content] on: Exception do: [:e | ^#workflow_parse_error]
  (parsed class) =:= Symbol ifTrue: [^parsed]
  // ... more steps, each with its own error convention

What Stays Unchanged

This ADR does NOT change the exception infrastructure from ADR 0015. Exceptions remain the correct mechanism for:

Constraints

  1. BEAM-native — Must interoperate cleanly with Erlang's {ok, V} | {error, R} convention
  2. Smalltalk-idiomatic — Must feel like message sends, not a foreign concept
  3. Coexist with exceptionson:do: and supervision remain for bugs/crashes
  4. Value type — Result is a tagged map (immutable value), not an actor
  5. No implicit unwrapping — Results must be explicitly handled; no auto-unwrap that hides failures

Decision

Core Principle: Exceptions for Bugs, Results for Expected Failures

Introduce a Result value class for operations where failure is a normal, expected outcome. The dividing line:

Failure modeMechanismExample
File doesn't existResultFile read: pathResult error: (IOError file_not_found)
Parse input is malformedResultYaml parse: textResult error: (ParseError malformed_input)
Network is unreachableResultHTTPClient get: urlResult error: (NetworkError connection_failed)
Object doesn't understand messageException42 foo → RuntimeError
Wrong number of argumentsExceptionArray new: 1 with: 2 extra: 3 → RuntimeError
Actor is deadExceptionSupervision restarts it

Guideline: If the caller should reasonably expect and handle the failure as part of normal program flow, return a Result. If the failure indicates a programming mistake, raise an exception.

Error payload convention: The errReason inside a Result error: follows the same structured-error rule as exceptions: public API methods must wrap the reason in a #beamtalk_error{} object (or equivalent structured Exception subclass instance), not a bare symbol. Bare symbols (#file_not_found) are only acceptable in internal helpers that are immediately translated at the public boundary. from_tagged_tuple/1 is an internal helper — FFI module wrappers that call it must populate errReason with a structured error before surfacing the Result to Beamtalk callers.

1. The Result Class

Result is a sealed Value subclass with two states: ok and error.

// stdlib/src/Result.bt
sealed Value subclass: Result
  state: okValue :: Object = nil
  state: errReason :: Object = nil
  state: isOk :: Boolean = true

  // --- Constructors (class-side) ---

  /// Create a successful Result wrapping a value.
  class ok: value -> Result => Result new: #{ #isOk => true, #okValue => value }

  /// Create a failed Result wrapping an error reason.
  class error: reason -> Result => Result new: #{ #isOk => false, #errReason => reason }

  // --- Querying ---

  /// True if this Result holds a success value.
  sealed ok -> Boolean => self isOk

  /// True if this Result holds an error.
  sealed isError -> Boolean => self isOk not

  // --- Guarded accessors ---
  // Named `value` and `error` for ergonomics, but guarded to prevent
  // silent nil when accessing the wrong state. The internal fields
  // (okValue, errReason) are available but undocumented — public API
  // is via these guarded methods or the safe combinators below.

  /// The success value. Raises if this is an error Result.
  sealed value -> Object =>
    self isOk ifTrue: [self okValue] ifFalse: [
      Exception signal: "Cannot access 'value' on Result error — use valueOr:, ifOk:ifError:, or unwrap"
    ]

  /// The error reason. Raises if this is an ok Result.
  sealed error -> Object =>
    self isOk ifFalse: [self errReason] ifTrue: [
      Exception signal: "Cannot access 'error' on Result ok — use ifOk:ifError: or mapError:"
    ]

  // --- Extracting ---

  /// Unwrap the success value, or return the default if error.
  sealed valueOr: default -> Object =>
    self isOk ifTrue: [self okValue] ifFalse: [default]

  /// Unwrap the success value, or evaluate a block with the error.
  sealed valueOrDo: block -> Object =>
    self isOk ifTrue: [self okValue] ifFalse: [block value: self errReason]

  /// Unwrap the success value, or raise an exception.
  /// Re-raises errReason directly if it is an Exception (preserving class, details, hints).
  /// FFI-sourced and tryDo: Results always carry Exception errReasons (from_tagged_tuple/1
  /// calls ensure_wrapped/1). Generic signal only fires for explicit Result error: rawSymbol.
  sealed unwrap -> Object =>
    self isOk ifTrue: [self okValue] ifFalse: [
      (self errReason isKindOf: Exception)
        ifTrue:  [self errReason signal]
        ifFalse: [Exception signal: "unwrap called on Result error: " ++ self errReason printString]
    ]

  // --- Transforming ---

  /// Apply a block to the success value, wrapping the result in a new Result.
  /// If this is an error, return self unchanged.
  sealed map: block -> Result =>
    self isOk ifTrue: [Result ok: (block value: self okValue)] ifFalse: [self]

  /// Apply a block that itself returns a Result. Flattens the nesting.
  /// If this is an error, return self unchanged.
  sealed andThen: block -> Result =>
    self isOk ifTrue: [block value: self okValue] ifFalse: [self]

  /// Apply a block to the error, wrapping the result in a new error Result.
  /// If this is ok, return self unchanged.
  sealed mapError: block -> Result =>
    self isOk ifTrue: [self] ifFalse: [Result error: (block value: self errReason)]

  // --- Pattern matching ---

  /// Handle both cases with blocks.
  sealed ifOk: okBlock ifError: errorBlock -> Object =>
    self isOk
      ifTrue: [okBlock value: self okValue]
      ifFalse: [errorBlock value: self errReason]

  // --- Display ---

  sealed printString -> String =>
    self isOk
      ifTrue: ["Result ok: " ++ self okValue printString]
      ifFalse: ["Result error: " ++ self errReason printString]

2. REPL Usage

> Result ok: 42
// => Result ok: 42

> Result error: #file_not_found
// => Result error: #file_not_found

> (Result ok: 42) map: [:v | v + 1]
// => Result ok: 43

> (Result ok: 42) andThen: [:v | Result ok: v * 2]
// => Result ok: 84

> (Result error: #nope) map: [:v | v + 1]
// => Result error: #nope

> (Result ok: 42) valueOr: 0
// => 42

> (Result error: #nope) valueOr: 0
// => 0

> (Result ok: 42) ifOk: [:v | "got " ++ v printString]
                   ifError: [:e | "failed: " ++ e printString]
// => "got 42"

> (Result error: #nope) unwrap
// => Exception: unwrap called on Result error: #nope

3. Error Examples (Misuse)

> (Result ok: 42) andThen: [:v | v + 1]
// => TypeError: andThen: block must return a Result, got Integer
//    Hint: Use map: to transform the value, or wrap in Result ok:

> Result ok: 42 map: [:v | v + 1]
// => RuntimeError: Integer does not understand 'map:'
//    Hint: Wrap in parentheses: (Result ok: 42) map: [...]
//    (Beamtalk keyword messages associate right — ok: consumes everything after it)

4. Erlang FFI Convention

Erlang modules that return {ok, V} | {error, R} can surface these as Results via a helper in the runtime:

%% beamtalk_result.erl — new module
-module(beamtalk_result).
-export([from_tagged_tuple/1]).

%% Convert Erlang {ok, V} | {error, R} to Result tagged maps.
%% Uses internal field names (okValue, errReason) to prevent unguarded
%% field access — the public API goes through guarded value/error methods.
%%
%% NOTE: from_tagged_tuple/1 strictly handles {ok, V} | {error, R}.
%% It does NOT accept bare ok atoms — the atom ok maps to the Beamtalk
%% Symbol #ok, not the boolean true, and conflating the two would silently
%% change the payload type. For functions returning bare ok (e.g., file:close/1),
%% FFI authors must handle the atom explicitly:
%%   ok -> beamtalk_result:ok(ok_symbol_here)  % or wrap in {ok, unit_value}
%% For {ok, V1, V2} multi-value tuples, normalize before calling this helper.
from_tagged_tuple({ok, Value}) ->
    #{'$beamtalk_class' => 'Result', 'isOk' => true, 'okValue' => Value, 'errReason' => nil};
from_tagged_tuple({error, Reason}) ->
    %% Promote #beamtalk_error{} records to Beamtalk Exception objects so that
    %% errReason is always either a Beamtalk Exception (re-raiseable by unwrap)
    %% or a raw value. ensure_wrapped/1 is a no-op if Reason is already a tagged map.
    ExObj = beamtalk_exception_handler:ensure_wrapped(Reason),
    #{'$beamtalk_class' => 'Result', 'isOk' => false, 'okValue' => nil, 'errReason' => ExObj}.

Usage in FFI modules:

%% beamtalk_file.erl — AFTER migration
'readAll:'(Path) ->
    case file:read_file(Path) of
        {ok, Content} ->
            beamtalk_result:from_tagged_tuple({ok, Content});
        {error, Reason} ->
            beamtalk_result:from_tagged_tuple({error, format_file_error(Reason, Path)})
    end.

Scope of from_tagged_tuple/1: This helper covers {ok, V} | {error, R} only. It does not accept bare ok atoms — the atom ok is the Beamtalk Symbol #ok, not true, and silently coercing it would change the payload type. FFI authors must handle bare ok explicitly (e.g., wrapping it as {ok, nil} or constructing the Result map directly). Multi-value tuples {ok, V1, V2} and other shapes must also be normalized before calling this helper. Functions that crash on failure (e.g., erlang:binary_to_integer/1 raising badarg) should use tryDo: instead.

This replaces the current 5-line error builder chain per error case:

%% beamtalk_file.erl — BEFORE (current pattern, repeated ~50 times)
'readAll:'(Path) ->
    case file:read_file(Path) of
        {ok, Content} -> Content;
        {error, enoent} ->
            Error0 = beamtalk_error:new(file_not_found, 'File'),
            Error1 = beamtalk_error:with_selector(Error0, 'readAll:'),
            Error2 = beamtalk_error:with_details(Error1, #{path => Path}),
            Error3 = beamtalk_error:with_hint(Error2, <<"Check that the file exists">>),
            beamtalk_error:raise(Error3);
        {error, eacces} ->
            %% ... another 5-line chain for permission_denied
    end.

5. tryDo: — Bridging Exceptions to Results

A class-side method on Result wraps exception-raising code into a Result:

// Wrap any exception-raising expression into a Result
result := Result tryDo: [Yaml parse: untrustedInput]
// => Result ok: parsedValue   OR   Result error: anException

// Useful for calling legacy stdlib code that still raises
result := Result tryDo: [SomeLegacyLib process: data]
result ifOk: [:v | use: v] ifError: [:e | log: e message]

Implementation in Erlang:

%% beamtalk_result.erl
'class_tryDo:'(_ClassSelf, _ClassVars, Block) ->
    try beamtalk_message_dispatch:send(Block, 'value', []) of
        Value -> from_tagged_tuple({ok, Value})
    catch
        Class:Reason:Stack ->
            ExObj = beamtalk_exception_handler:ensure_wrapped(Class, Reason, Stack),
            from_tagged_tuple({error, ExObj})
    end.

6. Symphony Rewritten with Result

The multi-step pipeline from the Symphony motivating example:

// BEFORE: three different error conventions, manual propagation
load: path =>
  content := [File readAll: path] on: Exception do: [:e | ^#missing_workflow_file]
  parsed  := [Yaml parse: content] on: Exception do: [:e | ^#workflow_parse_error]
  (parsed class) =:= Symbol ifTrue: [^parsed]
  self buildDefinition: parsed

// AFTER: composable Result chain
load: path =>
  (File readAll: path)
    andThen: [:content | Yaml parse: content]
    andThen: [:parsed  | self buildDefinition: parsed]
// BEFORE: manual type-test propagation at every boundary
fetchCandidateIssues =>
  result := self graphql: query variables: vars
  (result class) =:= Symbol ifTrue: [^result]
  // ... process result

// AFTER: Result propagates automatically
fetchCandidateIssues =>
  (self graphql: query variables: vars)
    andThen: [:data | self extractIssues: data]
    andThen: [:issues | self filterCandidates: issues]

7. Actor Methods Returning Result

An actor method can return a Result. This is a normal return value — it flows through gen_server:call as {ok, ResultMap} and is unwrapped by sync_send/3 to just ResultMap. Crucially:

// Actor method returns Result — actor stays alive
readConfig =>
  (File readAll: self configPath)
    andThen: [:content | Yaml parse: content]

// Caller handles the Result
config := worker readConfig
config ifOk: [:c | use: c] ifError: [:e | useDefaults]
// worker is still alive regardless of the Result

Caution — self-sends inside andThen: blocks: If an actor chains andThen: blocks that send messages back to self, the same deadlock risk applies as with any self-send inside a block (known issue — synchronous gen_server:call to self blocks). This is not unique to Result but is more likely with andThen: chains:

// DEADLOCK RISK: self-send inside andThen: block in an actor method
processFile: path =>
  (File readAll: path)
    andThen: [:content | self validate: content]   // self-send — deadlocks!

// SAFE: extract to local variable or use map: for pure transforms
processFile: path =>
  result := File readAll: path
  result andThen: [:content | self validate: content]  // same deadlock — still a self-send
  // Solution: don't self-send in fallible chains. Use pure functions or refactor.

8. REPL Display of Result Errors

When a Result error is returned at the REPL, it is displayed as a normal result (not an error):

> File readAll: "/nonexistent"
// => Result error: #file_not_found     (displayed as a value, not an error)
> _
// => Result error: #file_not_found     (bound to _ as last result)
> _error
// => nil                               (_error is NOT set — no exception occurred)

This is correct: the expression evaluated successfully and returned a Result value. _error is only set when an exception is raised. Users coming from exception-based error handling may initially expect _error to be set — the documentation should clarify this distinction.

Future consideration: The REPL could render Result error: values with different formatting (e.g., yellow text vs green) to visually signal that the result represents a failure. This is a UX enhancement, not a semantic change.

9. Forward Compatibility: Match Expression Integration

If match: gains class/structural patterns (planned separately), Result becomes directly destructurable:

// Future: class pattern arms in match:
(File readAll: path) match: [
  Result ok: content -> process: content;
  Result error: e    -> handleError: e
]

This is an alternative to ifOk:ifError: for exhaustive case analysis — the match enforces that both arms are present and the compiler can warn on missing cases. ifOk:ifError: remains the idiomatic API for chaining (andThen:, map:), where the Result stays wrapped and propagates through a pipeline.

The two are complementary: ifOk:ifError: for pipelines, match: for terminal case dispatch. No changes to ADR 0060's design are needed to support this — Result's tagged map representation (isOk, okValue, errReason) is straightforwardly matchable by a structural pattern system.

10. Forward Compatibility: Parameterized Result Types

The current design uses -> Result as the return type annotation, which doesn't express what types the ok value and error carry. When gradual typing (ADR 0025) matures, Result should support parameterized type annotations:

// Current (unparameterized)
sealed readAll: path :: String -> Result => ...

// Future (parameterized, when ADR 0025 supports it)
sealed readAll: path :: String -> Result(String, IOError) => ...

The runtime representation (tagged map with isOk, okValue, errReason fields) is compatible with future type parameterization — the type parameters constrain what values the fields may hold, they don't change the representation. No runtime changes will be needed; this is purely a type-system concern.

11. Guidelines: When to Use Which

SituationUseWhy
File might not existResultExpected — the caller should handle it
YAML input might be malformedResultExpected — untrusted input
Network might be unreachableResultExpected — infrastructure is fallible
HTTP response might have error statusNeither — return HTTPResponseStatus codes are data, not errors
Integer doesn't understand fooExceptionBug — wrong message for type
Actor crashesException + supervisionInfrastructure — let it crash
Wrong number of argumentsExceptionBug — programming mistake
Type mismatch in primitive opExceptionBug — wrong types passed
Division by zeroExceptionBug — mathematical error
Regex pattern is invalidResultExpected — user-supplied pattern
JSON parse of user inputResultExpected — untrusted input

Heuristic — the boundary test: If the method's primary input comes from outside the program's control (user input, filesystem, network, external process), return Result. If the method operates on already-validated internal data, raise exceptions. Concretely:

Edge cases and how to resolve them:

SituationResult or Exception?Why
Integer parse: userInputResultUser input — might not be a number
42 + "abc"ExceptionInternal — program passed wrong type
HTTPClient get: url (timeout)ResultNetwork — external, inherently unreliable
actor someMethod (gen_server timeout)ExceptionInternal — actor should have responded
File readAll: path (from config)ResultFilesystem — file might not exist
dict at: key (key from user)Existing ifAbsent: patternAlready handled by block fallback
Yaml parse: content (from API)ResultExternal data — might be malformed
Yaml parse: content (from own code)Use unwrap or tryDo:You trust your own data but can assert

Prior Art

Pharo / Squeak Smalltalk — Block-Based Fallbacks

Pharo handles expected failures via block arguments: at: key ifAbsent: [default], detect: [:x | pred] ifNone: [fallback], readStreamDo: [:s | ...] ifAbsent: [nil]. The block IS the error handler — the caller provides a closure that runs on failure.

There is no Result/Maybe/Optional type in Smalltalk tradition. The ifAbsent: pattern avoids wrapper types entirely.

What we adopted: The ifOk:ifError: message name follows the ifTrue:ifFalse: / ifAbsent: naming convention — Smalltalk-idiomatic keyword messages. valueOr: mirrors valueOrDefault: patterns. The block-argument style is preserved.

What we departed from: Pharo doesn't wrap the result — the caller provides inline handlers. We chose a Result wrapper because (a) it composes via andThen:, which block-argument APIs cannot, and (b) it maps directly to Erlang's {ok, V} | {error, R}, which block-argument APIs do not. The departure is justified by the chaining problem (§6 above) and FFI mapping needs.

Gleam — Result Type (BEAM-native)

Gleam uses Result(value, error) exclusively — no exceptions exist. use expressions desugar into monadic chains:

use username <- result.try(validate_username(input))
use password <- result.try(validate_password(input))
register_user(username, password)

let assert Ok(value) = expr crashes the process on Error (equivalent to unwrap).

What we adopted: The Result type with map, try (our andThen:), and explicit unwrap. The principle that libraries return Results and supervisors handle crashes.

What we adapted: Gleam has no exceptions at all — Result is the only error mechanism. Beamtalk keeps exceptions for bugs (ADR 0015) and adds Result for expected failures. This hybrid is closer to Elixir's model than Gleam's.

What we rejected: Gleam's use syntax sugar. Beamtalk's andThen: achieves the same composition via standard message sends — no special syntax needed.

Elixir — {:ok, v} | {:error, r} Convention

Elixir uses tagged tuples by convention: File.read("path") returns {:ok, contents} or {:error, :enoent}. The with statement chains fallible operations:

with {:ok, user} <- fetch_user(id),
     {:ok, account} <- fetch_account(user) do
  {:ok, account}
end

What we adopted: The principle of "errors as values" for expected failures. The clear separation between {:ok, v} (expected failure → Result) and raise (bugs → exceptions).

What we adapted: Elixir's tuples are raw data; our Result is a Beamtalk object that responds to messages. This follows Beamtalk's "everything is an object" principle while achieving the same semantics.

Elixir's enforcement story — and ours: Elixir's runtime enforcement comes from case on tagged tuples: miss a branch and you get a CaseClauseError crash at runtime. This is not static — it fires when the unmatched value actually arrives. Once Beamtalk's match: gains class/structural patterns (§9), Result dispatch will have the same runtime enforcement:

result match: [
  Result ok: v  -> process: v.
  Result error: e -> useDefaults
]
// Missing a branch → match failure at runtime, same as Elixir's CaseClauseError

Elixir uses with for chaining and case for dispatch at a decision point. Beamtalk maps the same split onto andThen: (chaining pipelines) and match: (exhaustive dispatch). The enforcement level is identical: runtime, not compile-time — which is appropriate for a dynamic, open-world system.

Rust — Result<T, E> with ? Operator

Rust's Result<T, E> with ? for propagation is the gold standard for typed error handling. The ? operator short-circuits on Err, propagating the error up the call stack.

What we adopted: map, and_then (our andThen:), unwrap_or (our valueOr:). The principle that Result is the return type for fallible operations.

What we rejected: The ? operator. Beamtalk doesn't need special syntax because andThen: achieves propagation via standard message sends. Adding a new operator would violate "messages all the way down."

Newspeak — Promises for Async Errors

Newspeak uses promises for asynchronous operations: if processing produces a result, the promise is "fulfilled"; if it raises an exception, the promise is "broken." This maps to the actor-based model — async errors are handled by promise chaining, not try/catch.

What we noted: Beamtalk's actor messaging is synchronous by default (ADR 0043), so promises are less immediately relevant. But the principle — fallible async operations return a value (Result/Promise) rather than raising — aligns with our decision.

Ruby — dry-monads (Dynamic Language Precedent)

Ruby is dynamically typed and has no gradual typing. The dry-monads gem (part of the dry-rb ecosystem) provides Success(value) / Failure(reason) with fmap (our map:), bind (our andThen:), and value_or (our valueOr:):

result = File.read("config.yml")
  .then { |content| YAML.safe_load(content) }
  .then { |parsed| build_config(parsed) }

case result
in Success(config) then use(config)
in Failure(reason) then use_defaults
end

dry-monads is widely used in production Rails applications — without type annotations. The chaining ergonomics (bind, fmap) provide value purely as a runtime convention: callers know what shape they're getting back, and the composition is explicit. The Ruby community does not find the absence of static enforcement to be a blocker; the convention itself is sufficient.

What this confirms for Beamtalk: Result works as a dynamic runtime convention. The ergonomic value — composable chaining, explicit failure shape, no surprise nil — does not require a type checker to be useful. This is the strongest dynamic-language precedent for our design.

What we adapted: Ruby uses Success/Failure constructors (following Haskell/Scala terminology). We use Result ok:/Result error: — more explicit about the container type, consistent with Beamtalk's keyword message style.

What We're Actually Borrowing from Rust and Gleam

Rust and Gleam are both statically typed. It might seem odd to cite them as prior art for a dynamic, live-environment language. The distinction is important: we are adopting their runtime ergonomics and FFI convention, not their static enforcement model.

What makes Rust's Result<T, E> and Gleam's Result(v, e) valuable has two parts:

  1. Static part — The type checker enforces exhaustive handling at every call site. Neither we nor Elixir have this for dynamic code, and Beamtalk cannot have it in the general case because new classes can be defined at the REPL at any time (open-world, live system).

  2. Runtime partResult is a structured value with known shape. map, and_then, unwrap_or are message sends that compose at runtime, regardless of what the type checker knows. The FFI mapping from {ok, V} | {error, R} is a runtime convention, not a type system feature.

Beamtalk adopts part 2 entirely. Part 1 is available in typed contexts (gradual typing annotations) but is never the primary enforcement mechanism. This is the right split: the runtime convention works uniformly across dynamic and typed code, and typed code gets additional static guarantees on top. Attempting to rely on part 1 alone — union types without a runtime wrapper — fails for Beamtalk because the open-world live system makes exhaustiveness checking unsound (Alternative G).

Summary

FeaturePharoRuby dry-monadsGleamElixirRustBeamtalk (proposed)
Typed?DynamicDynamicStaticDynamicStaticDynamic + optional types
Expected failure mechanismifAbsent: blocksSuccess/FailureResult(v, e){:ok, v} | {:error, r}Result<T, E>Result class
Bug mechanismExceptionsExceptionspanic / let assertraisepanic!Exceptions (ADR 0015)
CompositionNone (blocks inline)bind / fmapuse + result.trywith statement? operatorandThen:
Static exhaustiveness?NoNoYesNoYesNo (open-world live system)
FFI mappingN/AN/ANativeNative tuplesFFI crate-specificbeamtalk_result:from_tagged_tuple/1
Unwrap with defaultifAbsent: blockvalue_orresult.unwrapelem(tuple, 1).unwrap_or()valueOr:
Two systems coexist?Yes (exceptions + blocks)YesNo (Result only)Yes (tuples + raise)Yes (Result + panic)Yes (Result + exceptions)

User Impact

Newcomer (from Python/JS/Ruby/Rust)

Smalltalk Developer

Erlang/BEAM Developer

Production Operator

Tooling Developer (LSP/IDE)

Steelman Analysis

Option A: Pure Result (no block sugar)

CohortStrongest argument
Newcomer"Fewer methods to learn — just map, andThen, and unwrap. Less API surface."
BEAM veteran"Cleaner mapping to Gleam's Result — one canonical API, no Smalltalk sugar on top."
Language designer"Simpler implementation — Result is just a data type with standard methods. No bridge patterns."

Tension: Pure Result is sufficient functionally but misses the Smalltalk feel that makes Beamtalk distinctive.

Option B: Block-Based Fallbacks (Smalltalk-pure, no Result type)

CohortStrongest argument
Smalltalk purist"This IS how Pharo does it. at: key ifAbsent: is the canonical pattern. No wrapper types needed — blocks are the composition tool."
Newcomer"I just add ifError: to the method call — no new type to learn, no wrapping/unwrapping."
Language designer"One mechanism (blocks) instead of two (blocks + Result). Every fallible method gets an ifError: variant — consistent, discoverable."

Tension: Block-based fallbacks cannot compose across function boundaries. Symphony's fetchCandidateIssues → graphql → extractIssues → filterCandidates pipeline requires manual propagation with blocks; andThen: solves this structurally. The API explosion (every fallible method needs 2+ variants) is also a real cost.

Option C: Status Quo (exceptions only)

CohortStrongest argument
Newcomer"One error system is simpler than two. I don't want to learn BOTH on:do: AND Result — which do I use when?"
Smalltalk purist"Result is a Haskell monad in Smalltalk clothing. Blocks + on:do: IS the Smalltalk way. The Symphony pain could be solved by a pipeline:steps: method that chains on:do: wrappers, not by importing Rust's type system."
BEAM veteran"Let it crash works. If you're catching expected errors, you're doing it wrong — design your system so failures restart cleanly."
Operator"Fewer moving parts, fewer surprises. One error path to monitor and alert on."

Tension: The status quo is defensible for small programs but breaks down at application scale (Symphony). The real-world evidence of three incompatible conventions emerging organically demonstrates that the language needs to provide a standard mechanism before users invent ad-hoc ones. The Smalltalk purist's pipeline suggestion is worth exploring but would need its own design — and it still doesn't solve the FFI impedance mismatch with {ok, V} | {error, R}.

Option D: tryDo: Only (Minimal Phase 1 as Final State)

CohortStrongest argument
Newcomer"I wrap things in tryDo: when I want to chain them — one new concept, not a whole new error system."
BEAM veteran"No breaking changes to existing APIs. The FFI modules keep working. I add composition on top without touching the foundation."
Operator"Zero migration risk. Existing crash logs don't change. New code CAN use Result if it wants."

Tension: tryDo: alone provides 80% of the composability value with 0% breaking changes. The argument for native Result returns (Phases 2-3) is primarily performance — avoiding the exception construction + catch + wrap round-trip — and ergonomics — File readAll: path reading more naturally than Result tryDo: [File readAll: path]. If the performance argument is weak (error paths are already slow), tryDo:-only may be sufficient for a long time.

Tension Points

Alternatives Considered

Alternative A: Block-Based Fallbacks Only (Pharo-style)

Add ifError: variants to all fallible methods:

content := File readAll: path ifError: [:e | "{}"]
parsed := Yaml parse: content ifError: [:e | ^defaultConfig]

Rejected because: Cannot compose across function boundaries. Each fallible method needs 2+ variants (with/without ifError:), creating API explosion. The Symphony pipeline problem (andThen: chaining) cannot be solved with this approach. And it doesn't map naturally to Erlang's {ok, V} | {error, R} — there's no value to pass around or chain over.

Alternative B: Extend on:do: with Result Sugar

Make on:do: return a Result instead of re-raising:

result := [File readAll: path] asResult
result andThen: [:content | [Yaml parse: content] asResult]

Rejected because: Conflates two different semantics. on:do: catches exceptions (including bugs); Result represents expected failures. Wrapping exceptions as Results hides bugs that should crash. The tryDo: escape hatch covers the legitimate use case (wrapping legacy exception-based code) without making it the primary pattern.

Alternative C: Erlang-Style Tagged Tuples

Return raw {ok, V} / {error, R} tuples to Beamtalk code:

result := File readAll: path   // returns #(#ok, content) or #(#error, reason)
result first =:= #ok ifTrue: [process: result second]

Rejected because: Raw tuples are not objects — they don't respond to map:, andThen:, ifOk:ifError:. This violates "everything is an object" (Principle 6). Pattern matching on tuple position is fragile and unreadable. It's the anti-pattern that both Gleam and Beamtalk's object model are designed to improve upon.

Alternative D: Dual API Convention (Elixir ! Pattern)

Provide both exception-raising and Result-returning variants of every fallible method:

// Result-returning (new)
result := File readAll: path
result ifOk: [:content | process: content] ifError: [:e | handleError: e]

// Exception-raising (existing, kept as convenience)
content := File readAllOrRaise: path   // raises IOError on failure

This follows Elixir's File.read/1 vs File.read!/1 convention. The caller chooses the error handling style at the call site.

Not adopted as the primary pattern because: API surface doubles — every fallible method needs two variants. The naming convention (readAllOrRaise: or readAll!:) is un-Smalltalk-like. And unwrap already provides the "I want an exception" escape hatch: (File readAll: path) unwrap.

However: unwrap re-raises the original Exception when errReason is an Exception object — which it always is for FFI-sourced Results (via from_tagged_tuple/1 calling ensure_wrapped/1) and tryDo: Results. Full error context (class, details, hints) is preserved in these cases. The generic signal fallback only applies to explicit Result error: rawSymbol from Beamtalk code.

Alternative E: tryDo: Only (Minimal Approach)

Ship only Result.bt and tryDo:. Don't migrate any FFI modules. Users compose via tryDo: wrapping:

(Result tryDo: [File readAll: path])
  andThen: [:content | Result tryDo: [Yaml parse: content]]
  andThen: [:parsed  | self buildDefinition: parsed]

This provides composability with zero breaking changes — all existing APIs keep raising exceptions, and tryDo: wraps them into Results at the call site.

Not adopted as the final state because: Every tryDo: pays the cost of constructing a #beamtalk_error{}, raising it, catching it, and wrapping it — when the FFI could return a Result directly from {ok, V} | {error, R} without the exception round-trip. For methods called in tight loops (e.g., parsing each line of a file), this overhead matters.

However: This was considered as a cautious Phase 1 approach. Ultimately, existing callers (Symphony) are already in poor shape with ad-hoc error conventions, and deferring the FFI migration only entrenches tryDo: as a substitute convention. The ADR ships Result and migrates FFI modules together.

Alternative G: Naked Union Return Types (TypeScript Approach)

Return the value or an error object directly, without a wrapper. Callers discriminate with isKindOf: or a type annotation:

// No wrapper — return String or FileError directly
File readAll: path  // => "content..." or FileError(#file_not_found)

// Caller discriminates at runtime
content := File readAll: path
(content isKindOf: FileError)
  ifTrue: [useDefaults]
  ifFalse: [process: content]

With gradual typing, the return annotation could express the union:

class File
  readAll: path :: String -> String | IOError =>
    ...

Why TypeScript uses this: TypeScript's structural type system + exhaustiveness checking enforces that callers handle both branches at compile time. Type narrowing (if (result instanceof Error)) is syntactically lightweight. The runtime value is just the value — no wrapper object, no allocation.

Why this degrades in Beamtalk's dynamic mode:

In dynamic mode (the common case), -> String | IOError is an unenforced annotation — documentation, not a contract. Callers can ignore the error branch silently. There is no compile-time exhaustiveness check. This is strictly worse than exceptions, which at minimum surface as runtime crashes. It's also exactly what Symphony was already doing with Symbol sentinels ((result class) =:= Symbol) — the worst of the three ad-hoc conventions the ADR was written to replace.

The discrimination syntax (isKindOf:) is also heavier than TypeScript's instanceof narrowing, and the result is not composable — there is no andThen: or map: without reinventing the Result container.

The structural problem — not a maturity problem: This alternative is sometimes framed as "viable once gradual typing matures." That framing is wrong at two levels.

First, typing is always optional — dynamic-mode callers permanently exist with no exhaustiveness enforcement.

Second, and more fundamentally: Beamtalk is a live, open-world system. New classes can be created at the REPL at any time. The class hierarchy is open and mutable at runtime. Static exhaustiveness checking assumes a closed world where the set of types is fixed at compile time — an assumption Beamtalk explicitly rejects. Even fully-annotated code cannot guarantee exhaustiveness over String | IOError because new IOError subclasses can be defined mid-session. The type checker would have to re-verify all union branches on every new class definition, which is either unsound or requires whole-program re-checking on every REPL eval.

This is not a gap to close with better tooling. It is a consequence of the core design principle: the REPL is not a sandbox, it is the live system. A static exhaustiveness discipline that breaks on every new class definition is not a discipline — it is friction.

Not adopted because: Static exhaustiveness is irreconcilable with a live open-world system. The Result wrapper is the correct design for this context: it provides runtime shape guarantees ($beamtalk_class, isOk, guarded accessors) that hold regardless of typing mode or class hierarchy mutations. Composability (andThen:, map:) works at runtime without a type checker. Union type annotations could complement Result (e.g., result :: Result(String, IOError) for tooling hints) but cannot replace the runtime convention.

Alternative F: Optional/Maybe Type (Separate from Result)

Add Optional for "might be nil" and Result for "might fail with a reason":

// Optional — no error reason
name := dict at: "name"  // => Optional some: "James" or Optional none

// Result — with error reason
content := File readAll: path  // => Result ok: "..." or Result error: #file_not_found

Rejected for now: Adds complexity. Beamtalk already uses nil for absent values (Smalltalk tradition). An Optional type would compete with nil. Result can represent "absent" via Result error: #not_found. If demand emerges for a nil-safe wrapper, it can be added as a separate ADR without affecting Result.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1: Result Class + FFI Migration (M)

Ship Result and migrate all FFI modules in one step. Existing callers (including Symphony) are already in poor shape with ad-hoc error conventions — deferring the FFI migration only adds more callers to the future migration burden and allows tryDo: to become an entrenched substitute convention.

Components: stdlib (Beamtalk), runtime (Erlang), stdlib tests, e2e tests

  1. stdlib/src/Result.bt — Value class with state: declarations, ifOk:ifError:, map:, andThen:, valueOr:, valueOrDo:, unwrap, mapError:, printString. Boot order: ensure Result loads before File, Yaml, etc. in build_stdlib.rs
  2. runtime/apps/beamtalk_stdlib/src/beamtalk_result.erlfrom_tagged_tuple/1 helper, class_tryDo:/3 for tryDo: class method
  3. beamtalk_file.erl — Convert all 12 fallible methods to return Result via from_tagged_tuple/1. Each replaces a 5-line error builder chain per error case with 2-3 lines
  4. beamtalk_regex.erlRegex from: returns Result (invalid pattern is expected)
  5. beamtalk_http.erl — Network errors return Result; HTTP status codes remain on HTTPResponse
  6. beamtalk_subprocess.erl / beamtalk_reactive_subprocess.erl — Startup failures return Result
  7. Yaml/JSON parsing — Parse errors return Result
  8. stdlib/test/result_test.bt — BUnit tests for all Result methods
  9. Update stdlib tests — file, regex, HTTP, subprocess tests for Result-returning methods
  10. Update e2e teststests/e2e/cases/ for file-related cases

Phase 2: Language Features Documentation (S)

Components: docs

  1. docs/beamtalk-language-features.md — Add Result type section with examples
  2. Error handling guide — When to use Result vs exceptions, with examples
  3. FFI authoring guide — How to use from_tagged_tuple/1 in new Erlang modules

Migration Path

For Callers of File I/O Methods

// BEFORE: exception-based
content := File readAll: path
// or
content := [File readAll: path] on: IOError do: [:e | "default"]

// AFTER: Result-based
content := (File readAll: path) unwrap          // crashes on error (re-raises original Exception if errReason is one; generic signal otherwise — see Consequences §unwrap)
// or
content := (File readAll: path) valueOr: "default"  // safe fallback
// or
(File readAll: path)
  ifOk: [:content | process: content]
  ifError: [:e | handleError: e]

For FFI Module Authors

%% BEFORE: manual error builder chain
case some_erlang_call(Args) of
    {ok, Value} -> Value;
    {error, Reason} ->
        Error0 = beamtalk_error:new(some_kind, 'MyClass'),
        Error1 = beamtalk_error:with_selector(Error0, 'myMethod:'),
        Error2 = beamtalk_error:with_details(Error1, #{arg => Args}),
        Error3 = beamtalk_error:with_hint(Error2, <<"Helpful hint">>),
        beamtalk_error:raise(Error3)
end.

%% AFTER: one-line conversion
beamtalk_result:from_tagged_tuple(some_erlang_call(Args)).
%% Or with error context:
case some_erlang_call(Args) of
    {ok, Value} -> beamtalk_result:from_tagged_tuple({ok, Value});
    {error, Reason} ->
        %% Build a structured #beamtalk_error{} before passing to from_tagged_tuple/1
        Error = beamtalk_error:new(some_kind, 'MyClass'),
        Error1 = beamtalk_error:with_details(Error, #{reason => Reason}),
        beamtalk_result:from_tagged_tuple({error, Error1})
end.

Rollout

Phase 1 ships Result and migrates all FFI modules simultaneously. Existing callers (Symphony and stdlib tests) need updating, but unwrap provides a mechanical escape hatch for any call site that wants to preserve exception-raising behavior. New FFI modules must return Result by default from day one.

Implementation Tracking

Epic: BT-1253 — Result Type — Hybrid Error Handling (ADR 0060)

IssueTitleDepends on
BT-1254Result class, FFI helper, tests, and docs
BT-1255Migrate beamtalk_file.erl to ResultBT-1254
BT-1256Migrate remaining FFI modules (regex, yaml, json, http, subprocess)BT-1254

Status: Planned

References