ADR 0077: Type Coverage Visibility

Status

Accepted (2026-04-05)

Context

Beamtalk's gradual type system (ADR 0025) tracks rich type information internally: InferredType with Known, Union, and Dynamic variants; TypeProvenance distinguishing Declared, Inferred, Substituted, and Extracted sources; and a TypeMap mapping every expression span to its resolved type. The FFI layer (ADR 0075) auto-extracts Erlang specs into a NativeTypeRegistry. Return type writeback (BT-1005) infers method return types from bodies.

Enforcement already exists. ADR 0025 Phase 2b specified that typed classes warn on missing annotations. This is partially implemented — check_typed_method_annotations in validation.rs warns on missing parameter types and return types. Two gaps remain: state field annotations and Dynamic-inference warnings.

Visibility does not exist. Users have no way to see what the compiler knows:

The enforcement (warnings on typed classes) tells developers what's wrong. The missing visibility would tell them what to do — and motivate them to do it. TypeScript's experience shows that making any visible on hover drove type adoption more effectively than --noImplicitAny enforcement. We already have enforcement; we need the visibility to complement it.

Constraints

  1. Dynamic is validDynamic is not a bug; it's the correct type for genuinely dynamic code. Visibility must inform, not shame.
  2. Gradual adoption — Teams add types incrementally. Tooling must support "start at 0%, improve over time."
  3. typed semantics are establishedADR 0025 Phase 2b defines what typed means. This ADR completes the implementation and adds visibility, but does not change the contract.

Decision

1. Add Dynamic Provenance

The Dynamic variant of InferredType gains a DynamicReason explaining why the type could not be determined. This is the foundation for all visibility features — hover, coverage detail, and diagnostic messages.

/// Why a type could not be determined.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DynamicReason {
    /// Parameter has no type annotation.
    UnannotatedParam,
    /// Method has no return type annotation and body could not be inferred.
    UnannotatedReturn,
    /// Receiver is Dynamic, so message send result is Dynamic.
    DynamicReceiver,
    /// Control flow produces incompatible types (pre-union-narrowing fallback).
    AmbiguousControlFlow,
    /// Erlang FFI call with no spec or all-Dynamic spec.
    UntypedFfi,
    /// Fallback — no specific reason available.
    Unknown,
}

pub enum InferredType {
    Known { class_name, type_args, provenance },
    Union { members, provenance },
    Dynamic(DynamicReason),  // was: Dynamic (bare unit variant)
}

The PartialEq impl continues to ignore the reason (all Dynamic values are equal), matching how provenance is ignored on Known/Union. The union_of helper, which short-circuits to Dynamic when any member is Dynamic, will propagate the reason from the Dynamic member. When multiple Dynamic members are present or when the union itself is the source of ambiguity, the reason is AmbiguousControlFlow.

2. Show Dynamic in LSP Hover

When an expression's inferred type is Dynamic, the hover provider displays it explicitly with the reason:

Identifier: `handler` — Type: Dynamic (no return annotation on getHandler)
Identifier: `result` — Type: Dynamic (receiver is Dynamic)
Identifier: `data` — Type: Dynamic (parameter has no type annotation)

When the reason is Unknown, the hover shows just Type: Dynamic.

This replaces the current behavior of omitting the type line entirely. The reason tells you exactly what to fix: add a return type annotation, add a parameter type, or trace back to the source of a Dynamic receiver.

Note: display_name() currently returns None for Dynamic, which all callers interpret as "no type to display." This must change to return Some("Dynamic") (or a reason string). All callers of display_name() that branch on None must be audited — the hover provider at lines 563 and 639 of hover_provider.rs both use .and_then(InferredType::display_name) and will need updating.

3. beamtalk type-coverage CLI Command

A new CLI command reports type coverage statistics per class and per file:

$ beamtalk type-coverage
Type Coverage Report
====================

File                          Class              Coverage
src/AccountService.bt         AccountService     92.3%  (48/52 expressions)
src/Router.bt                 Router             87.1%  (54/62 expressions)
src/ConfigLoader.bt           ConfigLoader       71.4%  (30/42 expressions)
src/MyApp.bt                  MyApp              45.0%  (9/20 expressions)
──────────────────────────────────────────────────────────────────────
Total                                            80.1%  (141/176 expressions)

Coverage is defined as: (expressions with non-Dynamic inferred type) / (total expressions in the TypeMap).

What counts as an expression: Every AST node that produces a TypeMap entry — identifiers, message sends, assignments, literals, block bodies, and binary operations. Sub-expressions are counted individually: self balance + 1 contributes three entries (the self balance send, the literal 1, and the + send). Literal values (integers, strings, booleans) always resolve to Known types and contribute positively. This matches the formula used by TypeScript's type-coverage and Flow's flow coverage, which also count sub-expressions.

Scope: Coverage reports only the project's own classes (files under the project source directories). Dependencies and stdlib classes are excluded. This prevents project coverage from being dominated by stdlib quality.

Known limitation: Beamtalk's block-as-first-class-object model means block parameters and block bodies create TypeMap entries that are often Dynamic (stored blocks lose type context). Classes using heavy collection/iteration idioms (collect:, inject:into:) may show systematically lower coverage than equivalent recursive code. This is a known distortion in the metric — the coverage percentage is a progress indicator, not a quality score.

Flags

Example: Detail Mode

$ beamtalk type-coverage --detail --class MyApp
Type Coverage: MyApp (src/MyApp.bt) — 45.0% (9/20)

  Dynamic expressions:
    src/MyApp.bt:12:5   handler := getHandler        (no return annotation on getHandler)
    src/MyApp.bt:15:9   result := handler process:    (receiver is Dynamic)
    src/MyApp.bt:18:5   items collect: [:x | x transform]  (receiver is Dynamic)
    ...

