ADR 0068: Parametric Types and Protocols

Status

Accepted | Implemented (2026-03-20)

Context

Phases 1 and 2 of ADR 0025 (Gradual Typing) are complete — the compiler infers types from known classes and supports optional :: Type annotations that generate Dialyzer -spec attributes. However, two critical features remain unimplemented: parametric (generic) types and structural protocols.

The Immediate Problem: Result Loses Type Information

ADR 0060 introduced Result as a sealed Value class for expected-failure handling. Every accessor returns Object, erasing the wrapped type:

result := File read: "config.json"   // Type checker sees: Result
config := result unwrap              // → Object (not String!)
config size                          // ⚠️ No completions, no type checking

The Result class declares field: okValue :: Object because there is no way to express "the type the caller put in." The same problem affects map:, andThen:, valueOr:, and every other Result combinator. ADR 0060 explicitly acknowledges this: "Future parameterized types (-> Result(String, IOError)) will be needed for full type safety."

This is not just a Result problem — it applies to any container or wrapper type:

stack := Stack new             // Stack of what?
stack push: 42
item := stack pop              // → Object, not Integer
item + 1                       // ⚠️ No checking — Object doesn't have '+'

The Broader Gap: Protocols Need Generics

ADR 0025 Phase 3 defines structural protocols — named message sets like Printable (requires asString) and Collection (requires size, do:, collect:). But useful protocol definitions require type parameters:

// Without generics, Collection's collect: can't express its return type
Protocol define: Collection
  size -> Integer
  do: block :: Block
  collect: block :: Block -> Self   // collect: returns... Self of what element type?

// With generics, we can express the relationship
Protocol define: Collection(E)
  size -> Integer
  do: block :: Block(E, Object)
  collect: block :: Block(E, Object) -> Self

Generics are a prerequisite for expressive protocols. This ADR therefore addresses generics first, then protocols, as an integrated type system extension.

Current Infrastructure

The AST already defines TypeAnnotation::Generic { base, parameters, span } but it is never produced by the parser — only constructible programmatically. The type checker has two escape hatches that bail out on non-simple types:

ClassDefinition has no type_params field. Classes cannot declare themselves as generic.

Constraints

  1. Type erasure (ADR 0025): All type information is compile-time only. Zero runtime cost.
  2. Warnings, not errors (ADR 0025): Type mismatches produce warnings, never block compilation.
  3. Gradual adoption: Untyped code must continue working unchanged. Result ok: 42 without annotations stays valid.
  4. BEAM integration: Generic annotations should generate Dialyzer -spec attributes for FFI/interop (see Dialyzer section below).
  5. Forward compatibility: The design must support future protocol bounds (T :: Printable) without breaking changes.

Decision

Beamtalk adopts declaration-site parametric types with compile-time substitution and structural protocols, implemented in two ordered stages. Type parameters use parenthesis syntaxResult(T, E) — keeping < reserved exclusively as a binary message (comparison operator).

Stage 1: Parametric Types (Generics)

Class-Level Type Parameter Declaration

Classes declare type parameters after the class name using parentheses:

sealed Value subclass: Result(T, E)
  field: okValue :: T = nil
  field: errReason :: E = nil

  sealed unwrap -> T =>
    self.isOk ifTrue: [
      self.okValue
    ] ifFalse: [(Erlang beamtalk_result) unwrapError: self.errReason]

  sealed map: block :: Block(T, R) -> Result(R, E) =>
    self.isOk ifTrue: [Result ok: (block value: self.okValue)] ifFalse: [self]

  sealed andThen: block :: Block(T, Result(R, E)) -> Result(R, E) =>
    self.isOk ifTrue: [block value: self.okValue] ifFalse: [self]

Type parameters are bare uppercase identifiers (by convention single letters: T, E, K, V, R). They appear in:

Usage-Site Type Application

When using a generic class as a type annotation, concrete types replace the parameters:

// Annotating a method parameter
processResult: r :: Result(Integer, Error) -> Integer =>
  r unwrap + 1   // ✅ r unwrap is Integer, Integer has '+'

// Annotating a method return type (propagates to all callers)
readConfig -> Result(String, IOError) => File read: "config.json"

// Annotating state
Actor subclass: Cache(K, V)
  state: store :: Dictionary(K, V) = Dictionary new

Type Inference Through Generics

The type checker performs positional substitution: when it encounters Result(String, IOError), it maps T → String, E → IOError, and substitutes through all method signatures of Result:

// computeSomething declares -> Result(Integer, Error)
r := computeSomething
r unwrap          // Return type T → Integer ✅
r map: [:v | v asString]   // Block param T → Integer, return Result(String, Error)
r error           // Return type E → Error ✅

When the concrete type parameters are unknown — because the value comes from a method whose return type is bare Result with no type params — they fall back to Dynamic, preserving the current behavior:

// someMethod's return type is just -> Result (no type params declared)
r := someMethod
r unwrap                   // → Dynamic (T is unknown — no inference context)
r unwrap + 1               // No warning — Dynamic bypasses checking
// 💡 Hint: someMethod returns unparameterized Result — consider annotating
//    its return type (-> Result(Integer, Error)) to enable type checking

