ADR 0108: Named Union Type Aliases (type Declarations)

Status

Accepted (2026-07-15)

Implementation Tracking

Epic: BT-2893 Issues:

PhaseIssueTitleSizeBlocked by
1BT-2894AST, parser, unparse for type Name = ...M
2BT-2895Alias table + resolve_type_annotation integrationMBT-2894
3BT-2896Cycle detection + topological sort (batch resolution)MBT-2895
4BT-2897Display-name provenance for hover/diagnosticsSBT-2895
5BT-2898Export, seeding, internal modifier, leakage checkMBT-2895
6BT-2899Alias-name → annotation-site hot-reload re-check triggerMBT-2895, BT-2896
7BT-2900Named -type emission in spec_codegen.rsSBT-2895
8BT-2901LSP: completions, go-to-definition, find-referencesMBT-2895, BT-2899
8BT-2902REPL: :help, type input, display-value confirmationSBT-2895
8BT-2903System Browser browse-type-aliases + VS Code sidebarMBT-2898
9BT-2904Docs: language features, syntax rationale, surface paritySBT-2901, BT-2902, BT-2903
10BT-2905E2E btscript test (required final phase)SBT-2899, BT-2900, BT-2901, BT-2902

Status: Planned

Downstream: BT-2827 (Json.parse:/Yaml.parse:/HTTPResponse.bodyAsJson typed returns) is blocked by this epic and will retarget once it lands.

Context

Problem statement

Beamtalk can express anonymous structural unions in any type position — Integer | String, String | Nil, and (since BT-2627) closed singleton unions like #temporary | #transient | #permanent — but has no way to name one and reuse it. Every signature that accepts a restart policy must spell out the full union:

restart: policy :: #temporary | #transient | #permanent => ...
defaultPolicy -> #temporary | #transient | #permanent => #transient
escalate: from :: #temporary | #transient | #permanent
       to: to :: #temporary | #transient | #permanent => ...

This is not hypothetical — it is the actual supervision-policy idiom in the stdlib docs today (docs/beamtalk-language-features.md §Union Types uses exactly this example, and class supervisionPolicy returns exactly this atom set). BT-2618 (tightening over-broad Symbol stdlib annotations to singleton unions) is blocked in practice on this verbosity: repeating a three-member union across many signatures is error-prone (miss one member in one place and two "identical" annotations silently diverge), and there is no single declaration site to hang documentation on or check exhaustiveness against.

Current state

Constraints

Decision

Add transparent type aliases, declared with a top-level type declaration:

/// How a supervised child restarts after exit.
type RestartStrategy = #temporary | #transient | #permanent

An alias names an existing TypeAnnotation — nothing more. At annotation-resolution time (resolve_type_annotation), a reference to RestartStrategy expands to the structural union it names, so every downstream system — narrowing, the \/& algebra, advisory and asserted exhaustiveness, spec generation — operates on the exact same InferredType::Union shape it handles today, unchanged. The alias name is retained as display metadata so hover and diagnostics can render RestartStrategy rather than (or alongside) the expansion.

This is the TypeScript literal-union-alias model and the Erlang -type restart() :: temporary | transient | permanent. model: aliases are about naming, not about identity. There is no new kind of type.

Semantics

Exhaustiveness — inherited, not new

Because the alias resolves to a closed singleton union, both existing checks apply with zero new checker machinery:

type Direction = #north | #south | #east | #west

heading :: Direction := readHeading

heading match: [
  #north -> 0;
  #south -> 180;
  #east  -> 90
]
// ⚠ Warning: non-exhaustive match: `#west` is not handled
//   (residual type: `#west`)

heading matchExhaustive: [
  #north -> 0;
  #south -> 180;
  #east  -> 90
]
// ⛔ Error: non-exhaustive matchExhaustive: `#west` is not handled
//   (residual type: `#west`)

Adding a member to the alias declaration makes every matchExhaustive: over it non-exhaustive in one edit — the single biggest ergonomic win, and the reason BT-2628 was filed. The severity policy is exactly ADR 0102 §4 / ADR 0106's, untouched.

Narrowing and the set-theoretic algebra

Expansion happens before the algebra runs, so ADR 0102's operators need no changes:

policy :: RestartStrategy := readPolicy
policy =:= #temporary ifTrue: [ ... ] ifFalse: [
  // policy :: #transient | #permanent  — difference computed on the expansion
]

