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
-
Inconsistent object model — Inside
on:do:, errors are proper objects withmessage,kind,selector,printString. Outside, they're raw Erlang records. Same error, different representations depending on where it's caught. -
REPL leaks internals — When a runtime error like
42 foooccurs, the REPL catch wraps it as{eval_error, error, #beamtalk_error{...}}. Theformat_error_message/1clause for{eval_error, Class, Reason}callsformat_name(Reason), which falls through toio_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. -
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.
-
Flat hierarchy —
wrap/1always creates#{'$beamtalk_class' => 'Exception'}regardless of error kind. There's noRuntimeError,TypeError, orIOError— all errors look the same toon:do:class matching. -
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:
stdlib/src/Exception.bt— Class withmessage,hint,kind,selector,errorClass,printStringstdlib/src/Error.bt— Subclass of Exceptionbeamtalk_exception_handler:wrap/1— Wraps#beamtalk_error{}as tagged mapsbeamtalk_exception_handler:dispatch/3— Message dispatch for Exception objectsbeamtalk_error.erl— 15+ error kinds, structured construction, formatting
Constraints
- 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. - Erlang interop — Raw Erlang exceptions (
badarith,badmatch,function_clause) will always exist and must be handled gracefully. - Performance — Error path is already slow (stack trace capture). Wrapping overhead is negligible.
- Value types — Exception objects are tagged maps (value types), not actors.
- Backward compatibility —
on:do:andensure: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:
wrap→ensure_wrapped(idempotent — handles both pre-wrapped and raw Erlang exceptions)matches_classnow 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
- Errors are always objects — created at signal time, not catch time
MessageNotUnderstood,ZeroDivide, etc. are subclasses ofErrordoesNotUnderstand:creates aMessageNotUnderstoodand callssignalon it- The debugger receives the same object the handler would — no wrapping step
- Exception class hierarchy enables granular matching:
on: ZeroDivide do:vson: Error do: - Resumption protocol:
resume:,retry,pass— handlers can resume execution from the point of error - Adopted: Signal-time creation, class hierarchy,
on:do:matching - Deferred: Resumption protocol (see "Why Not Resumption?" below)
Elixir
- Exceptions are structs (
%RuntimeError{message: "..."}) — always objects - Raised with
raisewhich creates the struct:raise ArgumentError, message: "bad" - Caught by
try/rescuewith class matching:rescue e in ArgumentError -> - Rich hierarchy:
RuntimeError,ArithmeticError,ArgumentError,KeyError, etc. - No signal-time vs catch-time distinction — the struct IS the exception from creation
- Adopted: Class-based matching in rescue/on:do:, named error classes
Ruby
- Exceptions are objects created at raise time:
raise TypeError, "wrong type" - Class hierarchy:
Exception → StandardError → RuntimeError,TypeError,IOError, etc. $!is set during rescue but cleared after — Pry stores as_ex_for inspection- Adopted:
_errorbinding (like Pry's_ex_), familiar class names - Lesson: IRB's lack of
_ex_is a known limitation that Pry fixes
Erlang
- Exceptions are raw terms:
error(badarith),throw({not_found, Key}) - No class hierarchy — everything is atoms/tuples
- Shell displays raw terms:
** exception error: badarith - Lesson: This is the anti-pattern. Raw term display and lack of structure is what we're improving on.
Gleam
- No exceptions at all — uses
Result(Ok, Error)types for all error handling - Errors are explicit return values:
case file.read(path) { Ok(data) -> ... Error(e) -> ... } - No class hierarchy for errors — errors are just values in the Result type
- Rejected approach for Beamtalk: Beamtalk already uses Erlang's exception mechanism throughout. Converting to Result types would require rewriting all dispatch, runtime, and codegen. However, Gleam demonstrates that pure functional error handling IS viable on BEAM.
- Lesson: Result types are a valid BEAM approach but incompatible with Smalltalk's
on:do:+ supervision model that Beamtalk already uses.
Summary
| Feature | Smalltalk | Elixir | Ruby | Gleam | Erlang | Beamtalk (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 | ✅ yes | N/A | N/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:
resume:— Continue execution from the point of error with a replacement valueretry— Re-execute the protected code that failedpass— Propagate the exception to the next handler
Example in Pharo:
result := [1 / 0] on: ZeroDivide do: [:ex | ex resume: Float infinity].
"Returns infinity, continues execution"
Why this is hard on BEAM:
-
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.
-
Exception mechanism is built-in — Erlang's
error/1immediately unwinds the stack. There's no hook to intercept and redirect control flow before unwinding happens. -
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/1calls with custom signaling - Manually walk the stack, check handlers, resume or propagate
- This is 1000+ lines of runtime complexity
-
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:
- Clean slate — Restart gives you fresh state, not corrupted state patched over
- Isolation — Errors in one actor don't require defensive handling in another
- Proven at scale — WhatsApp, Discord, RabbitMQ all use supervision, not resumption
- Composable — Supervision trees organize restart strategies declaratively
What we DO adopt from Smalltalk:
- ✅ Signal-time creation (errors are objects from birth)
- ✅ Class hierarchy (
on: TypeError do:matches specific errors) - ✅
on:do:syntax (familiar to Smalltalk developers) - ✅ Object protocol (errors respond to messages like
message,hint,kind)
What we adapt to BEAM:
- ❌ Resumption → ✅ Supervision + restart (BEAM-native error recovery)
- ❌ Custom handler stack → ✅ Erlang's built-in try/catch (simpler, faster)
When resumption might return (future ADR): If there's strong demand, we could explore limited resumption for specific cases:
- Retry loops (
retryTimes:onError:) — syntactic sugar overon:do:+ recursion - Fallback values (
[expr] valueOrDefault: fallback) — pure data flow, no stack magic - Actor-level retry policies (supervisor strategies exposed to Beamtalk)
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:
- Familiar class syntax —
Supervisor subclass:looks like normal Beamtalk code - Declarative children — No manual OTP supervision spec construction
- Retry syntax — Looks like
on:do:but with automatic retry logic - ValueOrDefault — Simple pattern for "try this, or use default" without full try/catch
- REPL inspection — Smalltalk developers expect to inspect live objects
Implementation needs:
- New
Supervisorclass in stdlib (wraps OTP supervisor behavior) - Codegen for
retryTimes:onError:(desugars to recursiveon:do:) - Runtime support for supervision specs (read metadata, register with supervisor)
- REPL integration for supervision tree inspection
See: Future ADR for supervision syntax and semantics. This ADR focuses on exception objects; supervision is orthogonal.
User Impact
Newcomer (from Python/JS/Ruby)
- Signal-time objects — transparent. They see
RuntimeError,TypeError— familiar concepts. _errorbinding — matches Python's implicit__traceback__and Pry's_ex_- Inspectable errors —
_error kind,_error hint— interactive learning - Errors remain visually distinct from results (no confusion about success/failure)
Smalltalk Developer
- "Everything is an object" finally applies to errors — at raise time, not just inside
on:do: - Class hierarchy —
on: TypeError do:works like Pharo'son: ZeroDivide do: - Missing: Resumption protocol (no
resume:,retry). But BEAM's "let it crash" + supervision is the pragmatic replacement. _erroris a REPL convenience — Smalltalk developers would expect the debugger instead
Erlang/BEAM Developer
- BEAM mechanism unchanged —
error/1throws the term, try/catch catches it - The thrown term is now a tagged map instead of a record — can pattern match on
$beamtalk_class - Erlang interop: Erlang code catching Beamtalk errors sees tagged maps. Can extract inner
#beamtalk_error{}viaerrorkey. - Supervision: crash reasons are still inspectable — map structure is actually easier to read in crash logs than raw record tuples
- Raw Erlang exceptions (
badarith,badmatch) are still caught and wrapped at theon:do:catch site viaensure_wrapped/1
Production Operator
- Structured error logging — filter by
$beamtalk_classin crash logs - Granular error handling —
on: IOError do:for I/O,on: TypeError do:for validation - No performance impact — wrapping happens once at raise time, on the error path only
Steelman Analysis
"Keep Catch-Time Wrapping" (Extend Current Approach)
| Cohort | Strongest 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)
| Cohort | Strongest 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
- Signal-time wrapping changes the thrown term type — Erlang interop code that catches
#beamtalk_error{}would need updating - Hierarchy depth — Some prefer a flat
ErroroverRuntimeError → Error → Exceptionnesting - Resumption — Smalltalk purists want
resume:/retrybut BEAM can't easily support it
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
- Errors are objects everywhere — consistent with "everything is an object" principle
- Single wrapping point —
raise/1wraps once; no per-catch-site wrapping needed - Error class hierarchy —
on: TypeError do:enables granular error handling - REPL inspection —
_errorbinding for post-mortem debugging - Better display —
RuntimeError: Integer does not understand 'foo'instead of leaked Erlang syntax - Erlang interop improved — tagged maps in crash logs are more readable than record tuples
- Foundation for tooling — IDE error inspection, structured logging, error aggregation
Negative
- All
error(#beamtalk_error{})sites must change — ~54 throw sites in 9 runtime.erlfiles, plus ~17 codegen generation sites in Rust - Erlang pattern matching changes — Code matching
#beamtalk_error{}in catch must match tagged maps instead - More stdlib classes — RuntimeError, TypeError, InstantiationError (~3 new
.btfiles) _erroris a magic variable — implicit binding may surprise some users- No resumption protocol — Smalltalk's
resume:/retrynot supported on BEAM - OTP crash log format changes — Observer, sasl reports, and recon will display tagged maps instead of record tuples. Operators need updated documentation.
- Bootstrap ordering concern (Phase 2) — Error subclass processes (RuntimeError, TypeError) may not exist when the first error is raised during stdlib loading. Phase 1 is safe (wraps as
'Exception'); Phase 2'skind_to_class/1mapping must tolerate missing class processes or use fallback wrapping.
Neutral
- REPL protocol unchanged —
{"type": "error"}still used for error display #beamtalk_error{}record unchanged — still used internally, just wrapped before throwing- Supervision behavior unchanged — OTP sees a different crash reason term but behavior is the same
on:do:semantics unchanged — handlers still receive Exception objects, just no wrapping step
Implementation
Phase 1: Signal-Time Wrapping + REPL _error
Effort: M
Components affected: Runtime (Erlang), REPL, Codegen (Rust)
beamtalk_error.erl— Addraise/1that wraps and throws- Runtime
.erlfiles (~54 throw sites across 9 files) — Replaceerror(ErrorRecord)withbeamtalk_error:raise(ErrorRecord):beamtalk_dispatch.erl— method not foundbeamtalk_actor.erl— actor_dead, does_not_understandbeamtalk_list_ops.erl— type_error, bounds errors (19 sites)beamtalk_set_ops.erl— type errorsbeamtalk_file.erl— IO errors (11 sites)beamtalk_tuple_ops.erl— type errors (5 sites)beamtalk_string_ops.erl— type errorsbeamtalk_object_class.erl— class registration, method errors (8 sites)beamtalk_primitive.erl— primitive dispatch errorsbeamtalk_exception_handler.erl— signal/1, signal_message/1
beamtalk_exception_handler.erl— Addensure_wrapped/1(idempotent wrapper for Erlang exceptions)- 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)
- Direct error generation:
on:do:codegen (exception_handling.rs) — Useensure_wrappedinstead ofwrap, pass wrapped object tomatches_class- REPL (
beamtalk_repl_eval.erl,beamtalk_repl_server.erl) — Catch wrapped objects, bind to_error, format viaprintString. Also fixformat_error_message/1for{eval_error, error, #beamtalk_error{}}as immediate improvement. - Dialyzer — Update type specs for
matches_class/2,wrap/1,ensure_wrapped/1to reflect map return types - Tests — Update exception E2E tests, add
_errorREPL 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.
- New stdlib files:
stdlib/src/RuntimeError.bt,stdlib/src/TypeError.bt,stdlib/src/InstantiationError.bt beamtalk_exception_handler.erl— Addkind_to_class/1mapping inwrap/1matches_class/2— Hierarchy-aware matching (RuntimeError is-a Error is-a Exception)- Tests —
on: TypeError do:matching,on: Error do:catches all subclasses, class display in errors
Phase 3: Extended Error Classes (deferred)
Effort: S
IOErrorfor file system errors (file_not_found,permission_denied,io_error)ConcurrencyErrorfor actor/future errors (actor_dead,timeout,future_not_awaited)- Consider
MessageNotUnderstoodas RuntimeError subclass (closer to Smalltalk) - Expand kind-to-class mapping
Phase 4: Resumption Protocol (future, needs design)
Effort: L — Separate ADR required
Smalltalk's resume:, retry, pass would need:
- Handler stack implementation in the runtime
- Continuation capture (difficult on BEAM)
- Alternative: Use BEAM's process model — supervisor restarts = Erlang-native "retry"
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
tests/e2e/cases/errors.bt—ERROR:assertion format may need updating for wrapped objectsstdlib/bootstrap-test/error_method.bt— Method-level error behavior; should continue to pass- Runtime tests that catch
#beamtalk_error{}directly — Update to match wrapped objects
Implementation Tracking
Epic: BT-450 — Signal-Time Exception Objects (ADR 0015)
| Issue | Title | Size | Status |
|---|---|---|---|
| BT-451 | Signal-time wrapping: raise/1, runtime + codegen migration, REPL _error binding | L | Planned |
| BT-452 | Error class hierarchy: RuntimeError, TypeError, InstantiationError + kind_to_class mapping | M | Planned (blocked by BT-451) |
Supersedes: BT-30 (Prototype object-wrapped exceptions), BT-237 (REPL error formatting)
References
- Related ADRs: ADR 0005 (object model), ADR 0006 (dispatch), ADR 0007 (stdlib)
- Existing implementation:
beamtalk_exception_handler.erl,stdlib/src/Exception.bt,stdlib/src/Error.bt - Design doc:
docs/internal/design-self-as-object.md(§3.8 Error Taxonomy) - Test coverage:
tests/e2e/cases/errors.bt,stdlib/bootstrap-test/error_method.bt - Prior art: Pharo Exception handling, Taking Exception to Smalltalk, Elixir exceptions, Pry
_ex_