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 … returns Dynamic(Unknown). … <ClassName> class metatype (BT-2034) … like Self 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 syntacticallyis_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:

What is missing — entirely in the type checker's type representation:

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

  1. Additive / gradual — consistent with ADR 0025. Code that does not annotate metatypes keeps working; unresolved metatypes still fall back to Dynamic.
  2. Reuse the existing tower — must route through find_class_method and the Metaclass chain already present, not a parallel mechanism.
  3. No runtime/codegen change — this is a static-analysis precision change only. Dispatch already works at runtime.
  4. Precision increase is opt-in pressure — turning X class into a real type will surface new diagnostics where code previously rode on Dynamic; these must be absorbable (fix or annotate), not a hard break.

Decision

Make metatypes first-class in inference:

  1. Represent a metaclass type as a dedicated, name-only variant. Add InferredType::Meta { class_name, provenance } — the metatype of class C, a.k.a. C class. Critically, the class object is not parameterized (ADR 0068:511: "there's no Result(Integer, Error) class object"). Meta carries a class name only (Meta{List}, never Meta{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 an is_meta flag on Known) is chosen deliberately: ~131 non-test sites destructure Known { 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 (an if let Known{..} simply falls through to "unknown" rather than producing a wrong answer). Named Meta (not Metaclass) to avoid collision with the tower's Metaclass class.

  2. Subtyping into the tower. metatype-of-C <: Class <: Behaviour <: Object, so a metatype value still satisfies :: Class / :: Behaviour parameters and FFI returns typed List(Behaviour). This must compose with the existing expected == "Class" shortcut and class-literal/metaclass compatibility branch in validation.rs (BT-1877 / BT-2038, validation.rs:686–700).

  3. Resolve the annotations. type_resolver resolves TypeAnnotation::SelfClass to the metatype of the enclosing class, and TypeAnnotation::ClassOf { name } to the metatype of name, instead of Dynamic (type_resolver.rs:128–134).

  4. Route sends on metaclass-typed receivers to class-side lookup. When a receiver's inferred type is a metatype of C, resolve the selector via find_class_method(C, …) (with the existing Metaclass → Class → Behaviour fallback). This generalizes is_class_side_send from a syntactic test to a type-driven one, and applies a class-side method's declared return — including a -> Self return resolved to C (the same mechanism new -> Self needs).

  5. self class new and class-method returns. new / basicNew on a metatype of C returns an instance of C. Soundness caveat: when C is abstract (Collection, Behaviour), new must not be blessed as a concrete instance — guard infer_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.

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

User Impact

Steelman Analysis

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

Negative

Neutral

Implementation

  1. Spike — confirm the representation and trace the species case end-to-end on one method before broad work (the smallest proof: Counter class new infers Counter, and self species withAll: resolves with species -> Self class).
  2. Representation — add the InferredType::Meta { class_name, provenance } variant (name-only, per Decision item 1). Define Meta{C} <: Class <: Behaviour in is_type_compatible; display as "C class". Expect bounded, compiler-guided churn adding match arms (display_for_diagnostic, subtyping, etc.).
  3. type_resolver — resolve SelfClass / ClassOf to the metatype instead of Dynamic (type_resolver.rs:128–134).
  4. inference.rs — when the receiver type is a metatype, set is_class_side_send and look up via find_class_method; apply class-method returns (incl. -> Self → the metatype's class, and ADR 0068 call-site param inference for withAll:-style methods).
  5. validation.rs — extend the Metaclass-chain fallback to metatype-typed receivers; compose with the expected == "Class" shortcut (:686–700); guard infer_constructor_type (:284) so abstract metatypes don't yield concrete instances.
  6. stdlib — re-declare Collection>>species -> Self class; remove the species @expect dnu overrides; verify/remove the implicit class-side new override; absorb new diagnostics.
  7. Tests — type-checker unit tests (type_checker/tests/: a self_class.rs / metatype suite); stdlib BUnit (stdlib/test/) for species/reflection; ensure just test-stdlib stays warning-clean.
  8. 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

  1. species typing → re-declare species -> Self class. Body-driven self class inference was rejected: the call site uses the method's declared return type, so inferring the body as a metatype would not help self species callers without separately changing return-type computation. Re-declaring is local, explicit, and correct (the body is already self class).
  2. Representation → dedicated InferredType::Meta { class_name, provenance } variant (not an is_meta flag). ~131 non-test Known { 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.
  3. Species scope → include in Slice 1. Once metatype routing and -> Self class-method return exist (required for self class new regardless), the species fix is the -> Self class re-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