// Fix: annotate the method's return type (propagates to all callers)
someMethod -> Result(Integer, Error) => ...
r := someMethod
r unwrap + 1               // ✅ Integer has '+'

Note that constructor calls like Result ok: 42 do infer type params from their arguments (see Constructor Type Inference below). The Dynamic fallback only applies when type params are genuinely unknowable — typically from unparameterized method return types or Erlang FFI calls.

Constructor Type Inference

For named constructors (ok:, error:, new), the compiler infers type parameters from the argument types:

r := Result ok: 42                  // Inferred: Result(Integer, Dynamic)
r unwrap                            // → Integer ✅

r2 := Result error: #file_not_found // Inferred: Result(Dynamic, Symbol)
r2 error                            // → Symbol ✅

This is limited to direct constructor calls with literal or already-typed arguments. Complex expressions fall back to Dynamic parameters.

Dialyzer Spec Generation (FFI/Interop Boundary)

Dialyzer specs serve the BEAM interop boundary, not pure Beamtalk code. For Beamtalk-to-Beamtalk calls, the Beamtalk type checker is the primary tool — it understands class hierarchies, message sends, sealed classes, and doesNotUnderstand: overrides. Dialyzer sees only BEAM bytecode (gen_server:call, map operations) and knows none of this.

Dialyzer specs are valuable when:

Generic types generate expanded Dialyzer specs with concrete types substituted:

processResult: r :: Result(Integer, Error) -> Integer => r unwrap + 1

Generates:

-spec processResult(#{
  '__class__' := 'Elixir.Result',
  'okValue' := integer(),
  'errReason' := any()  % Error maps to any() in Dialyzer
}) -> integer().

Unresolved type parameters map to any() in Dialyzer specs.

Runtime Type Representation

Beamtalk does not fully erase types at the BEAM level. Every compiled class exports __beamtalk_meta/0 containing method_info maps with return_type and param_types entries (used by REPL chain completion, :help, and CompiledMethod introspection — see ADR 0045). This means generic type information must survive into the runtime representation, or introspection will lie about method signatures.

Parameterized methods store type parameter references, not erased none:

%% __beamtalk_meta/0 for Result(T, E)
#{method_info => #{
    'unwrap' => #{arity => 0, return_type => {type_param, 'T', 0}, ...},
    'error'  => #{arity => 0, return_type => {type_param, 'E', 1}, ...},
    'map:'   => #{arity => 1,
                  return_type => {generic, 'Result', [{type_param, 'R', -1}, {type_param, 'E', 1}]},
                  param_types => [{generic, 'Block', [{type_param, 'T', 0}, {type_param, 'R', -1}]}],
                  ...}
  },
  type_params => ['T', 'E'],   %% declared type parameter names
  ...
}

The {type_param, Name, Index} tagged tuple preserves the parameter name and its position in the class's type parameter list (-1 for method-local params like R in map:). This enables:

The method_return_types map on the class gen_server (used for fast chain-completion lookups) stores the tagged tuples directly. The chain-resolution code in beamtalk_repl_ops_dev.erl gains a substitution step: if it encounters {type_param, _, Index}, it looks up the concrete type from the caller's annotation context.

