ADR 0094: Object String Representation Protocols (printString / displayString / inspect)

Status

Implemented (2026-06-07) — structural printString/displayString protocols, canonical beamtalk_object_printer renderer, kind-headed actor/supervisor labels (BT-2462, BT-2504)

Context

The problem

Beamtalk inherits Smalltalk's default object descriptor: an instance with no custom printing renders as "a ClassName" ("a Point", "a Counter"). This is the first thing a developer sees when they evaluate a fresh domain object at the REPL, and it is almost useless — it tells you the class (which you usually already know) and nothing about the instance's state.

Three problems compound this:

  1. The default is worse than Pharo's. Pharo's Object>>printOn: is article-aware (an OrderedCollection). Beamtalk's default (Object.bt:92) is a bare "a " ++ self class printString with no vowel handling, so it produces ungrammatical output like "a Integer", "a Object".

  2. The good output exists but is unreachable by default. Value already has a structural inspect (Value.bt:63) that produces ClassName(field: value, ...) — exactly the descriptor we want. But it is wired to inspect, and nothing on the default display path calls inspect. The REPL renders Value results by dispatching printString as a message send (beamtalk_repl_json.erl:316), falling back to beamtalk_runtime_api:print_string/1 if dispatch fails. Actor instances are rendered by a separate hard-coded path that bypasses printString entirely. Either way, the structural output from inspect is invisible unless the user explicitly types someValue inspect. The Value.bt doc comment even claims Point new // => Point(x: 0, y: 0), but the live REPL shows a Point.

  3. Three overlapping protocols with no clear contract. Beamtalk has printString, displayString, and inspect, and developers cannot tell when to use which. displayString defaults to printString; inspect defaults to printString. In practice developers only ever reach for printString and never understand what displayString is for — yet displayString is silently invoked by string interpolation on every {...} segment (expressions.rs:92), so it is in fact pervasively used, just never named. And inspect is named like it should do something rich (open an inspector, as it does in Pharo) but is typed to return a plain String — the name promises tooling, the type delivers text.

Current state

There are multiple parallel implementations that must be reconciled:

A canonical structural renderer must be chosen and the redundant paths removed or delegated to it — this ADR must specify which becomes canonical.

Several platform-primitive wrapper classes already diverge from "a Foo" by delegating to their Erlang identity: Pid, Port, Reference, and Tuple do printString => self asString, yielding #Pid<0.123.0>, #Port<0.5>, etc.

Additionally, beamtalk_repl_json.erl has a third rendering path that bypasses printString entirely for actor and supervisor instances:

This hard-coded REPL formatting is inconsistent with the printString result (which returns "a Counter") and must be reconciled.

Constraints

Decision

Adopt a two-string-protocol model (printString = Debug, displayString = Display), drop the a/an prefix entirely, and make a derived structural descriptor the default for Value.

Scope note: the redesign of inspect into a tooling/action verb is deferred to a separate follow-up ADR, to be designed alongside the LiveView live-tooling surface (now under way). inspect is barely used today, so leaving it as-is for now carries no cost, and its right shape depends on what the live UI actually needs. This ADR therefore makes inspect a no-op concern: it continues to return a String (delegating to printString), and is not changed here. This deliberately keeps the breaking change out of this ADR — see Alternatives ("Split the bundle").

1. Protocol roles

ProtocolRole (Rust analogue)ReturnsWho calls itOverride when
printStringDebugStringyou, the REPL, logs, and any other printString that nests this objectalmost never — it is derived
displayStringDisplayStringthe language (string interpolation {...}), user-facing output surfacesthe object has a natural human form (e.g. Money → $10.50)
inspect(unchanged — deferred)String (for now)you, explicitlyfuture LiveView-driven ADR will make it a tooling/action verb

2. Default printString by class kind

The a/an prefix is removed entirely. No vowel logic is needed because the prefix is gone.

Three visually distinct forms, one per category of thing:

Class kindDefault printStringFormRationale
ValueClassName(field: value, ...)class-headed, labelled fieldsimmutable field: data — show the data
ActorActor(ClassName, pid)kind-headed, positionala live process; the kind matters, state is behind the message boundary
SupervisorSupervisor(ClassName, pid) / DynamicSupervisor(ClassName, pid)kind-headed, positionala supervising process; ancestry determines the kind head
Object (plain reference)ClassName, or a class-defined formbare labelno Beamtalk-visible fields and not a process; per-class override where a handle is meaningful