Display through normalisation — scoped honestly. The alias name is shown when the type at hand is structurally identical to the alias expansion (hover on policy at its declaration shows RestartStrategy (#temporary | #transient | #permanent)). Residuals are not guaranteed to carry the name: ADR 0102's difference/intersect/union_of are normalising functions that build fresh Union/Negation values, flattening and deduplicating members — nothing in that algebra propagates a display name, and on dedup between two aliases sharing a member there is no principled owner. So the false branch above hovers as the structural #transient | #permanent, v1. Propagating an (from RestartStrategy) breadcrumb through the operators is a compatible later enhancement, not a v1 commitment — promising it would mean re-plumbing every 0102 operator for a cosmetic gain.

Surface syntax: type Name = ...

Two candidate spellings were seriously considered (see Steelman Analysis for the full argument):

// Chosen — dedicated declaration form
type RestartStrategy = #temporary | #transient | #permanent

// Rejected — keyword-message style, following `Protocol define:`
Type define: RestartStrategy as: #temporary | #transient | #permanent

The type form is chosen because:

  1. An alias has no message surface. Classes and protocols are runtime-reflective objects — Counter methods, Printable requiredMethods — so declaring them with a message-send-shaped form (subclass:, define:) is an honest affordance. A transparent alias vanishes at resolution; it is never a receiver, and dressing it as a message send to a pseudo-class Type (which is not a class) would be a false affordance.
  2. Every reference language spells aliases as a dedicated form. Erlang -type restart() :: ... ., Gleam type X = ... / pub type X { ... }, TypeScript type X = ..., Elixir @type. A newcomer from any of them guesses this syntax cold.
  3. One-liner ergonomics are the point. The feature exists to make naming a union cheap; a three-keyword message form works against the grain of its own motivation.
  4. Parsing is contained. type becomes a contextual keyword only in declaration position (start of a top-level statement, followed by an uppercase identifier and =). It remains a legal identifier everywhere else — no reserved word is added, and no existing code can break. Precedent: =>, ::, ->, and ^ already establish that Beamtalk uses non-message syntax where declarations, not messages, are being written (docs/beamtalk-syntax-rationale.md §Class Definition: Message Send → Syntax makes exactly this argument for subclass: itself being parsed syntax).

REPL session

> type Direction = #north | #south | #east | #west
=> Direction
> d :: Direction := #north
=> #north
> d matchExhaustive: [#north -> 0; #south -> 180; #east -> 90]
⛔ Error: non-exhaustive matchExhaustive: `#west` is not handled
   (residual type: `#west`)
> d matchExhaustive: [#north -> 0; #south -> 180; #east -> 90; #west -> 270]
=> 0

What does type Direction = ... evaluate to? The => Direction shown above is a REPL display convention, not a derived runtime value — a type declaration is total compile-time erasure (Semantics), so there is no alias object to echo. Echoing the declared name is the natural choice (parallel to Actor subclass: Counter returning/displaying Counter, and to :help's existing declaration-echo convention), but this ADR does not finalize it: CLAUDE.md's REPL-output rule is explicit that any REPL display value, prompt format, or output behaviour needs confirmation with the maintainer rather than being assumed by an implementer, and carries its own e2e test coverage once decided. The implementing issue must confirm this value explicitly — and, per surface parity (docs/development/surface-parity.md), whatever is decided must agree across CLI REPL, MCP, and any LSP-side "evaluate" affordance.

"What does this alias map to?" — :help <Alias>. The existing :help <Name> command (which already renders class and protocol docs) is extended to alias names, showing the declaration, expansion, and doc comment:

> :help RestartStrategy
type RestartStrategy = #temporary | #transient | #permanent

  How a supervised child restarts after exit.

Declared in: stdlib/src/Supervisor.bt:12

For an alias with no doc comment, the block below the declaration line is omitted entirely — just type Name = ... followed by the Declared in: line — matching how :help already renders an undocumented class or method (declaration only, no blank comment section).

A Haskell-style :t <expr> (type-of-expression) command is not part of this ADR: :t is already taken in the Beamtalk REPL as the alias for :test, and ADR 0102 explicitly deferred a :type-style command as its own surface-parity decision (docs/development/surface-parity.md) — that deferral stands. Alias introspection (:help) is in scope here because the declaration is the feature; expression-level type queries remain the separate, broader decision.

Error examples

// Unknown alias — the existing unresolved-class diagnostic (ADR 0100)
restart: policy :: RestartStrateg => ...
// ⛔ Error: unknown type `RestartStrateg` (did you mean `RestartStrategy`?)

// Collision with an existing class/protocol name
type String = Symbol
// ⛔ Error: `String` is already defined as a class

// Reference cycle
type Ab = Bc | Integer
type Bc = Ab | Symbol
// ⛔ Error: type alias cycle: `Ab` → `Bc` → `Ab`

// Single-letter name — reserved for type parameters (ADR 0068)
type T = Integer | Nil
// ⛔ Error: single-letter type names are reserved for type parameters;
//    choose a longer name (e.g. `type OptionalInt = Integer | Nil`)

// Unbound type variable on the RHS — parametric aliases not yet supported
type Timeout = Integer | T
// ⛔ Error: unbound type parameter `T` in type alias `Timeout`;
//    parametric aliases (`type Name(T) = ...`) are not yet supported

// Value outside the named union — existing union-membership diagnostics,
// now phrased with the alias name
p :: RestartStrategy := #premanent
// ⚠ Warning: `#premanent` is not a member of `RestartStrategy`
//   (#temporary | #transient | #permanent) — did you mean `#permanent`?

Prior Art

LanguageApproachWhat we take / leave
Erlang/Elixir typespecs (-type / @type)Transparent aliases over structural types; -type restart() :: temporary | transient | permanent. is literally this feature for the same underlying atom sets. Dialyzer expands aliases before analysis.Adopt wholesale as the semantic model — Beamtalk singletons are Erlang atoms, and this is how the BEAM ecosystem has always named atom sets. Confirms transparent-alias is the BEAM-native answer. We also gain what typespecs have: a named -type can be emitted in generated specs for FFI readability.
TypeScript (literal-union type aliases)type Direction = 'n' | 's' | 'e' | 'w' — transparent, structural, erased; tooling displays the alias name; exhaustiveness via narrowing + satisfies never.Adopt the alias-with-display-name model and the composition with an opt-in exhaustiveness assertion (Beamtalk already has matchExhaustive:, our analogue of satisfies never, ADR 0106). TS proves at scale that named literal unions cover the enum use case without nominal enums.
Rust / Swift enumsNominal, closed sum types with distinct identity; exhaustive match by default; variants can carry payloads.Leave. Their model requires runtime identity (discriminant tags) and default-strict exhaustiveness — both incompatible with type erasure (the value is the atom) and with gradual, advisory-by-default checking (ADR 0100). Payload-carrying variants are already served by sealed Value subclass: + constructor patterns (ADR 0060/0107).
GleamNominal custom types (type Season { Spring Summer }) — closed, exhaustive by construction; also has transparent type aliases for naming.Split the difference knowingly: Gleam's custom types are the nominal road we decline (its exhaustiveness comes from nominal closedness; ours comes from the ADR 0102 structural algebra). Gleam's aliasestype Headers = List(#(String, String)) — are exactly this ADR.
Smalltalk / NewspeakNo static types, no aliases; atom-set idioms are documented in comments and method names.No prior art to preserve — like ADR 0102, this is purely additive edit-time tooling with zero runtime footprint.

User Impact

Steelman Analysis

Chosen: transparent alias, type Name = ... syntax

Rejected A: nominal enum (distinct identity, opaque)

Rejected B: keyword-message declaration syntax (Type define: X as: ...)

Rejected C: do nothing (keep anonymous unions only)

Tension points

Alternatives Considered

See Steelman Analysis above — nominal enums (A), keyword-message declaration syntax (B), and status quo (C) were all considered and rejected in favour of transparent aliases with a type declaration, for the reasons given there. Two further variants were examined and dropped briefly:

Hybrid: transparent assignability, nominal diagnostics/exhaustiveness

The genuine midpoint between the alias and the nominal enum: keep structural assignability and narrowing (so FFI and the ADR 0102 algebra are untouched), but track alias identity as a first-class fact for diagnostics, hover, and exhaustiveness — closer to how Elm or OCaml render aliases, and a road that could later support Meters/Feet branding as an opt-in. This deserves naming because the chosen design already contains its seed: display-name provenance is nominal-for-display. The distinction is degree — the hybrid makes the name definitional (every operator must preserve and reason about it; exhaustiveness keyed on "the alias's member set" rather than the structural expansion), where the chosen design makes it cosmetic (operators stay name-blind; v1 display is scoped to structurally-identical types). Not chosen because the definitional version re-opens exactly the costs of Rejected A at the operator level — every difference/intersect/union_of call must decide what happens to the name, dedup between overlapping aliases needs an ownership rule, and exhaustiveness gains nothing (matchExhaustive: over the expansion is already the full guarantee; a name-keyed check would be the same computation with extra bookkeeping). If branded types are ever wanted, they should be their own ADR with this section as the starting point — the alias grammar and registry built here are forward-compatible with that extension.

Class-shaped declaration (sealed Union subclass: RestartStrategy)

Modelling the enum as a class-family (one class per member, or a sealed class with singleton instances) — how a pure Smalltalk would do it. Rejected: the values must remain plain atoms for FFI (supervisionPolicy returns #permanent today and Erlang supervisors consume the atom); wrapping them in instances breaks every existing call site and the FFI boundary. Payload-carrying sum types are already served by sealed Value subclass: + constructor patterns (ADR 0060/0107) — that lane exists and is not this feature.

New declaration operator (RestartStrategy ::= ...)

A dedicated top-level ::= binding form. Rejected: invents a novel token for no expressive gain over type ... =, with no precedent in any reference language a user might arrive from.

Consequences

Positive

Negative

Neutral

Implementation

To be broken into an epic via /plan-adr once Accepted. Expected shape:

References