ADR 0015: Signal-Time Exception Objects and Error Class Hierarchy

Status

Implemented (2026-02-15)

Context

The Fundamental Problem

In Smalltalk, exceptions are always objects. A MessageNotUnderstood is created as an object at signal time — the thing being thrown IS the Exception object. In Beamtalk today, #beamtalk_error{} is a raw Erlang record that only becomes a Beamtalk Exception object at catch time inside on:do: blocks. Everywhere else — the REPL, production try/catch, supervision — the raw Erlang record leaks through.

This is backwards. The question isn't "should the REPL wrap errors?" — it's "at what point in the pipeline does #beamtalk_error{} become an Error object?"

Current Error Lifecycle

1. Construction:  beamtalk_error:new(does_not_understand, 'Integer')
                  → #beamtalk_error{kind, class, selector, message, hint, details}
                  (Erlang record — NOT a Beamtalk object)

2a. Dispatch return: {error, #beamtalk_error{...}}
                  (some modules return errors as values, not exceptions)

2b. Raising:      error(#beamtalk_error{...})
                  (raw Erlang record thrown via Erlang exception mechanism;
                   ~54 throw sites in runtime, ~17 generated by codegen)

3. Catch in on:do: codegen:
                  catch <Type, Error, Stack> ->
                    let ExObj = call 'beamtalk_exception_handler':'wrap'(Error) in
                    apply HandlerFun (ExObj)
                  (wrapped into Exception tagged map ONLY at this catch site)

4. Catch in REPL: catch Class:Reason:_Stacktrace ->
                    {error, {eval_error, Class, Reason}, State}
                  (NEVER wrapped — format_name/1 falls through to ~p,
                   leaks raw Erlang record syntax)

5. Catch in production try/catch:
                  (raw Erlang record — no Beamtalk object at all)

What's Wrong

  1. Inconsistent object model — Inside on:do:, errors are proper objects with message, kind, selector, printString. Outside, they're raw Erlang records. Same error, different representations depending on where it's caught.

  2. REPL leaks internals — When a runtime error like 42 foo occurs, the REPL catch wraps it as {eval_error, error, #beamtalk_error{...}}. The format_error_message/1 clause for {eval_error, Class, Reason} calls format_name(Reason), which falls through to io_lib:format("~p", [Reason]) for records, displaying raw Erlang record syntax. Note: a direct #beamtalk_error{} clause exists (line 819) but is never reached because the eval error path wraps it in the {eval_error, ...} tuple first.

  3. Violates "everything is an object" — Beamtalk's core design principle (from Smalltalk) says everything the user encounters should be an object they can send messages to.

  4. Flat hierarchywrap/1 always creates #{'$beamtalk_class' => 'Exception'} regardless of error kind. There's no RuntimeError, TypeError, or IOError — all errors look the same to on:do: class matching.

  5. No post-error inspection — After an error in the REPL, the error object is gone. Users can't examine what went wrong without wrapping in on:do: first.

What Smalltalk Does

In Pharo Smalltalk, the exception lifecycle is:

1. Object created:    MessageNotUnderstood new
                      (Exception object exists BEFORE signaling)

2. Signaled:          exception signal
                      (the OBJECT is what traverses the handler stack)

3. Handler receives:  [:e | e message]
                      (same object — no wrapping step)

4. Debugger receives: same object
                      (fully inspectable, resumable)

Key insight: there is no "raw error" that later gets wrapped. The Exception object IS the error from the very beginning. The signal method searches the handler stack with the object itself.

Current Infrastructure

The wrapping machinery already exists but is only used in on:do: catch clauses:

Constraints

  1. BEAM exception mechanism — Must use Erlang's error/1, throw/1, exit/1. Cannot implement a custom handler stack like Smalltalk's VM-level support.
  2. Erlang interop — Raw Erlang exceptions (badarith, badmatch, function_clause) will always exist and must be handled gracefully.
  3. Performance — Error path is already slow (stack trace capture). Wrapping overhead is negligible.
  4. Value types — Exception objects are tagged maps (value types), not actors.
  5. Backward compatibilityon:do: and ensure: codegen must continue working.

Decision

Core Principle: Errors Are Objects From Birth

Every #beamtalk_error{} is wrapped as an Exception object at the point of raising, not at the point of catching.

This means the term passed to Erlang's error/1 is already a Beamtalk object — a tagged map with $beamtalk_class set to the appropriate error class.

1. Signal-Time Wrapping via beamtalk_error:raise/1

A new function beamtalk_error:raise/1 replaces all error(#beamtalk_error{}) calls:

%% beamtalk_error.erl — NEW
-spec raise(#beamtalk_error{}) -> no_return().
raise(#beamtalk_error{} = Error) ->
    Wrapped = beamtalk_exception_handler:wrap(Error),
    error(Wrapped).

Before (current):

%% Scattered across runtime — ~54 throw sites in .erl files, ~17 generated in codegen
Error0 = beamtalk_error:new(does_not_understand, 'Integer'),
Error1 = beamtalk_error:with_selector(Error0, 'foo'),
error(Error1)   %% ← raw Erlang record thrown

After:

Error0 = beamtalk_error:new(does_not_understand, 'Integer'),
Error1 = beamtalk_error:with_selector(Error0, 'foo'),
beamtalk_error:raise(Error1)   %% ← Exception object thrown

Now everything that catches this error — on:do:, REPL, production code — receives a proper Exception object.

2. Codegen Changes

Generated Core Erlang code that raises errors changes from:

%% Before: raw error record
call 'erlang':'error'(Error2)

to:

%% After: raise wraps as object
call 'beamtalk_error':'raise'(Error2)

The on:do: catch clause changes in two ways:

  1. wrapensure_wrapped (idempotent — handles both pre-wrapped and raw Erlang exceptions)
  2. matches_class now receives the wrapped object instead of the raw error (semantic change — enables hierarchy-aware matching on $beamtalk_class)
%% Before: wrap at catch time, match on raw error
catch <Type, Error, Stack> ->
    let ExObj = call 'beamtalk_exception_handler':'wrap'(Error) in
    let Match = call 'beamtalk_exception_handler':'matches_class'(ExClass, Error) in
    case Match of
        true  -> apply HandlerFun (ExObj)
        false -> primop 'raw_raise'(Type, Error, Stack)
    end

%% After: ensure_wrapped (idempotent), match on wrapped object
catch <Type, Error, Stack> ->
    let ExObj = call 'beamtalk_exception_handler':'ensure_wrapped'(Error) in
    let Match = call 'beamtalk_exception_handler':'matches_class'(ExClass, ExObj) in
    case Match of
        true  -> apply HandlerFun (ExObj)
        false -> primop 'raw_raise'(Type, Error, Stack)
    end

ensure_wrapped/1 is idempotent — already-wrapped objects pass through, raw Erlang exceptions get wrapped:

ensure_wrapped(#{'$beamtalk_class' := _} = Already) -> Already;
ensure_wrapped(#beamtalk_error{} = Error) -> wrap(Error);  %% safety net
ensure_wrapped(Other) -> wrap(Other).  %% raw Erlang exception

3. Error Class Hierarchy

Expand the hierarchy to distinguish error categories:

Exception                    (base — catches everything in on:do:)
└── Error                    (non-resumable — maps to Erlang's error class)
    ├── RuntimeError         (general runtime errors)
    │   ├── does_not_understand
    │   ├── arity_mismatch
    │   └── immutable_value
    ├── TypeError            (type violations)
    │   └── type_error
    ├── InstantiationError   (wrong construction method)
    │   └── instantiation_error
    └── IOError              (file system errors — future)
        ├── file_not_found
        ├── permission_denied
        └── io_error

Each stdlib class is a .bt file:

// stdlib/src/RuntimeError.bt
Error subclass: RuntimeError
  describe => 'a RuntimeError'

// stdlib/src/TypeError.bt
Error subclass: TypeError
  describe => 'a TypeError'

// stdlib/src/InstantiationError.bt
Error subclass: InstantiationError
  describe => 'an InstantiationError'

The kind → class mapping lives in beamtalk_exception_handler:

wrap(#beamtalk_error{kind = Kind} = Error) ->
    ClassName = kind_to_class(Kind),
    #{'$beamtalk_class' => ClassName, error => Error}.

kind_to_class(does_not_understand) -> 'RuntimeError';
kind_to_class(arity_mismatch) -> 'RuntimeError';
kind_to_class(immutable_value) -> 'RuntimeError';
kind_to_class(type_error) -> 'TypeError';
kind_to_class(instantiation_error) -> 'InstantiationError';
kind_to_class(file_not_found) -> 'IOError';
kind_to_class(permission_denied) -> 'IOError';
kind_to_class(io_error) -> 'IOError';
kind_to_class(_) -> 'Error'.

4. matches_class/2 Updated for Hierarchy

The exception matching in on:do: blocks respects the class hierarchy:

%% Updated to handle wrapped objects AND hierarchy
matches_class(ExClass, #{'$beamtalk_class' := ObjClass, error := Error}) ->
    matches_hierarchy(ExClass, ObjClass, Error);
matches_class(ExClass, #beamtalk_error{} = Error) ->
    %% Fallback for unwrapped errors (shouldn't happen after Phase 1)
    matches_class(ExClass, wrap(Error));
matches_class(nil, _) -> true;
matches_class(_, _) -> true.  %% Unknown → catch for safety

matches_hierarchy(nil, _, _) -> true;
matches_hierarchy('Exception', _, _) -> true;
matches_hierarchy('Error', _, _) -> true;  %% Error catches all Error subclasses
matches_hierarchy(FilterClass, ObjClass, _Error) when FilterClass =:= ObjClass -> true;
matches_hierarchy('RuntimeError', ObjClass, _Error) ->
    %% RuntimeError only catches RuntimeError, not TypeError/InstantiationError
    ObjClass =:= 'RuntimeError';
matches_hierarchy('TypeError', ObjClass, _Error) -> ObjClass =:= 'TypeError';
matches_hierarchy('InstantiationError', ObjClass, _Error) -> ObjClass =:= 'InstantiationError';
matches_hierarchy('IOError', ObjClass, _Error) -> ObjClass =:= 'IOError';
matches_hierarchy(_FilterClass, _ObjClass, _Error) -> false.
// Catches all errors (including RuntimeError, TypeError, etc.)
[42 foo] on: Error do: [:e | e class]
// => RuntimeError

// Catches only TypeError
['hello' + 42] on: TypeError do: [:e | e message]
// => Expected Integer argument for '+' on String

// RuntimeError doesn't catch TypeError
['hello' + 42] on: RuntimeError do: [:e | e message]
// => (uncaught — re-raises)

5. REPL: _error Binding for Post-Mortem Inspection

As a consequence of signal-time wrapping, the REPL naturally gets inspectable errors:

%% beamtalk_repl_eval.erl catch clause — Error IS already an object
catch error:Reason:_Stacktrace ->
    ExObj = beamtalk_exception_handler:ensure_wrapped(Reason),
    %% Bind to _error in session state
    NewBindings = maps:put('_error', ExObj, Bindings),
    FinalState = beamtalk_repl_state:set_bindings(NewBindings, NewState),
    {error, ExObj, FinalState}
> 42 foo
RuntimeError: Integer does not understand 'foo'
  Hint: Check spelling or use 'respondsTo:' to inspect available methods

> _error kind
// => does_not_understand

> _error message
// => Integer does not understand 'foo'

> _error errorClass
// => Integer

> _error selector
// => foo

6. Display Format

Error display changes from raw Erlang records to class-prefixed messages:

> 42 foo
RuntimeError: Integer does not understand 'foo'
  Hint: Check spelling or use 'respondsTo:' to inspect available methods

> Actor new
InstantiationError: Cannot create Actor with 'new'
  Hint: Use 'spawn' to create a new actor

> 'hello' + 42
TypeError: Expected Integer argument for '+' on String

Format: <ClassName>: <message>\n Hint: <hint> (hint line only when present).

7. Non-REPL Contexts

Production code with on:do::

// Granular error handling
[parseUserInput: data] on: TypeError do: [:e |
  Logger warn: 'Bad input: ' , e message
  defaultValue
]

Erlang interop (try/catch):

%% Erlang code catching Beamtalk errors
try SomeBeamtalkCall of
    Result -> Result
catch
    error:#{'$beamtalk_class' := Class, error := Error} ->
        %% Full access to structured error
        io:format("~s: ~s~n", [Class, Error#beamtalk_error.message]);
    error:RawReason ->
        %% Non-Beamtalk error
        io:format("Erlang error: ~p~n", [RawReason])
end

Supervision: No change — OTP supervisors see {error, WrappedObject, [...]} instead of {error, #beamtalk_error{}, [...]}. The crash reason is still pattern-matchable.

Prior Art

Pharo Smalltalk

Elixir

Ruby

Erlang

Gleam

Summary

FeatureSmalltalkElixirRubyGleamErlangBeamtalk (proposed)
Errors are objects✅ always✅ structs✅ always❌ Result types❌ raw terms✅ signal-time
Class hierarchy✅ rich✅ rich✅ rich❌ none❌ none✅ RuntimeError, TypeError, etc.
Signal-time creation✅ yes✅ yes✅ yesN/AN/A✅ via raise/1
Class-based matching✅ on:do:✅ rescue✅ rescue❌ pattern match❌ pattern match✅ on:do: + hierarchy
REPL inspection✅ debugger❌ manual✅ Pry ex❌ N/A❌ none✅ _error binding
Resumption✅ resume:❌ no❌ no❌ no❌ no❌ deferred

Why Not Resumption? (And Why Supervision Is Better)

Smalltalk's resumption protocol (resume:, retry, pass) allows exception handlers to:

Example in Pharo:

result := [1 / 0] on: ZeroDivide do: [:ex | ex resume: Float infinity].
"Returns infinity, continues execution"

Why this is hard on BEAM:

  1. No first-class continuations — Smalltalk VMs have built-in continuation support. BEAM does not. Resuming from an arbitrary point requires capturing and restoring the call stack as data.

  2. Exception mechanism is built-in — Erlang's error/1 immediately unwinds the stack. There's no hook to intercept and redirect control flow before unwinding happens.

  3. Would require custom handler stack — Smalltalk maintains an explicit handler stack in the VM. On BEAM, we'd need to:

    • Store handlers in process dictionary or state
    • Replace all error/1 calls with custom signaling
    • Manually walk the stack, check handlers, resume or propagate
    • This is 1000+ lines of runtime complexity
  4. Conflicts with BEAM's design philosophy — Erlang's entire error model is "let it crash, restart clean." Resumption fights this by trying to patch up corrupted state and continue.

Why supervision + restart is the right model for BEAM:

Erlang's "let it crash" philosophy is fundamentally different from resumption but equally powerful:

// Smalltalk approach: catch and resume
connection := [Database connect: config]
  on: ConnectionTimeout do: [:ex | ex resume: cachedConnection].

// BEAM approach: supervisor restarts failed process
// If Database actor crashes (timeout, connection refused, etc.),
// the supervisor automatically restarts it with clean state.
// The client just waits or retries — no manual error patching.

Benefits of the BEAM model:

What we DO adopt from Smalltalk:

What we adapt to BEAM:

When resumption might return (future ADR): If there's strong demand, we could explore limited resumption for specific cases:

But general resume: that continues from arbitrary error points is fundamentally incompatible with BEAM's execution model.

Making Supervision Intuitive (Future Work)

The gap: Smalltalk developers think "catch and fix." BEAM developers think "let it crash and restart clean." We need Smalltalk-friendly syntax for supervision trees.

Proposed Beamtalk patterns (needs separate ADR + implementation):

// NOTE: This is pseudocode/future syntax. List literals (#(...)), tuple
// literals, and supervision DSL are not yet implemented in Beamtalk.

// 1. Supervisor as a class (declarative)
Supervisor subclass: WebApp
  children: #(
    #{#class => DatabasePool, #restartStrategy => #permanent},
    #{#class => HTTPRouter, #restartStrategy => #transient},
    #{#class => MetricsCollector, #restartStrategy => #temporary}
  )
  strategy: #oneForOne
  maxRestarts: 5
  restartWindow: 60

// 2. Actor-level supervision spec (metadata)
Actor subclass: Worker
  supervisionPolicy: #{
    #restart       => #transient,
    #maxRestarts   => 5,
    #restartWindow => 60
  }

// 3. Retry patterns (syntactic sugar)
result := [Database query: sql]
  retryTimes: 3
  onError: [:e | e isA: ConnectionTimeout]
  backoff: [:attempt | attempt * 1000]  // exponential backoff

// 4. Fallback chains (error -> default)
data := [API fetchUser: id]
  valueOrDefault: cachedUser
  onError: [:e | Telemetry record: e]

// 5. Supervision from REPL (inspection)
supervisor := WebApp supervise.
supervisor children.           // => #(DatabasePool, HTTPRouter, MetricsCollector)
supervisor restartCount: 'DatabasePool'.  // => 3
supervisor strategyFor: 'HTTPRouter'.     // => #transient

Why this bridges the gap:

Implementation needs:

See: Future ADR for supervision syntax and semantics. This ADR focuses on exception objects; supervision is orthogonal.

User Impact

Newcomer (from Python/JS/Ruby)

Smalltalk Developer

Erlang/BEAM Developer

Production Operator

Steelman Analysis

"Keep Catch-Time Wrapping" (Extend Current Approach)

CohortStrongest argument
⚙️ BEAM veteran"Signal-time wrapping means every error/1 call changes. Catch-time is more conservative — just add wrapping where needed."
🏭 Operator"Fewer changes = fewer regressions. Catch-time wrapping at REPL + on:do: covers the important cases."
🎨 Language designer"Keeping #beamtalk_error{} as the thrown term means Erlang code can pattern match on it without learning tagged maps."

Rebuttal: Catch-time wrapping means N catch sites, each needing wrap/1. Signal-time means 1 raise/1 function that wraps once. And the Erlang interop argument cuts both ways — tagged maps with $beamtalk_class are MORE informative than raw records for Erlang code reading crash logs.

"Errors as Results, Not Exceptions" (Functional Approach)

CohortStrongest argument
⚙️ BEAM veteran"Elixir uses {:ok, value} / {:error, reason} — explicit error handling is better than exceptions."
🎩 Smalltalk purist"In Smalltalk, doesNotUnderstand: IS a message send that returns a value. Errors can be values."
🎨 Language designer"No special error path needed. Simpler runtime. Everything is a message send."

Rebuttal: Beamtalk already uses exceptions (Erlang's error mechanism) everywhere. Converting to result types would require rewriting all dispatch, all runtime code, and all codegen. The on:do: + supervision model is well-proven on BEAM.

Tension Points

Alternatives Considered

Alternative A: Catch-Time Wrapping Only (Extend Current)

Add wrap/1 calls to REPL and recommend it in production try/catch. Keep error(#beamtalk_error{}) as the thrown term.

%% Each catch site wraps independently
catch error:Reason:_ST ->
    ExObj = beamtalk_exception_handler:wrap(Reason),  %% needed at every catch

Rejected because: Multiple wrapping sites to maintain. Inconsistent — sometimes you get an object, sometimes a raw record, depending on who catches it. Not "everything is an object."

Alternative B: Replace #beamtalk_error{} Record Entirely

Don't use Erlang records at all — construct the Exception tagged map directly in beamtalk_error:new/2:

new(Kind, Class) ->
    #{'$beamtalk_class' => kind_to_class(Kind),
      kind => Kind, class => Class, message => generate_message(Kind, Class, undefined),
      hint => undefined, selector => undefined, details => #{}}.

Rejected because: Too invasive. The #beamtalk_error{} record provides compile-time field checking in Erlang code (records are tuples with named fields). Keeping the record as the internal representation and wrapping in raise/1 preserves this safety while presenting objects to Beamtalk code.

Alternative C: Return Error Objects as REPL Results

When the REPL catches an error, return it as the result value instead of on the error path:

> 42 foo
=> RuntimeError: Integer does not understand 'foo'

Rejected because: Blurs success/error distinction. Users can't tell if an expression succeeded or failed. The REPL protocol distinction (type: "result" vs type: "error") is valuable for CLI rendering (colors, exit codes).

Alternative D: Minimal REPL Fix Only

Fix the format_error_message/1 bug directly — add a clause to extract #beamtalk_error{} from {eval_error, error, Reason}:

format_error_message({eval_error, error, #beamtalk_error{} = Error}) ->
    iolist_to_binary(beamtalk_error:format(Error));

Not rejected — but insufficient. This fixes the display bug (~5 lines, immediate value) and should be done regardless. However, it doesn't address the core issue: errors are not objects outside on:do:, there's no _error inspection, and no class hierarchy. This is a good first commit within Phase 1, not an alternative to the decision.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1: Signal-Time Wrapping + REPL _error

Effort: M

Components affected: Runtime (Erlang), REPL, Codegen (Rust)

  1. beamtalk_error.erl — Add raise/1 that wraps and throws
  2. Runtime .erl files (~54 throw sites across 9 files) — Replace error(ErrorRecord) with beamtalk_error:raise(ErrorRecord):
    • beamtalk_dispatch.erl — method not found
    • beamtalk_actor.erl — actor_dead, does_not_understand
    • beamtalk_list_ops.erl — type_error, bounds errors (19 sites)
    • beamtalk_set_ops.erl — type errors
    • beamtalk_file.erl — IO errors (11 sites)
    • beamtalk_tuple_ops.erl — type errors (5 sites)
    • beamtalk_string_ops.erl — type errors
    • beamtalk_object_class.erl — class registration, method errors (8 sites)
    • beamtalk_primitive.erl — primitive dispatch errors
    • beamtalk_exception_handler.erl — signal/1, signal_message/1
  3. beamtalk_exception_handler.erl — Add ensure_wrapped/1 (idempotent wrapper for Erlang exceptions)
  4. Codegen (dispatch_codegen.rs, intrinsics.rs, gen_server/*.rs, primitive_implementations.rs, value_type_codegen.rs) — Two kinds of changes:
    • Direct error generation: call 'erlang':'error'(Error2)call 'beamtalk_error':'raise'(Error2) (~17 sites)
    • Dispatch error conversion: <{'error', Error, _}> -> call 'erlang':'error'(Error)call 'beamtalk_error':'raise'(Error) (~3 sites in dispatch_codegen.rs)
  5. on:do: codegen (exception_handling.rs) — Use ensure_wrapped instead of wrap, pass wrapped object to matches_class
  6. REPL (beamtalk_repl_eval.erl, beamtalk_repl_server.erl) — Catch wrapped objects, bind to _error, format via printString. Also fix format_error_message/1 for {eval_error, error, #beamtalk_error{}} as immediate improvement.
  7. Dialyzer — Update type specs for matches_class/2, wrap/1, ensure_wrapped/1 to reflect map return types
  8. Tests — Update exception E2E tests, add _error REPL test

Phase 2: Error Class Hierarchy

Effort: S

Note: Phase 2 introduces a bootstrap ordering concern — kind_to_class/1 maps error kinds to class names like 'RuntimeError', but these class processes may not exist during early startup (before stdlib loads). The wrapping itself (tagged map creation) works without the class process, but dispatch on the wrapped object would fail. Implementation must either: (a) ensure error subclasses load before any user code, or (b) have kind_to_class/1 fall back to 'Error' if the class isn't registered yet.

Note: The matches_hierarchy/3 implementation shown in §4 is deliberately hardcoded for the initial set of error classes. True dynamic hierarchy walking for value types is not yet defined in ADR 0006 (Unified Method Dispatch). If user-defined exception subclasses are needed in the future, this should be revisited with a proper hierarchy walking mechanism. For the initial 4 error classes (RuntimeError, TypeError, InstantiationError, IOError), hardcoding is pragmatic and sufficient.

  1. New stdlib files: stdlib/src/RuntimeError.bt, stdlib/src/TypeError.bt, stdlib/src/InstantiationError.bt
  2. beamtalk_exception_handler.erl — Add kind_to_class/1 mapping in wrap/1
  3. matches_class/2 — Hierarchy-aware matching (RuntimeError is-a Error is-a Exception)
  4. Testson: TypeError do: matching, on: Error do: catches all subclasses, class display in errors

Phase 3: Extended Error Classes (deferred)

Effort: S

  1. IOError for file system errors (file_not_found, permission_denied, io_error)
  2. ConcurrencyError for actor/future errors (actor_dead, timeout, future_not_awaited)
  3. Consider MessageNotUnderstood as RuntimeError subclass (closer to Smalltalk)
  4. Expand kind-to-class mapping

Phase 4: Resumption Protocol (future, needs design)

Effort: L — Separate ADR required

Smalltalk's resume:, retry, pass would need:

Migration Path

Runtime Erlang Code

All error(#beamtalk_error{})beamtalk_error:raise(#beamtalk_error{}). Mechanical replacement.

Generated Code

Codegen changes from call 'erlang':'error'(ErrorVar) to call 'beamtalk_error':'raise'(ErrorVar). Recompile all .bt files.

Erlang Interop

Code that catches Beamtalk errors via pattern matching on #beamtalk_error{} in catch clauses must update to match tagged maps:

%% Before
catch error:#beamtalk_error{kind = Kind} -> ...

%% After
catch error:#{'$beamtalk_class' := _, error := #beamtalk_error{kind = Kind}} -> ...

Existing Tests

Implementation Tracking

Epic: BT-450 — Signal-Time Exception Objects (ADR 0015)

IssueTitleSizeStatus
BT-451Signal-time wrapping: raise/1, runtime + codegen migration, REPL _error bindingLPlanned
BT-452Error class hierarchy: RuntimeError, TypeError, InstantiationError + kind_to_class mappingMPlanned (blocked by BT-451)

Supersedes: BT-30 (Prototype object-wrapped exceptions), BT-237 (REPL error formatting)

References