ADR 0083: Metaclass-Aware Type Inference
Status
Implemented (2026-05-23)
Context
Problem statement
Beamtalk has a full metaclass tower at runtime and in the class hierarchy
(Metaclass → Class → Behaviour → Object → ProtoObject, ADR 0036), and the type
annotation syntax for metatypes already exists: TypeAnnotation::SelfClass
(Self class) and TypeAnnotation::ClassOf (X class), added in BT-2034.
Object>>class is even declared -> Self class and Class>>class -> Metaclass.
But the type checker discards this information. In
type_checker/type_resolver.rs, both Self class and X class resolve to
Dynamic:
Self class… returnsDynamic(Unknown). …<ClassName> classmetatype (BT-2034) … likeSelf class… resolves to Dynamic.
The consequence: any expression whose static type is "a class object" loses all
type information. Sends to it cannot be checked, and the type checker cannot route
them to class-side method lookup. Class-side resolution today is decided
syntactically — is_class_side_receiver is true only when the receiver is a
class literal (String new) or self inside a class method — never from a
value's type being a metaclass.
Current state
What already exists and works:
- The tower (ADR 0036):
Behaviour,Class,Metaclassclasses with class-side method storage (ClassInfo.class_methods) andfind_class_method. - A metaclass-tower fallback in
type_checker/validation.rs: when an instance-side lookup on a class literal fails, it re-checks against theMetaclass → Class → Behaviourchain. - The annotation syntax
Self class/X class(BT-2034), parsed and accepted.
What is missing — entirely in the type checker's type representation:
Self class/X classresolve toDynamic(type_resolver.rs:128–134), not a tracked "metaclass-of-X" type. (Object>>classis declared-> Self class, but that annotation resolves toDynamictoday.)- A method that returns a class value yields
Dynamicdownstream — and a method that returns a class value via a plain type loses even more:Collection>>speciesis declared-> Object => self class(Collection.bt:57), soself speciesinfers asObject, not even a class. - Sends on a metaclass-typed receiver are not routed through
find_class_method.
This is the structural reason behind several @expect overrides found in the
stdlib audit (see BT-2254 / the ADR 0075 amendment for the FFI-element-type
sibling): for example self species withAll: in Collection.bt carries
@expect dnu because species is typed -> Object, so withAll: cannot be
resolved as a class-side send. Note this override is not removable by metatype
typing alone — it additionally requires re-declaring species -> Self class
(see Slicing, below).
Constraints
- Additive / gradual — consistent with ADR 0025. Code that does not annotate
metatypes keeps working; unresolved metatypes still fall back to
Dynamic. - Reuse the existing tower — must route through
find_class_methodand theMetaclasschain already present, not a parallel mechanism. - No runtime/codegen change — this is a static-analysis precision change only. Dispatch already works at runtime.
- Precision increase is opt-in pressure — turning
X classinto a real type will surface new diagnostics where code previously rode onDynamic; these must be absorbable (fix or annotate), not a hard break.
Decision
Make metatypes first-class in inference:
-
Represent a metaclass type as a dedicated, name-only variant. Add
InferredType::Meta { class_name, provenance }— the metatype of classC, a.k.a.C class. Critically, the class object is not parameterized (ADR 0068:511: "there's noResult(Integer, Error)class object").Metacarries a class name only (Meta{List}, neverMeta{List(E)}), which makes parameterized metatypes structurally unrepresentable — the 0068 rule is enforced by the type, not by discipline. Instance type arguments are recovered at the call site via ADR 0068's existing class-method inference ("class method calls on generic classes as implicit type application sites") — e.g.List withAll: aList(Integer)infers the element type from the argument, not from the class object. A dedicated variant (rather than anis_metaflag onKnown) is chosen deliberately: ~131 non-test sites destructureKnown { class_name, .. }and would silently treat a metatype as the instance type under a flag; a variant makes the relevant matches compiler-visible and degrades safely (anif let Known{..}simply falls through to "unknown" rather than producing a wrong answer). NamedMeta(notMetaclass) to avoid collision with the tower'sMetaclassclass. -
Subtyping into the tower.
metatype-of-C <: Class <: Behaviour <: Object, so a metatype value still satisfies:: Class/:: Behaviourparameters and FFI returns typedList(Behaviour). This must compose with the existingexpected == "Class"shortcut and class-literal/metaclass compatibility branch invalidation.rs(BT-1877 / BT-2038,validation.rs:686–700). -
Resolve the annotations.
type_resolverresolvesTypeAnnotation::SelfClassto the metatype of the enclosing class, andTypeAnnotation::ClassOf { name }to the metatype ofname, instead ofDynamic(type_resolver.rs:128–134). -
Route sends on metaclass-typed receivers to class-side lookup. When a receiver's inferred type is a metatype of
C, resolve the selector viafind_class_method(C, …)(with the existingMetaclass → Class → Behaviourfallback). This generalizesis_class_side_sendfrom a syntactic test to a type-driven one, and applies a class-side method's declared return — including a-> Selfreturn resolved toC(the same mechanismnew -> Selfneeds). -
self class newand class-method returns.new/basicNewon a metatype ofCreturns an instance ofC. Soundness caveat: whenCis abstract (Collection,Behaviour),newmust not be blessed as a concrete instance — guardinfer_constructor_type(validation.rs:284) against abstract metatypes, falling back to the abstract type /Dynamic.
Slicing
The clean, self-contained win is reflection / class-as-value typing; the species pattern needs an extra stdlib change and is honestly the harder case.
-
Slice 1 (this ADR's core): items 1–5 above — metatype representation, tower subtyping, annotation resolution, type-driven class-side routing, and class-method return typing (
new/-> Selfresolved to the metatype's class). Cleanly covers:aConcreteInstance class newtyped as the instance class,obj class <selector>reflective resolution, class values flowing through variables/collections, and (subsuming the separate small fix) the implicit class-sidenewoverride. -
Species (Slice 1, but requires a stdlib change): removing the
self species withAll:override is not free. It requires re-declaringCollection>>species -> Self class(today-> Object). With that, at the definition siteself : Collection(E)⇒self species : metatype-of-Collection,withAll:resolves class-side and its-> Selfreturn isCollection(E), which matchescollect: -> Self. The DNU disappears. The runtime species (the concrete subclass) remains statically invisible — at the abstract definition siteSelfis the defining class, so the body types asCollection, not the subclass. That is sound (the declared return isSelf) but it means metatype typing reproduces the resolution, not the subtype precision. -
Slice 2 (deferred for scope, not blocked): class-side
Self-return precision for concrete class literals —Set withAll: → Set,List withAll: → List. The variance prerequisite (ADR 0068 Stage 2 / BT-1583) is already complete, so this can be planned independently on top of Slice 1; it is split out only to keep Slice 1 focused, not because anything blocks it. Tracked as BT-2256.
Example
Today (species -> Object, the override is needed):
typed Object subclass: Collection(E)
species -> Object => self class // returns Object — loses class-ness
collect: block :: Block(E, R) -> Self =>
result := (self inject: #() into: [:acc :each | acc addFirst: (block value: each)]) reversed
@expect dnu // withAll: not resolvable on Object
self species withAll: result
Proposed (Slice 1 + the species re-declaration):
species -> Self class => self class // metatype-of-Self
collect: block :: Block(E, R) -> Self =>
result := (...) reversed
self species withAll: result // withAll: resolved class-side, -> Self — no @expect
Reflection — honest about precision limits:
// allClasses returns List(Behaviour); the element is a class value but its
// INSTANCE type is statically unknown.
cls := SystemNavigation default allClasses first // cls :: Behaviour
cls name // class-side resolved (Behaviour>>name) — no @expect
cls new // resolves, but typed Object: the instance type is unknown
Prior Art
- Strongtalk (the most relevant prior art): a statically-typed Smalltalk that
layered a structural type system over exactly this metaclass model. It typed
class-side protocols separately from instance protocols and used
Self-types for the species/newfamily — the same problem this ADR addresses. Adopted: the separation of class-side from instance-side method lookup andSelf-return resolution. Adapted: Beamtalk uses the nominal class hierarchy (ADR 0036) rather than Strongtalk's structural protocols. - Smalltalk (Pharo/Squeak): every class is an instance of its metaclass; the metaclass tower is the canonical model ADR 0036 follows. Dynamically typed, so it gets class-side dispatch "for free" but no static checking — this ADR adds the static layer.
- Newspeak: classes are first-class messages; metaclass-aware but again dynamic.
- TypeScript: models "the class object" with
typeof ClassNameand constructor types (new () => T). Note TypeScript does parameterize the constructor side; Beamtalk deliberately does not (ADR 0068 — the class object is unparameterized, instance params are inferred at the call site), so the analogue is the unparameterized metatype-of-name, nottypeofover generics. - Gleam: no metaclasses; rejected as a model since Beamtalk committed to the tower in ADR 0036.
User Impact
- Smalltalk developer: the species pattern,
self class new, and reflective class-side sends type-check without@expect— matching their expectation that classes are real objects. - Newcomer: fewer
@expectdirectives to copy/cargo-cult; class-side calls on stored class values get the same hints as instance calls. - Erlang/BEAM developer: reflection FFI that returns classes
(
SystemNavigation,Behaviour) gains downstream type info instead of decaying toDynamic. - Operator: no runtime change.
Steelman Analysis
- "Keep it Dynamic" (status quo) — newcomer/maintainer: class values are rare,
and
@expectdocuments the boundary honestly. Strongest where the metatype is genuinely unknown (heterogeneous class lists from reflection, wherenewisObjectanyway — see the reflection example). Countered by the metaprogramming surface (SystemNavigation, builders, DSLs) being exactly where Beamtalk leans hardest onDynamic. - BEAM developer: "class objects are just atoms/modules at runtime; static metatypes add checker complexity for something dispatch already handles." Genuine — and why this ADR is static-only with zero runtime change; the payoff is purely earlier diagnostics, which a BEAM dev may value less than a Smalltalker.
- Language designer: the strongest case against is the ADR 0068 boundary —
parameterized metatypes were explicitly rejected. This ADR must stay on the
unparameterized side of that line (Decision item 1); if a future need for
metatype-of-List(E)precision appears, it reopens 0068, not just 0083. - Tension / where reasonable people disagree: whether re-declaring
species -> Self classis worth it given the runtime species stays statically invisible (the def-site only ever seesCollection). The win is removing one@expectand documenting intent; full subtype precision arrives only with Slice 2 (BT-2256). A reviewer could reasonably say "leave the species override; ship Slice 1 for reflection only."
Alternatives Considered
Keep metatypes as Dynamic (status quo)
Documented via @expect. Rejected as the default direction because it caps the
typed-coverage ceiling exactly in reflective/metaprogramming code, but retained as
the fallback for genuinely-unknown metatypes.
Syntactic-only class-side detection (extend is_class_side_receiver)
Special-case more receiver shapes (e.g. <expr> class) without a real metatype.
Rejected: it does not compose — it cannot follow a class value through a variable,
a collection, or an FFI return, which is where the actual overrides live.
Full structural metatypes (TypeScript-style constructor types)
Model class-side as arbitrary constructor signatures independent of the tower. Rejected: duplicates the tower that ADR 0036 already provides; more machinery than the Smalltalk model needs.
Consequences
Positive
- Subsumes the implicit class-side
newoverride and unlocks typed reflection/metaprogramming (class-side method resolution on class values). - Generalizes class-side resolution from syntactic to type-driven, so class values flow through variables, collections, and FFI returns.
- Removes the
speciesoverride given thespecies -> Self classre-declaration (not for free — see Negative). - Complements BT-2254 (FFI element types): that ADR types the elements; this one types the class-side of those elements.
Negative
- The
speciesoverride removal requires a stdlib signature change (species -> Self class) and still does not recover the runtime species statically (def-siteSelfis the defining class). - Precision increase surfaces new diagnostics where stdlib code rode on
Dynamicmetatypes; must be fixed or annotated deliberately (not purely subtractive). - Abstract-class
newis a soundness hazard (Collection newmust not type as a concrete instance) — needs an explicit guard. - Adds an
InferredType::Metavariant — bounded, compiler-guided churn across match arms — and must compose with the existingexpected == "Class"/ metaclass-compat branch invalidation.rs(BT-1877 / BT-2038). - Class-side
Self-return precision for concrete literals (Set withAll: → Set) is deferred to Slice 2 (BT-2256) for scope — not deliverable in Slice 1, but not blocked (its variance prerequisite, ADR 0068 Stage 2 / BT-1583, is complete).
Neutral
- No runtime, codegen, or syntax change — static analysis only (the
speciesre-declaration is a type-annotation change; its runtime body is unchanged). Metaclass/Class/Behaviourremain valid explicit annotations; the metatype representation sits alongside them and subtypes into them.
Implementation
- Spike — confirm the representation and trace the species case end-to-end on
one method before broad work (the smallest proof:
Counter class newinfersCounter, andself species withAll:resolves withspecies -> Self class). - Representation — add the
InferredType::Meta { class_name, provenance }variant (name-only, per Decision item 1). DefineMeta{C} <: Class <: Behaviourinis_type_compatible; display as "C class". Expect bounded, compiler-guided churn adding match arms (display_for_diagnostic, subtyping, etc.). type_resolver— resolveSelfClass/ClassOfto the metatype instead ofDynamic(type_resolver.rs:128–134).inference.rs— when the receiver type is a metatype, setis_class_side_sendand look up viafind_class_method; apply class-method returns (incl.-> Self→ the metatype's class, and ADR 0068 call-site param inference forwithAll:-style methods).validation.rs— extend theMetaclass-chain fallback to metatype-typed receivers; compose with theexpected == "Class"shortcut (:686–700); guardinfer_constructor_type(:284) so abstract metatypes don't yield concrete instances.- stdlib — re-declare
Collection>>species -> Self class; remove the species@expect dnuoverrides; verify/remove the implicit class-sidenewoverride; absorb new diagnostics. - Tests — type-checker unit tests (
type_checker/tests/: aself_class.rs/ metatype suite); stdlib BUnit (stdlib/test/) for species/reflection; ensurejust test-stdlibstays warning-clean. - Slice 2 (separate issue, BT-2256 — not blocked) — class-side
Self-return precision for concrete-class-literal receivers.
Affected components: type checker (type_resolver, inference, validation,
types) plus one stdlib annotation (Collection.bt). No parser/codegen/runtime
changes.
Resolved Questions
speciestyping → re-declarespecies -> Self class. Body-drivenself classinference was rejected: the call site uses the method's declared return type, so inferring the body as a metatype would not helpself speciescallers without separately changing return-type computation. Re-declaring is local, explicit, and correct (the body is alreadyself class).- Representation → dedicated
InferredType::Meta { class_name, provenance }variant (not anis_metaflag). ~131 non-testKnown { class_name, .. }sites would silently mis-handle a metatype under a flag; the variant is compiler-guided and degrades safely. Name-only, so parameterized metatypes are unrepresentable (enforces ADR 0068 structurally). See Decision item 1. - Species scope → include in Slice 1. Once metatype routing and
-> Selfclass-method return exist (required forself class newregardless), the species fix is the-> Self classre-declaration plus removing two overrides, and type-checks cleanly at the definition site. Full subtype precision waits for Slice 2 (BT-2256), but Slice 1 removes real overrides and documents intent at low marginal cost.
Migration Path
Additive. Existing code is unchanged except that some sends on class values that
previously inferred Dynamic now type-check; any that surface a real warning are
fixed or annotated as part of the stdlib cleanup. Genuinely-unknown metatypes
still fall back to Dynamic.
References
- Related issues: BT-2034 (
Self class/X classannotation syntax), BT-1952 (Self classhistorically resolves toDynamic), BT-1877 / BT-2038 (expected == "Class"shortcut + metaclass-tower compatibility invalidation.rs), BT-1583 (ADR 0068 Stage 2 variance — complete, the Slice 2 prerequisite), BT-2254 (typed FFI collection element types — sibling), BT-2255 (Slice 1 implementation), BT-2256 (Slice 2 implementation) - Related ADRs: ADR 0036 (Full Metaclass Tower — the runtime/hierarchy this types),
ADR 0068 (Parametric Types — constraint: class objects are unparameterized
(§511); this ADR stays inside that boundary. Note variance, originally deferred
to Stage 2 (§350), is now implemented (BT-1583), so Slice 2 is unblocked),
ADR 0025 (Gradual Typing and Protocols — the type system this plugs into),
ADR 0075 (Erlang FFI Type Definitions — class-returning reflection FFI),
ADR 0077 (Type Coverage Visibility — interaction with
@expect) - Documentation:
docs/beamtalk-language-features.md(type annotations,@expect)