Example: CI Ratchet

# In CI pipeline
- run: beamtalk type-coverage --at-least 75 --format json
{
  "total_expressions": 176,
  "typed_expressions": 141,
  "coverage_percent": 80.1,
  "threshold": 75,
  "passed": true,
  "classes": [
    {
      "name": "AccountService",
      "file": "src/AccountService.bt",
      "total": 52,
      "typed": 48,
      "coverage_percent": 92.3
    }
  ]
}

Note: The DynamicReason strings in JSON output (e.g., "no return annotation on getHandler") are informational and may change as the type checker improves. CI scripts should gate on coverage_percent and passed, not on specific reason strings.

4. Complete typed Class Diagnostics

ADR 0025 Phase 2b specifies that typed classes warn on missing annotations. Two gaps remain in the current implementation:

State field annotations (gap)

ADR 0025 Phase 2b: "All state fields should have type annotations (warn if missing)."

typed Actor subclass: BankAccount
  state: balance = 0          // warning: missing type annotation for state field
                               //          `balance` in typed class `BankAccount`
  state: owner :: String       // OK

This parallels the existing check_typed_method_annotations logic — check StateDeclaration.type_annotation.is_none() when the class is typed.

Dynamic inference warning (gap)

When an expression in a typed class infers as Dynamic, warn — the user opted into thorough checking and should know where the compiler can't help.

typed Actor subclass: BankAccount
  process: handler =>
    handler doWork    // warning: expression inferred as Dynamic in typed class
                      //          `BankAccount` (parameter has no type annotation)

This uses the new DynamicReason from Phase 1 to produce actionable messages. Where Dynamic dispatch is intentional, suppress with @expect type:

typed Actor subclass: BankAccount
  process: handler =>
    @expect type
    handler doWork    // no warning — suppressed

Prior Art

TypeScript

Sorbet (Ruby)

mypy (Python)

Flow (Facebook)

Elixir

User Impact

Newcomer (from Python/JS/Ruby)

Smalltalk Developer

Erlang/BEAM Developer

Production Operator

Steelman Analysis

The core decision — showing Dynamic on hover, adding a coverage CLI, completing the typed diagnostic gaps — is uncontroversial. No cohort would argue against making invisible information visible. The one real deferred decision is tiered strictness.

For Tiered Strictness Now (typed strict, typed strong)

CohortArgument
Operator"I want typed strong for payment processing — zero Dynamic allowed. But my formatting helpers should stay loose. One tier forces me to choose between strictness on critical code and annotation noise on trivial code."
Language designer"Sorbet proved tiered strictness works at scale (Stripe, Shopify). You're building the infrastructure (DynamicReason) that makes tiers trivial — why not ship them while you're in the code?"
BEAM veteran"My gen_server callback module needs strict checking because message dispatch is safety-critical. The current typed warns on missing annotations but still allows Dynamic — that's not enough for code that handles money."

Why deferred: The current typed semantics (warn on missing annotations, check message sends) cover the most common use case. Tiered strictness is a language design decision with naming, inheritance, and interaction implications that deserve their own ADR. The visibility tooling shipped here provides the usage data to design the right tiers — which classes do teams mark typed? What coverage levels do they target? Where do they use @expect type? Ship, observe, then design tiers with evidence.

Alternatives Considered

Alternative: Visibility Only (No Diagnostic Completion)

Ship hover + coverage CLI without completing the typed diagnostic gaps.

Not chosen: The state field warning is a few lines of code alongside the existing check_typed_method_annotations. The Dynamic-inference warning is trivial once DynamicReason exists. Deferring them creates artificial sequencing — the infrastructure this ADR builds (DynamicReason) makes them essentially free.

Alternative: Global --no-implicit-dynamic Flag

A build flag that makes Dynamic inference a warning everywhere, not just in typed classes.

Rejected: This is the global strict mode that ADR 0025 explicitly rejected. "The same code behaves differently depending on who runs it." The typed modifier puts enforcement in the source where it's visible and consistent.

Alternative: Per-File Sigils (Sorbet-style)

A comment at the top of each .bt file controlling strictness.

Rejected: Beamtalk already has the typed keyword on the class declaration. Adding file-level sigils creates two competing mechanisms. The class-level modifier is more granular (a file can contain both typed and untyped classes) and more visible.

Alternative: IDE-Only Coverage (No CLI Command)

Show coverage only through LSP (hover, code lens, diagnostics) without a CLI tool.

Rejected: CI integration requires a CLI command. Teams need to track coverage over time, gate PRs on regressions, and generate reports for dashboards.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1: Dynamic Provenance + LSP Hover (M)

Affected components:

Tests: Update type checker tests (~16 sites) to use Dynamic(DynamicReason::Unknown) or the appropriate specific reason.

Phase 2: Complete typed Diagnostics (S)

Affected components:

Tests: Add test cases for state field warnings and Dynamic-inference warnings in typed classes, including @expect type suppression.

Phase 3: CLI Command (M)

Affected components:

Add a CoverageReport struct that walks the TypeMap and classifies each entry as typed (Known/Union) or untyped (Dynamic). The CLI command compiles the project (reusing the build pipeline), collects TypeMaps per module, and formats the report.

Tests: Integration tests compiling fixture .bt files with known type distributions, asserting expected coverage percentages and JSON output structure.

Future Work

Implementation Tracking

Epic: BT-1910 Issues:

References