This is NOT reified generics (Java's alternative to erasure). There are no runtime type checks, no generic type tags on instances, no instanceof Result(Integer, Error). The type parameter metadata lives on the class (in method_info), not on instances. It's introspection data for tooling, not a runtime type system.

Contrast with Java's erasure problem: Java erased generics from .class files but kept instanceof, getClass(), and reflection APIs that expected to find them — a mismatch. Beamtalk stores type params in method_info (which tooling already reads) and doesn't pretend they're absent. No mismatch, no lies.

REPL Examples

> r := Result ok: 42
=> Result ok: 42

> r unwrap
=> 42
// Type info: Integer (inferred from Result(Integer, Dynamic))

> r map: [:v | v asString]
=> Result ok: "42"
// Type info: Result(String, Dynamic)

> r2 :: Result(String, IOError) := File read: "test.txt"
=> Result ok: "hello world"

> r2 unwrap size
=> 11
// Type info: Integer (String >> size -> Integer)

Error Examples

// When type params are known, checking works through them
r :: Result(Integer, Error) := computeSomething
r unwrap ++ " suffix"
// ⚠️ Warning: Integer does not respond to '++'
//    Did you mean '+'?

// Mismatched type application
x :: Result(Integer, Error) := Result ok: "hello"
// ⚠️ Warning: Result(Integer, Error) expected Integer for T, got String

Union Type Checking

Union types (Integer | String, String | False) are already parsed and stored in the AST as TypeAnnotation::Union. The type checker currently skips them (_ => return). Stage 1 adds proper union checking: a message send on a union-typed value warns unless all members of the union respond to that selector.

// All members must respond to the message
x :: Integer | String := getValue
x asString             // ✅ Both Integer and String have asString
x size                 // ⚠️ Warning: Integer does not respond to 'size'
                       //    (String does, but Integer doesn't)
x + 1                  // ⚠️ Warning: String does not respond to '+'
                       //    (Integer does, but String doesn't)

The nullable pattern (String | nil) is the most common union — it's Beamtalk's Option/Maybe type. nil in type position resolves to UndefinedObject (the singleton's class), just as nil in expression position evaluates to the singleton instance. Without union checking, these are invisible to the type system:

name :: String | nil := dictionary at: "name"
name size              // ⚠️ Warning: UndefinedObject does not respond to 'size'
                       //    Hint: check for nil before sending 'size'

Similarly, false in type position resolves to False — used for Erlang FFI patterns where functions return false on failure:

entry :: Tuple | false := ErlangLists keyfind: key   // lists:keyfind returns false on miss

Union + narrowing compose — this is where both features pay off together:

name :: String | nil := dictionary at: "name"
name isNil ifTrue: [^"unknown"]
name size              // ✅ name is narrowed to String — nil eliminated by early return

Return type of union message sends: When a message is valid on all union members but returns different types, the return type is the union of return types. (Integer | Float) abs returns Integer | Float (both have abs returning their own type). If all members return the same type, the return type is that type: (Integer | String) asString returns String.

Union representation in InferredType: Unions are represented as a new variant alongside Known and Dynamic:

enum InferredType {
    Known { class_name: EcoString, type_args: Vec<InferredType>, provenance: TypeProvenance },
    Union { members: Vec<InferredType>, provenance: TypeProvenance },
    Dynamic,
}

Control Flow Narrowing (Simple Cases)

When the type checker recognises a type-testing message send followed by ifTrue: / ifFalse:, it narrows the variable's type inside the block scope:

// class identity check — narrows to exact class
process: x :: Object =>
  x class = Integer ifTrue: [
    x + 1          // ✅ x is Integer here — has '+'
  ]
  x + 1            // ⚠️ x is Object here — no narrowing outside the block

// kind check — narrows to class including subclasses
process: x :: Object =>
  x isKindOf: Number ifTrue: [
    x abs           // ✅ x is Number here
  ]

// early return narrows the rest of the method
validate: x :: Object =>
  x isNil ifTrue: [^nil]
  x doSomething    // ✅ x is non-nil for the remainder — narrowed by early return

Supported narrowing patterns (Stage 1):

PatternNarrows toScope
x class = Foo ifTrue: [...]x is Foo in true blockTrue block only
x isKindOf: Foo ifTrue: [...]x is Foo in true blockTrue block only
x isNil ifTrue: [^...]x is non-nil after the statementRest of method
x isNil ifTrue: [^...] ifFalse: [...]x is non-nil in false blockFalse block

Not supported in Stage 1:

These can be added incrementally — each new pattern is a new AST shape to recognise, not a new mechanism.

What Is NOT Included in Stage 1

Design Challenges

The following challenges were identified during design and have specific solutions. These are not deferred — they must be addressed in Stage 1.

Challenge 1: Method-Local Type Parameters (the R Problem)

Result's map: method introduces a type variable R that is not a type parameter of Result:

sealed map: block :: Block(T, R) -> Result(R, E) =>
  self.isOk ifTrue: [Result ok: (block value: self.okValue)] ifFalse: [self]

R is the block's return type — unknown until the call site. This is effectively a generic method, not just a generic class.

Solution: Implicit method-local type params via call-site inference. Any identifier in type position that is neither a known class/protocol name nor a class-level type parameter is treated as a method-local type parameter. Its value is inferred from the arguments at each call site:

r :: Result(Integer, Error) := computeSomething

// At this call site, the block returns String, so R = String
r map: [:v | v asString]
// Type checker infers: Block(Integer, String) → R = String → Result(String, Error)

// At this call site, the block returns Integer, so R = Integer
r map: [:v | v + 1]
// Type checker infers: Block(Integer, Integer) → R = Integer → Result(Integer, Error)

This is lightweight call-site unification — the type checker matches the argument types against the parameter's generic type to solve for unknown variables. It is not full Hindley-Milner inference; it only solves variables that appear in parameter positions and can be determined from the provided arguments.

When a method-local type param cannot be inferred (no matching argument), it falls back to Dynamic:

// R cannot be inferred if the block is stored in a variable
myBlock := [:v | v asString]
r map: myBlock    // Block type params unknown → R = Dynamic → Result(Dynamic, Error)

Challenge 2: Block Is Not a Normal Generic Class

Block is sealed Object subclass: Block with @intrinsic methods. Blocks take 0, 1, or 2+ arguments — they have variable-arity type parameters, which can't be expressed as a fixed class-level Block(A, R).

Solution: Block type params are special-cased in the type checker. Block(...) in a type annotation is not treated as a regular generic class application. Instead, the type checker interprets it as:

The last type parameter is always the return type; all preceding ones are argument types. Block.bt itself is not modified to declare type params — the type checker handles Block as a built-in generic form, similar to how TypeScript treats function types (a: A) => R specially rather than as a generic class.

This special-casing is limited to Block only. All other generic types are regular declaration-site generics.

Challenge 3: Self Type with Generic Type Arguments

The existing Self return type resolves to the receiver's class name via string comparison. With generics, Self must carry type arguments:

Collection(E) subclass: Array(E)
  // Inherited from Collection: select: -> Self
  // For Array(Integer), Self should be Array(Integer), not bare Array

arr :: Array(Integer) := Array withAll: #[1, 2, 3]
filtered := arr select: [:x | x > 1]
// filtered should be Array(Integer), not Array(Dynamic)

Solution: Extend InferredType::Known to carry optional type arguments and provenance.

/// Tracks where a type came from — enables precise error messages
/// and determines how far inference should propagate.
enum TypeProvenance {
    Declared(Span),      // user wrote :: Type at this location
    Inferred(Span),      // compiler inferred from expression at this location
    Substituted(Span),   // derived from a generic substitution at this location
}

enum InferredType {
    Known {
        class_name: EcoString,
        type_args: Vec<InferredType>,  // empty for non-generic types
        provenance: TypeProvenance,
    },
    Union {
        members: Vec<InferredType>,    // e.g., [Known("String"), Known("False")]
        provenance: TypeProvenance,
    },
    Dynamic,
}

Provenance tracks whether a type was explicitly declared by the user, inferred by the compiler, or derived from a generic substitution. This serves two purposes:

  1. Error messages distinguish declared vs inferred types:

    // Declared — user owns the assertion
    x :: Result(Integer, Error) := someMethod
    x unwrap ++ "hello"
    // ⚠️ Warning: Integer does not respond to '++'
    //    Note: x declared as Result(Integer, Error) on line 1
    
    // Inferred — compiler guessed, user can override
    x := Result ok: 42
    x unwrap ++ "hello"
    // ⚠️ Warning: Integer does not respond to '++'
    //    Note: x inferred as Result(Integer, Dynamic) from constructor on line 1
    //    Hint: add a type annotation if this inference is wrong
    
  2. Expected-type propagation uses provenance to calibrate confidence. Both declared and inferred types propagate forward through chains, but error messages always trace back to the origin and tell the user whether they wrote the type or the compiler guessed it. This lets users decide whether to trust the inference or anchor it with an explicit annotation.

When resolving Self for a receiver with known type args, the type args propagate:

This also enables generic type flow through chains: r map: [:v | v asString] returns Result(String, Error), and further calls on that result carry String and Error through.

Challenge 4: Generic Inheritance and Superclass Type Application

When a generic class extends another, the type parameter mapping must be explicit:

// Array passes its E to Collection's E
Collection(E) subclass: Array(E)

// IntArray fixes E to Integer
Collection(Integer) subclass: IntArray

// SortedArray passes E through and adds a constraint (Stage 2)
Array(E) subclass: SortedArray(E)

The superclass in the class header is now a type application, not just a name. When arr :: Array(Integer) calls a method inherited from Collection, the type checker must:

  1. Know that Array(E) extends Collection(E) — so Array's E maps to Collection's E
  2. Substitute E → Integer through Collection's method signatures too

Solution: Store the superclass type application in ClassInfo.

struct ClassInfo {
    // ... existing fields ...
    type_params: Vec<EcoString>,
    superclass_type_args: Vec<TypeParamMapping>,  // how our params map to super's params
}

For Collection(E) subclass: Array(E), Array's superclass_type_args records that E (position 0) maps to Collection's position 0. For Collection(Integer) subclass: IntArray, the mapping is a concrete type, not a param reference.

When the type checker resolves an inherited method, it composes the substitution: caller's type args → current class's params → superclass's params.

Challenge 5: Constructor Inference Bridges Class and Instance

Result ok: 42 calls a class method where the parameter is :: T. But T is a type param of Result instances. The class object itself is not parameterized — there's no Result(Integer, Error) class object.

Solution: Class methods that reference instance type params trigger inference. The type checker recognises that T in a class method's parameter type refers to the enclosing class's type params. When ok: receives an Integer argument, the checker infers T = Integer and returns Result(Integer, Dynamic) as the inferred type of the expression.

This works because class methods conceptually construct instances — they are the bridge between the unparameterized class object and parameterized instances. The type checker treats class method calls on generic classes as implicit type application sites.

Challenge 6: Control Flow Narrowing Through Blocks

TypeScript narrows types inside if branches — lexical scopes that the compiler directly controls. In Beamtalk, ifTrue: takes a block argument — a closure. The type checker needs to recognise that certain message patterns create narrowing contexts and thread the narrowed type into the block's scope.

Solution: Pattern-match on the AST shape, not on general message semantics. The type checker recognises a fixed set of narrowing idioms:

  1. When visiting a message send like [expr] class = [ClassName] ifTrue: [block], the checker:

    • Identifies the cascade: binary send class = producing a Boolean, followed by ifTrue: with a block argument
    • Determines which variable expr refers to
    • Pushes a scope refinement {variable → ClassName} into the block's scope before type-checking the block body
  2. For early-return narrowing (x isNil ifTrue: [^nil]), the checker:

    • Recognises the block contains a non-local return (^)
    • After the statement, pushes the complement refinement (x is non-nil) into the current method scope for all subsequent statements

This is not a general narrowing framework — it's a small set of recognised patterns. Each new pattern (e.g., respondsTo: in Stage 2) is a new case in the pattern matcher, not a new mechanism. The type checker already walks the AST and already has scoped environments; narrowing adds refinement entries to those environments.

Challenge 7: @primitive Methods in Generic Classes

Array(E), Dictionary(K, V), and Block methods are @primitive — dispatched to Erlang functions that know nothing about type parameters. The type params exist only in annotations.

This is fine — type erasure means the runtime dispatch is unchanged. The type checker uses the annotations for checking and inference, and codegen generates the same primitive dispatch as today. The __beamtalk_meta/0 function carries the type param metadata for tooling, but the actual method dispatch ignores it completely. This is exactly the same as how typed classes generate identical bytecode to non-typed classes.

Stage 2: Structural Protocols

Protocol Definition

Protocols define named message sets. A class conforms to a protocol if it responds to all required messages — no implements: declaration needed.

Protocol define: Printable
  /// Return a human-readable string representation.
  asString -> String

Protocol define: Comparable
  < other :: Self -> Boolean
  > other :: Self -> Boolean
  <= other :: Self -> Boolean
  >= other :: Self -> Boolean

Protocol define: Collection(E)
  /// The number of elements in this collection.
  size -> Integer

  /// Iterate over each element, evaluating the block for side effects.
  do: block :: Block(E, Object)

  /// Transform each element, returning a new collection of the same kind.
  collect: block :: Block(E, Object) -> Self

  /// Return elements matching the predicate.
  select: block :: Block(E, Boolean) -> Self

Protocol bodies use class-body style — method signatures without => implementations. This supports parameter names, type annotations, return types, and doc comments on each required method. The parser distinguishes protocol method signatures from class method definitions by the absence of =>.

Protocol names are bare identifiers (uppercase, like class names). Protocols and classes share a single namespace — having both a class and a protocol named Printable is a compile error.

Protocol Type Syntax

Protocol types use the same syntax as class types in type annotations — bare identifiers. The compiler resolves the name and determines whether to perform nominal (class) or structural (protocol) checking:

// Nominal type — compiler looks up Integer, finds a class → nominal check
deposit: amount :: Integer => ...

// Structural/protocol type — compiler looks up Printable, finds a protocol → structural check
display: thing :: Printable =>
  Transcript show: thing asString    // ✅ Printable guarantees asString

// Generic protocol type
printAll: items :: Collection(Object) =>
  items do: [:each | Transcript show: each asString]

No special wrapper syntax is needed — name resolution is sufficient because protocols and classes share a namespace. This is the same model used by TypeScript (interfaces) and Swift (protocols).

Conformance Checking

Conformance is structural and automatic:

// String has asString → conforms to Printable
// Integer has asString → conforms to Printable
// Counter has asString (from Object) → conforms to Printable

display: "hello"           // ✅ String conforms to Printable
display: 42                // ✅ Integer conforms to Printable
display: Counter spawn     // ✅ Counter conforms to Printable

Conformance checking uses a three-tier model depending on what information is available:

Tier 1: Compile-time ClassHierarchy (batch compilation). The type checker walks the full superclass chain via ClassHierarchy — statically defined methods only. Methods added at runtime (Counter >> newMethod => ...) are invisible. This is the same method table used for dispatch and existing type inference.

Tier 2: REPL workspace (live development). In the REPL, the workspace tracks live method additions and class redefinitions. Conformance checking in the REPL can use the runtime method table for more accurate results than batch compilation.

Tier 3: doesNotUnderstand: bypass. Classes that override doesNotUnderstand: can respond to any message — they structurally conform to every protocol. The conformance checker treats these classes as conforming without checking individual selectors, matching the existing ADR 0025 rule that suppresses unknown-message warnings for DNU classes.

// Tier 1: compile-time — walks full hierarchy
display: "hello"           // ✅ String has asString (from Object hierarchy)
display: 42                // ✅ Integer has asString (from Object hierarchy)
display: Counter spawn     // ✅ Counter has asString (inherited from Object)

// Tier 3: DNU bypass — conforms to everything
Actor subclass: Proxy
  doesNotUnderstand: msg => self.target forward: msg
display: Proxy spawn       // ✅ No warning — Proxy has DNU override

When conformance cannot be verified (Dynamic values, runtime-constructed classes):

display: someUnknownValue
// ⚠️ Warning: cannot verify Printable conformance for Dynamic value

Diagnostic Philosophy: Warnings, Never Errors

The type system — including generics and protocol conformance — never produces errors (ADR 0025). Code always compiles and runs. This principle extends unchanged to all features in this ADR:

SituationDiagnosticSeverity
Unknown message send"Counter does not respond to 'foo'"Warning
Protocol conformance unverifiable"cannot verify Printable conformance"Warning
Type mismatch in argument"expected Integer, got String"Warning
Missing annotation in typed class"untyped parameter in typed class"Warning
Unparameterized generic return"someMethod returns unparameterized Result"Warning + hint
Namespace collision (class + protocol)"Printable is already defined as a class"Error (structural)
Invalid type param reference"T is not a type parameter of this class"Error (structural)
Malformed protocol definition"expected method signature"Error (parse)

Only parse and structural errors block compilation — never type checking results. The escalation model:

ADR 0025 rejected a language-level strict mode (where the same code behaves differently depending on a compiler flag). The language always produces warnings, never errors, for type issues. However, the build pipeline enforces warnings via --warnings-as-errors on test-stdlib, test-docs, and test-examples (PR #1567). This is the adoption forcing function:

This avoids TypeScript's --strict problem (same code, different behavior per config) while providing the enforcement that drives adoption. The forcing function is in the build pipeline, not the language semantics. Every new type checking feature in this ADR (generics, unions, narrowing, protocols) produces warnings that --warnings-as-errors will enforce in CI automatically — no additional configuration needed.

Runtime Protocol Queries

> Integer conformsTo: Printable
=> true

> Integer protocols
=> #(Printable, Comparable)

> Printable requiredMethods
=> #(#asString)

> Printable conformingClasses
=> #(Integer, Float, String, Boolean, Symbol, Array, ...)

Runtime queries use the protocol registry compiled into module attributes. conformsTo: and protocols are messages on class objects; requiredMethods and conformingClasses are messages on protocol objects.

Protocol Composition

// Require multiple protocols
sort: items :: Collection(Object) & Comparable => ...

// Protocol extending another
Protocol define: Sortable
  extending: Comparable
  /// The key used for sort ordering.
  sortKey -> Object

Generic Protocol Bounds (Connecting Stages 1 and 2)

Once both stages are complete, type parameters can be bounded by protocols:

// T must conform to Printable
Actor subclass: Logger(T :: Printable)
  log: item :: T =>
    Transcript show: item asString    // ✅ Guaranteed by Printable bound

This is the natural composition of Stage 1 (type parameters) and Stage 2 (protocols as type constraints).

Prior Art

Strongtalk (Primary Influence)

Optional, structural typing for Smalltalk designed by Gilad Bracha. Strongtalk introduced protocols as named message sets with structural conformance — no implements: needed. We adopt this model directly. Strongtalk used <Type> syntax for all type annotations; we use :: Type for annotations (per ADR 0053) and bare names for both classes and protocols (the compiler resolves which is which).

Adopted: Structural conformance, protocols as message sets, type erasure. Adapted: Bare-name protocol types instead of Strongtalk's angle-bracket wrapper.

TypeScript (Inference and Generics Model)

TypeScript's generics use <T> syntax with structural compatibility. Generic interfaces (interface Stack<T> { push(item: T): void }) map directly to our protocol design. TypeScript infers generic type arguments from constructor calls and assignment context — we adopt the same inference strategy for constructors. TypeScript interfaces and classes share a namespace, with no special syntax to distinguish them in type position — we adopt this approach.

Adopted: Constructor inference, structural compatibility, shared namespace for protocols/classes. Adapted: Parenthesis syntax (T) instead of angle brackets <T> to avoid overloading < (a binary message in Beamtalk).

Gleam (Generics on BEAM)

Gleam has full Hindley-Milner generics with complete type inference. fn push(stack: Stack(a), item: a) -> Stack(a) uses parentheses for type application. Gleam proves that parametric types work well on BEAM with type erasure — generated BEAM code is identical with or without generics.

Adopted: Parenthesis syntax for type application (Result(T, E)), type erasure for generics on BEAM (zero runtime cost). Rejected: Mandatory typing, lowercase type variable convention.

Swift (Protocols with Associated Types)

Swift combines protocols with associated types for parametric protocol definitions. protocol Collection { associatedtype Element } is equivalent to our Protocol define: Collection(E). Swift requires explicit conformance declarations (struct Foo: Collection); we reject this in favor of automatic structural conformance.

Adopted: Parametric protocols, generic protocol constraints (T: Protocol). Rejected: Explicit conformance declarations (too much boilerplate for a Smalltalk-family language).

Elixir (Protocols on BEAM)

Elixir protocols are nominal — types must explicitly implement them (defimpl Printable, for: Integer). This is the opposite of our structural approach. However, Elixir proves that protocol dispatch on BEAM works well and generates efficient code.

Rejected: Nominal/explicit conformance. Learned: Protocol dispatch is efficient on BEAM; protocol metadata can live in module attributes.

Pony (Structural Typing with Capabilities)

Pony combines structural subtyping with reference capabilities. Its interface keyword defines structural types similar to our protocols. Pony requires explicit fun signatures in interfaces — we simplify to selector lists with optional type annotations.

Learned: Structural typing composes well with actor-model languages.

User Impact

Newcomer (from Python/JS/Ruby)

Smalltalk Developer

Erlang/Elixir Developer

Production Operator

Tooling Developer (LSP)

Steelman Analysis

Option A: Annotation-Only Generics (Rejected)

Parse Result(Integer, IOError) at usage sites but don't add type params to class definitions. Hardcode substitution rules for known stdlib types (Result, Array, Dictionary, Set, Block).

CohortStrongest argument
Newcomer"I only need to write :: Result(Integer, Error) — I never have to define generic classes myself. Less to learn."
Smalltalk purist"This keeps class definitions pure Smalltalk — no parenthesized params polluting class headers. The type system stays invisible to class authors."
BEAM veteran"Generates the same Dialyzer specs at the FFI boundary with dramatically less compiler complexity. Fewer moving parts in the compiler means fewer compiler bugs."
Operator"For the next 2 years, only stdlib types need generics. Hardcoding 5 substitution rules is less risky than building a generics engine that might have subtle bugs."
Language designer"YAGNI — we only need Result, Array, Dictionary, Set, and Block. That's 5 types, not a generics system. If users eventually need custom generic classes, we can upgrade then — the annotation syntax is forward-compatible."

Why rejected: The YAGNI argument is genuinely compelling for the short term. But the type checker becomes a bag of special cases rather than a principled system — each new generic type requires new hardcoded rules instead of falling out from a general mechanism. More importantly, user-defined generic classes (e.g., Stack(E), Cache(K, V)) would be impossible without upgrading to declaration-site generics. The annotation syntax is forward-compatible, but the type checker internals would need a rewrite.

Option B: Full Parametric Polymorphism (Rejected)

Complete generics with variance annotations (+T covariant, -T contravariant), bounded quantification, generic methods with explicit type params, and type parameter inference from usage patterns.

CohortStrongest argument
Newcomer"In TypeScript and Kotlin, List<Integer> is assignable to List<Number>. If Beamtalk's invariant generics reject this, I'll hit a wall fast and blame the type system."
Smalltalk purist"If you're going to impose a type system on Smalltalk, at least make it sound. Half-measures are worse than nothing — they give false confidence."
BEAM veteran"Gleam ships full HM inference with zero annotations needed. If Beamtalk's generics require annotations that Gleam infers automatically, we'll look like the inferior BEAM typed language."
Operator"Invariant generics mean I can't pass Array(Integer) where Array(Number) is expected — I'll end up casting everything, which defeats the purpose of types."
Language designer"Variance is the difference between a toy type system and a real one. Without it, every container type hits a wall at subtyping boundaries. Adding variance later means migrating every existing generic annotation — it's not as painless as the ADR claims."

Why rejected: The variance argument is the strongest — invariant generics will surprise users at subtyping boundaries. However: (1) Beamtalk's class hierarchy is shallow (most types are sealed leaf classes), so subtyping boundaries are rare in practice; (2) variance can be added later as an extension to existing invariant generics — existing code remains valid, it just gains more permissive assignability; (3) the implementation cost of variance (XL+) would delay everything else in this ADR by months. The pragmatic path is: ship invariant generics, collect real-world evidence of where variance is needed, then add it with data rather than speculation.

Option C: Pragmatic Declaration-Site Generics (Chosen)

Classes declare type parameters. Methods reference them. Type checker substitutes. No variance, no bounds (until protocols land), no HKTs. Union checking and simple control flow narrowing included.

CohortStrongest argument
Newcomer"Result(Integer, Error) reads cleanly — like Gleam or Python type hints. Union checking catches my `String
Smalltalk purist"Type params are optional — my untyped code doesn't change at all. Protocols formalize what I already do informally. The structural conformance model IS duck typing, just explicit."
BEAM veteran"Generates proper Dialyzer specs at the FFI boundary. Union types match Erlang's {ok, V} | {error, R} pattern naturally."
Operator"Zero runtime cost, same bytecode, same observability. The type system helps my team catch bugs without any production impact."
Language designer"Simple enough to implement well, extensible to variance/bounds later. Union checking and narrowing compose into something genuinely useful without HM complexity."

Option D: Generics Only — Defer Protocols, Unions, and Narrowing (Not Chosen)

Ship only Stage 1 (generic type params and substitution). Defer protocols, union checking, and narrowing to separate ADRs.

CohortStrongest argument
Operator"Smaller scope = less risk. Ship generics, prove they work, then add protocols. One big ADR with 10 implementation phases is a recipe for scope creep."
Language designer"Protocols are a separate concept from generics. Bundling them forces both to ship together — if protocols slip, generics slip too. Decouple them."
BEAM veteran"Elixir shipped protocols and generics independently. They don't have to be one thing."

Why not chosen: Generics without union checking leaves a hole — String | nil is the most common type annotation, and without union checking it's decoration. Narrowing without unions is pointless. Protocols without generics can't express Collection(E). The features compose into something greater than the sum — shipping them separately means each is less useful in isolation. However, the staged implementation (Stage 1 before Stage 2) does allow generics + unions + narrowing to ship before protocols if needed.

Tension Points

Alternatives Considered

Alternative A: Angle Bracket Syntax (Result<T, E>)

Use Result<Integer, IOError> following TypeScript/Java/Rust convention.

sealed Value subclass: Result<T, E>
  field: okValue :: T = nil
  unwrap -> T => ...

Rejected because:

Alternative B: Square Bracket Syntax (Result[T, E])

Use Result[Integer, IOError] following Scala/Python convention.

sealed Value subclass: Result[T, E]
  field: okValue :: T = nil
  unwrap -> T => ...

Rejected because:

Alternative C: No Class-Level Declaration — Infer Everything

Don't add type params to classes. Instead, infer generic relationships from method signatures.

Rejected because:

Alternative D: Nominal Protocol Conformance (implements:)

Require explicit implements: declarations instead of automatic structural conformance.

Value subclass: Point
  implements: [Printable, Comparable]
  ...

Rejected because:

Alternative E: Protocols Before Generics

Implement protocols first (Phase 3), then generics (Phase 4), following ADR 0025's original ordering.

Rejected because:

Alternative F: Angle-Bracket Protocol Type Wrapper (<Printable>)

Use <Printable> in type annotations to distinguish protocol types from class types, following Strongtalk convention.

display: thing :: <Printable> => ...   // structural check
deposit: amount :: Integer => ...      // nominal check

Rejected because:

Consequences

Positive

Negative

Neutral

Implementation

Prerequisite: Dialyzer Spec Validation (BT-1565)

Dialyzer specs serve the FFI/interop boundary — they let Erlang/Elixir callers get type-checked when calling Beamtalk actors. For pure Beamtalk, the Beamtalk type checker is the right tool (it understands message sends, class hierarchies, doesNotUnderstand:, etc.; Dialyzer just sees gen_server:call).

Before expanding spec generation to handle generics, we need confidence that the specs we generate for interop are actually valid. Today:

Required before Phase 1d (generic spec codegen):

  1. Add a CI step that compiles stdlib .bt classes to BEAM and runs Dialyzer on the output — verifying that existing -spec attributes are valid
  2. Add an integration test with an Erlang module calling a typed Beamtalk actor — verifying Dialyzer catches type mismatches at the FFI boundary
  3. Include a negative test (intentionally wrong types from Erlang side → Dialyzer warning)

Without this, we'd be generating increasingly complex generic specs (Result(integer(), any())) with no verification that Dialyzer can parse or use them at the boundary. This is tracked as BT-1565.

Stage 1: Parametric Types (Size: L)

Phase 1a: Parser and AST (M)

Phase 1b: Type Checker Substitution (L)

Phase 1c: Constructor Inference (M)

Phase 1d: Codegen — Spec and Meta (M)

Phase 1e: Stdlib Annotations (M)

Phase 1f: Union Type Checking (M)

Phase 1g: Control Flow Narrowing (M)

Stage 2: Structural Protocols (Size: L)

Phase 2a: Protocol AST and Parser (M)

Phase 2b: Protocol Registry and Conformance (L)

Phase 2c: Runtime Queries (M)

Phase 2d: Type Parameter Bounds (S)

Phase 2e: respondsTo: Narrowing (S)

Phase 2f: Variance for Protocol-Typed Parameters (M)

Implementation Tracking

Epic: BT-1567 Status: Completed in v0.3.0

PhaseIssueDescriptionSizeDependencies
prereqBT-1565Dialyzer spec validation CIMNone
1aBT-1568Parser and AST for generic typesMNone
1BT-1569Extend InferredType with type_args, Union, provenanceMNone
1bBT-1570Type checker generic substitutionLBT-1568, BT-1569
1cBT-1571Constructor type inferenceMBT-1570
1fBT-1572Union type checkingMBT-1569
1gBT-1573Control flow narrowingMBT-1569, BT-1572
1dBT-1574Codegen — Dialyzer specs for genericsMBT-1568, BT-1565
1dBT-1575Codegen — runtime meta for genericsMBT-1568
1eBT-1576Stdlib generic annotationsMBT-1570, BT-1574, BT-1575
BT-1577Generic inheritance — superclass type applicationMBT-1570
2aBT-1578Protocol AST and parserMBT-1568
2bBT-1579Protocol registry and conformanceLBT-1570, BT-1578
2cBT-1580Runtime protocol queriesMBT-1579
2dBT-1581Type parameter boundsSBT-1579
2eBT-1582respondsTo: narrowingSBT-1573, BT-1579
2fBT-1583Variance for protocol-typed paramsMBT-1579, BT-1581
BT-1584Documentation — language spec updateMBT-1576, BT-1579

Migration Path

No migration is required. Both features are purely additive:

The only stdlib change is updating Result.bt and collection classes to declare their type parameters and update field/method annotations. This is a non-breaking change — the runtime behavior is identical (type erasure).

References