Examples:

Point x: 3 y: 4              // => Point(x: 3, y: 4)
Point new                   // => Point(x: 0, y: 0)
Message sender: "Alice" text: "Hi"   // => Message(sender: "Alice", text: "Hi")
Point new: #{}              // (no fields) => Point()

counter := Counter spawn    // => Actor(Counter, 0.123.0)
sup := MySup startLink      // => Supervisor(MySup, 0.200.0)
pool := MyPool startLink    // => DynamicSupervisor(MyPool, 0.201.0)

Why the Kind(Class, pid) family. The three categories — data, live process, plain reference — read as three distinct shapes:

A live actor is categorically different from a reference-Object handle (a process with a mailbox and lifecycle vs. a synchronous wrapper around an external Erlang resource), so it earns a distinct, self-labelling shape rather than sharing a generic ClassName<id> form with handles.

Distinguishing Value from process forms. Both are Word(...), but: Value forms carry field: labels (Point(x: 3, y: 4)) while process forms are positional (Actor(Counter, 0.123.0)); and the process heads (Actor/Supervisor/DynamicSupervisor) are reserved kind words that no user Value class may shadow. The combination makes the two unambiguous.

Rejected delimiters. Counter@0.123.0 (Erlang node@host style) was rejected — a bare Class@x.y.z reads as a version (Counter@0.1.0), obscuring that it is a live actor. Counter<0.123.0> was rejected because </> are comparison operators and the bare angle form doesn't signal "actor" strongly enough. The existing #-prefixed #Actor<...> was preferred over @ but still loses to the Kind(...) family, which drops the foreign # Erlang sigil and uses Beamtalk's native ().

# on raw primitives unchanged. The bare platform primitives keep their Erlang-native rendering (#Pid<0.123.0>, #Port<0.5>, #Ref<0.1.2.3>) — these are Erlang terms, so the #…<> form is correct for them. The consequence: a domain actor and its raw underlying pid render differently (Actor(Counter, 0.123.0) vs #Pid<0.123.0>), which is useful — they are genuinely different things (a typed actor vs. a raw process handle).

Replaces the existing REPL format. This supersedes the hard-coded #Actor<ClassName,PID> / #Supervisor<ClassName,PID> paths in beamtalk_repl_json.erl (~123 test assertions). Those paths are replaced by printString dispatch (with the timeout-fallback in Critical Risks), so the REPL and printString produce identical output — fixing today's split where actor printString returned "a Counter" while the REPL rendered #Actor<Counter,_>.

3. Structural format: ClassName(field: value, ...)

Chosen over %ClassName{...} (Elixir) and #ClassName{...} (Erlang record) because:

Fields are rendered in sorted order for deterministic output, each value rendered via its own printString (Debug form — strings stay quoted inside structural output). This is a deliberate change from today: Value.bt:65 currently calls (self fieldAt: name) inspect for nested values, and beamtalk_object_ops.erl calls inspect_field which sends inspect. Under the new model, nested rendering calls printString, and Value's inspect simply delegates to printString (same output) — decoupling structural recursion from inspect so the deferred inspect redesign can't break it.

4. Nested rendering: bounded recursion

Nested values expand recursively (so Line(from: Point(x: 0, y: 0), to: Point(x: 3, y: 4)) shows in full), bounded by:

When any bound is hit, the truncated position renders as .... Common cases (a Line of two Points, a small record-of-records) expand fully; only genuinely large or cyclic graphs are elided. Values are immutable trees post-ADR 0042, so cycles are rare among Values; the guard primarily protects against actor/collection references.

5. Relationship to asString and the Printable protocol

asString is a separate conversion method (not a display protocol) that returns a "natural" string form — 42 asString"42", #foo asString"foo". It is not defined on Object and only exists on classes where a string conversion is meaningful. displayString and asString overlap for many types (both return "42" for an integer) but diverge where displayString is for human display and asString is for lossless conversion (e.g. a hypothetical Color might have asString → "#FF0000" and displayString → "red"). This ADR does not change asString.

The Printable protocol (ADR 0068) requires asString and printString — both remain string-returning methods, so protocol conformance is unaffected. inspect is not part of any protocol and its contract change creates no conformance issues. displayString is also not part of Printable; whether to add it is deferred.

6. Reconcile the two implementations

The compiled stdlib (Object.bt, Value.bt) and the runtime fallback (beamtalk_object_ops.erl, beamtalk_primitive.erl, beamtalk_reflection.erl) must produce byte-identical output for the same value. The structural renderer (format, sorting, bounds, cycle guard) is implemented once as a shared runtime primitive and called from both paths. All Core Erlang fragments are built via the Document/typed-leaf API.

Prior Art

LanguageDebug reprDisplay repr"Inspect" actionWhat we take
Smalltalk (Pharo/Squeak)printString / printOn:a Foo by default; library classes override heavilydisplayString / displayNbinspect opens an Inspector window (side-effecting)We keep inspect as the action (faithful to Pharo), but make the default string useful instead of a Foo.
Elixirinspect/1 / Inspect protocol → %Struct{...}, derivable, bounded (:limit)to_string/1 / String.CharsIO.inspect/2 (prints + returns value for pipelining)Structural-by-default, bounded recursion, the Debug/Display split. We map InspectprintString, String.CharsdisplayString.
RustDebug ({:?}), #[derive(Debug)]Point { x: 3, y: 4 }Display ({}), hand-writtendbg!() macroThe clean two-trait split: derive Debug, hand-write Display. This is the core of our model.
Erlang~p#rec{}, <0.1.0>, #Port<...>~s (iolist)observer / reconThe #…<> form for raw handles; the Kind(Class, pid) shape generalises Erlang's habit of tagging opaque terms by kind.
Gleamstring.inspectPoint(3, 4)to_string per-typeConfirms ClassName(...) reads naturally on the BEAM.

Net: we adopt Rust's two-trait conceptual split, Elixir's structural-default + bounded recursion, Erlang's handle notation for opaque instances, and Smalltalk's inspect-as-action — rejecting only Smalltalk's a Foo default and the foreign %/# sigils.

User Impact

Steelman Analysis

Keep a Foo (status quo default)

%ClassName{...} (Elixir sigil)

#ClassName{...} (Erlang record)

Collapse to two string protocols (drop inspect entirely / alias it)

Tension points

Alternatives Considered

See Steelman Analysis for a Foo, %Struct{}, #Record{}, and delete-inspect. Also rejected: top-level-only (depth-1) nesting (rejected in favour of bounded recursion because depth-1 loses detail exactly where nested data is most interesting). Three further alternatives deserve explicit treatment:

Minimal fix: just repoint printString to the existing structural logic

The narrowest change that solves the stated problem: override Value printString to call the structural logic that already exists in Value inspect, and override Actor printString to produce Actor(ClassName, pid). Two method overrides plus a handful of test updates. No new shared renderer, no displayString re-documentation, no inspect contract change, no Printable impact.

Separate Inspectable protocol (Elixir's actual model)

Rather than repurpose inspect, introduce a distinct Inspectable protocol (à la Elixir's Inspect), leaving Printable and the existing inspect method untouched. Tooling dispatches Inspectable.

Actor format: alternatives to Actor(Class, pid)

Three other shapes for the live-process form were weighed:

Decision: Actor(Class, pid) — kind-headed, positional, native brackets. The kind label is what makes a live process recognisable; the () keeps it in Beamtalk's idiom; raw primitives keep their #Pid<…> Erlang form, so an actor and its raw pid read distinctly (intended).

Consequences

Positive

Negative

Neutral

Implementation

High-level, roughly phased. Phases 1-2 must be completed together (the structural renderer is useless without callers). Phase 4 (inspect) can be deferred independently.

  1. Shared structural renderer (runtime): one Erlang primitive producing ClassName(field: value, ...) with sorted fields, per-field printString dispatch, and the depth/width/length/cycle bounds. Used by both compiled Value stdlib and the runtime beamtalk_object_ops fallback. Primitive-backed collections (Array, List, Set, Dictionary, Bag, String) already override printString and will not use this renderer.
  2. printString defaults + REPL unification:
    • Repoint Value printString to the structural renderer.
    • Repoint Object.bt:92 from "a " ++ ... to self class name asString (bare class name, no article).
    • Update beamtalk_object_ops.erl printString/displayString clauses to produce Actor(ClassName, pid) for actors, Supervisor(ClassName, pid) / DynamicSupervisor(ClassName, pid) for supervisors, and bare ClassName for plain objects.
    • Remove the hard-coded REPL formatting paths in beamtalk_repl_json.erl (#Actor<...>, #Supervisor<...>) and replace them with printString dispatch, so actor formatting is unified.
    • Keep Pid/Port/Reference/Tuple delegating to asString (already consistent — #Pid<0.123.0> etc.).
  3. displayString: keep default-to-printString; document the interpolation-hook role in Object.bt and the language docs.
  4. inspect: out of scope for this ADR. Left returning String (delegating to printString). Its redesign into a tooling/action verb is a follow-up ADR designed against the LiveView surface. The only change here is ensuring Value's inspect delegates to printString (same output) so structural recursion no longer depends on inspect.
  5. Test migration: update ~9 BUnit assertions expecting "a Foo", ~123 REPL protocol test assertions expecting #Actor<...>, and any Rust codegen golden tests.
  6. Docs: update Printable.bt, Value.bt/Object.bt doc comments (the Value.bt comment already claims the structural output — it becomes true), docs/beamtalk-language-features.md, and docs/development/surface-parity.md (display output is a cross-surface behaviour).

Affected components: stdlib (.bt), runtime (beamtalk_object_ops, beamtalk_primitive, beamtalk_reflection, beamtalk_runtime_api, beamtalk_repl_json), codegen (string-interpolation displayString dispatch is unchanged but verified), and the broad test corpus.

Critical implementation risks

These were surfaced in review and must be handled or the change introduces latent runtime crashes:

  1. Circular migration of inspect (applies to the deferred follow-up ADR). Value.bt:65 calls (self fieldAt: name) inspect recursively, and beamtalk_object_ops inspect_field sends inspect. This ADR pre-empts the problem by switching the structural renderer's field recursion to printString now (while inspect still returns a string), so the later inspect contract change cannot break structural printing.
  2. Latent gradual-typing crash (applies to the deferred follow-up ADR). Number.bt:94 (and 13 others) define inspect -> String => self printString. Removing these later makes 42 inspect return the receiver/inspector, not "42"; because typing is gradual, any remaining ... ++ x inspect compiles but crashes at runtime. The follow-up ADR must find every inspect-result-used-as-string site before removing the overrides.
  3. Actor printString dispatch safety (resolved: timeout-with-fallback). Replacing the hard-coded #Actor<...> REPL path with printString dispatch means contacting the actor process, which can block or fail if the actor is busy/dead/mid-call. Decision: the display path attempts printString dispatch with a short timeout, and falls back to a tuple-derived Actor(ClassName, pid) (read directly from the #beamtalk_object{} tuple, no message round-trip) on timeout/error/dead-process. Consequences:
    • Default actors (no printString override): the dispatch result is Actor(ClassName, pid), byte-identical to the fallback — so the fast path and the dispatch path show the same thing. In practice the implementation can skip dispatch entirely for un-overridden actors.
    • Custom-printString actors: the override is honoured when the actor is responsive (the normal case); only an unresponsive actor degrades to Actor(ClassName, pid) — and reading its custom state was unsafe in exactly that case anyway.
    • The REPL therefore never hangs on a wedged actor, and never silently shows stale/empty state.
  4. Canonical renderer. Exactly one of beamtalk_primitive:print_string, beamtalk_object_ops:dispatch(inspect,...), and beamtalk_reflection:inspect_string/1 must become canonical; the others must delegate to it or be deleted. The ADR mandates byte-identical output — three independent formatters cannot guarantee that.
  5. Stale runtimeCalledSelectors (applies to the deferred follow-up ADR). SystemNavigation.bt:2006 lists #inspect as a runtime-invoked selector (so unusedSelectors doesn't flag inspect overrides). This stays correct for now (the inspector surface still invokes the method). When inspect is repurposed, this entry must be re-evaluated.
  6. inspect name collision (applies to the deferred follow-up ADR). The REPL already has an "op": "inspect" protocol operation (beamtalk_repl_ops_actors) that calls sys:get_state — unrelated to the Beamtalk inspect method. When the follow-up repurposes the method as "open tooling", the relationship between the two must be documented (and ideally unified) for contributors. SystemNavigation.bt:2006 runtimeCalledSelectors lists #inspect and must be re-evaluated at that point.

Migration Path

References