ADR 0075: Erlang FFI Type Definitions

Status

Accepted (2026-04-02)

Context

The Problem

Beamtalk's Erlang FFI (ADR 0028) lets users call any Erlang function via message-send syntax:

Erlang lists reverse: #(3, 2, 1)    // => #(1, 2, 3)
Erlang maps merge: a with: b        // => merged map
Erlang crypto hash: #sha256 with: "hello"

This works at runtime, but the type checker has zero visibility into Erlang function signatures. Every FFI call returns Dynamic, which means:

This is the single largest gap in typed coverage. A codebase that is fully typed in Beamtalk loses all type information the moment it touches Erlang — which is frequent, since OTP, Hex packages, and hand-written native modules all cross this boundary.

Current State

Type checker: When the receiver is Erlang or ErlangModule, the type checker skips DNU warnings (because both classes override doesNotUnderstand:args:) and infers Dynamic for the return type. See inference.rs and validation.rs.

Existing infrastructure that makes this solvable:

  1. Beamtalk→Erlang type mapping is already defined in crates/beamtalk-core/src/codegen/core_erlang/spec_codegen.rs (lines 33–51) for generating Dialyzer -spec attributes. The forward mapping includes types not present in Erlang specs (e.g., Characterinteger(), Setmap()). The reverse mapping (Erlang→Beamtalk) covers the types that actually appear in Erlang -spec annotations:

    Erlang typeBeamtalk type
    integer()Integer
    float()Float
    number()Number
    binary()String
    boolean()Boolean
    atom()Symbol
    list() / [T]List / List(T)
    tuple()Tuple
    map()Dictionary
    pid()Pid
    fun()Block
    true / falseTrue / False
    nilNil
    term() / any()Dynamic
  2. beam_lib:chunks/2 is already used in beamtalk_module_activation.erl to read BEAM file attributes. The same API reads -spec attributes from the abstract_code chunk (available when modules are compiled with +debug_info, which is the default for OTP, rebar3, and Beamtalk's own compile.escript).

  3. EEP-48 doc chunks are already post-processed from .beam files (ADR 0008) — the build pipeline already does BEAM chunk extraction.

  4. OTP spec coverage is excellent: core modules (lists, maps, string, file, io, ets, gen_server, erlang) are essentially 100% specced since OTP 18+. Coverage drops in obscure internal modules but is strong where it matters.

  5. validate_specs.escript already extracts and processes spec attributes from compiled output — the tooling pattern exists.

  6. ADR 0028 explicitly identified spec extraction as future work: "Erlang parameter name introspection — with +debug_info, beam_lib:chunks/2 can extract parameter names from the abstract code AST [...] Requires a spike first."

Constraints

  1. Must work for any BEAM module — OTP, Hex packages, hand-written Erlang in native/ (ADR 0072). Not just a curated OTP subset.
  2. Must not break existing code — FFI calls without type info must continue to work (fall back to Dynamic).
  3. Must match user's actual environment — type info should reflect the OTP version and dependencies actually installed, not a frozen snapshot.
  4. Quality must exceed auto-extraction for commonly used modules — term()Dynamic is technically correct but unhelpful for lists:member/2 or ets:lookup/2.
  5. Must integrate with existing type checkerInferredType enum, TypeProvenance, class hierarchy, and the gradual typing model (ADR 0025).
  6. Must support the package system — packages (ADR 0070, 0072) can bundle type definitions for their native Erlang code.

Decision

Hybrid: Auto-Extract from .beam + Stub Override Files

A two-layer system that provides automatic baseline typing for all specced Erlang modules on the code path and curated overrides where precision matters.

Layer 1: Auto-Extract from .beam Abstract Code

At build time, the compiler reads the abstract_code chunk from compiled .beam files on the code path, extracting both -spec type annotations and parameter names in a single pass. This builds a NativeTypeRegistry mapping module/function/arity to Beamtalk types.

How it works:

  1. An Erlang helper module (beamtalk_spec_reader.erl) reads the abstract_code chunk via beam_lib:chunks(File, [abstract_code]):
    • abstract_code chunk — contains {attribute, _, spec, ...} forms (function type signatures with named type variables like From :: integer(), Elem :: T) and {function, _, Name, Arity, Clauses} forms (clause param names as fallback). Provides types AND meaningful parameter names.
    • No .erl source parsing needed — abstract_code contains everything needed for type checking.
    • EEP-48 docs are read separately at runtime (not at build time) — see "Documentation" below.
  2. The Rust compiler invokes it via the existing beamtalk_build_worker pattern (same mechanism used for .core.beam compilation)
  3. The Erlang→Beamtalk type mapping (reverse of spec_codegen.rs) converts Erlang abstract type representations to InferredType values, using spec variable names as keyword names
  4. Results are cached per module and invalidated when .beam file timestamps change

Parameter names come from specs, not source parsing:

Verified against OTP: spec variable names in abstract_code are high quality. lists:seq/2 has {var, _, 'From'} and {var, _, 'To'} directly in the spec form. lists:member/2 has {var, _, 'Elem'} and {var, _, 'List'}. These are better than function clause params (which often use _ for pattern-matched arguments).

%% In lists.beam abstract_code — spec form for seq/2:
{attribute, _, spec, {{seq, 2}, [{bounded_fun, ...
    {product, [{var, _, 'From'}, {var, _, 'To'}]}, ...

Parameter name → keyword mapping: Spec variable names are lowercased to Beamtalk keywords. The first becomes the function keyword; subsequent become positional keywords:

seq/2 spec vars: From, To → seq: from :: Integer to: to :: Integer -> List(Integer)
member/2 spec vars: Elem, List → member: elem :: Dynamic in: list :: List -> Boolean

When abstract_code is unavailable (+debug_info stripped), parameter names fall back to positional with: convention. Types are still correct, only keyword names lose meaning.

+debug_info dependency:

Spec extraction requires the abstract_code chunk, which is only present in .beam files compiled with +debug_info. This is widely available but not universal:

Source+debug_info by default?Notes
OTP modulesYesShipped with debug info since OTP 14+
rebar3 depsYes{erl_opts, [debug_info]} is rebar3's default
Beamtalk native .erlYescompile.escript includes debug_info
Mix deps (dev)YesMix includes debug_info by default
Mix release buildsNomix release strips debug info since Elixir 1.9
Hex packages (precompiled)VariesSome strip for size optimization

Policy: When a .beam file lacks abstract_code, the spec reader returns no specs for that module — it silently falls through to Dynamic (layer 5 in the resolution chain). The build emits a one-time info-level diagnostic: "Note: <module>.beam has no debug_info — auto-extracted types unavailable. Add a stub file in stubs/ for type coverage." This is not a warning (it's expected for some packages) but gives users a path forward.

Important: This limitation only affects auto-extraction. Curated stub files work regardless of +debug_info — they are the recommended path for packages known to strip debug info.

Beamtalk-compiled .beam files: Note that Beamtalk compiles via Core Erlang, and Core Erlang compilation does not preserve -spec attributes in the abstract_code chunk (documented in validate_specs.escript). However, this is not a problem: Beamtalk-compiled modules already have full type information in the Beamtalk type checker via ClassHierarchy — auto-extraction targets foreign (non-Beamtalk) .beam files.

Example — what auto-extraction provides:

%% In lists.erl (source) + lists.beam (specs):
-spec reverse(List) -> List when List :: [T].
reverse(List) -> ...

-spec seq(From, To) -> Seq
      when From :: integer(), To :: integer(), Seq :: [integer()].
seq(From, To) -> ...

-spec member(Elem, List) -> boolean()
      when Elem :: T, List :: [T].
member(Elem, List) -> ...

Auto-extracts to (with parameter names from spec variable names in abstract_code):

FunctionBeamtalk signatureSource of keyword names
reverse/1reverse: list :: List(T) -> List(T)List from function head
seq/2seq: from :: Integer to: to :: Integer -> List(Integer)From, To from function head
member/2member: elem :: Dynamic in: list :: List -> BooleanElem, List from function head

Note: Elem :: T with no constraint maps to Dynamic — correct but imprecise. Project-local stubs can tighten this where needed.

Type variable handling:

Edge cases:

Erlang patternBeamtalk mappingRationale
{ok, T} | {error, E}Tuple (see note below)Requires separate ADR for Result(T, E) conversion
term() / any()DynamicNo useful type info
non_neg_integer()IntegerBeamtalk has no subrange types (yet)
iodata() / iolist()DynamicRecursive type, no Beamtalk equivalent
-opaque typesDynamicOpaque by design — don't expose internals
Multiple -spec clausesUnion of return typesCommon for overloaded Erlang functions
no_return()DynamicFunctions that never return (throw/exit)
map() (untyped)DictionaryBeamtalk equivalent
#{key := Type}DictionaryTyped map keys not yet supported in Beamtalk

{ok, T} | {error, E}Result(T, E) conversion (future ADR):

The most common Erlang return pattern — {ok, Value} | {error, Reason} — maps to Tuple in this ADR, which loses the inner types. The preferred direction is a separate ADR for ok/error tuple → Result(T, E) conversion at the FFI boundary, following the same pattern as charlist → String coercion (BT-1127). This would:

  1. Convert {ok, V}Result ok: V and {error, R}Result error: R in the proxy at runtime
  2. Map the Erlang spec {ok, binary()} | {error, posix()}Result(String, Symbol) in auto-extract
  3. Give users map:, andThen:, and full Result combinators on FFI return values

This is a meaningful change to the FFI's "transparent interop" principle (ADR 0028) and involves runtime, codegen, and type system changes — hence a separate ADR rather than a row in this table. Until that ADR lands, {ok, T} | {error, E} specs map to Tuple and users use isOk/unwrap from the Tuple class.

Documentation: EEP-48 Docs Read at Runtime

EEP-48 documentation (per-function docs with examples) is read dynamically at runtime, not extracted at build time. A single Erlang module (beamtalk_native_docs) reads the "Docs" chunk from .beam files on demand:

%% beamtalk_native_docs:lookup(lists, reverse, 1) →
%% #{doc => <<"Returns a list with elements in reverse order...">>,
%%   sig => <<"reverse(List1)">>,
%%   examples => <<"lists:reverse([1,2,3]) → [3,2,1]">>}

Three consumers share one codepath:

Why runtime, not build time:

EEP-48 docs are available in OTP 25+ and most Hex packages. When the "Docs" chunk is absent, :help falls back to showing only the type signature from NativeTypeRegistry.

Layer 2: Stub Override Files (.bt in stubs/)

Hand-curated stub files that override or supplement auto-extracted types. These add meaningful keyword names, tighter types, and type information for unspecced functions.

Key design decision: Stubs are valid .bt files parsed by the existing parser — not a separate stub format with a separate parser. This follows TypeScript's principle that declaration files use the same language. However, unlike TypeScript where .d.ts describes TypeScript types requiring the full type system parser, our stubs need only method signatures with type annotations — a small subset already implemented for protocol definitions. We add a single new top-level form (declare native:) and reuse the existing protocol method signature parser for the body.

Stub file format:

// stubs/lists.bt

/// Type declarations for Erlang module `lists`.
/// OTP compatibility: 25+
declare native: lists

  /// Reverse a list.
  reverse: list :: List(T) -> List(T)

  /// Generate a sequence of integers.
  seq: from :: Integer to: to :: Integer -> List(Integer)
  seq: from :: Integer to: to :: Integer step: step :: Integer -> List(Integer)

  /// Test membership.
  member: elem :: T in: list :: List(T) -> Boolean

  /// Sort a list using the default comparison.
  sort: list :: List(T) -> List(T)

  /// Sort with a custom comparator.
  sort: list :: List(T) by: comparator :: Block(T, T, Integer) -> List(T)

  /// Apply a function to each element.
  map: fun :: Block(A, B) with: list :: List(A) -> List(B)

  /// Left fold.
  foldl: fun :: Block(T, Acc, Acc) with: acc :: Acc with: list :: List(T) -> Acc

Format rules:

What reusing the parser buys us:

What stubs are NOT:

Override semantics:

A stub declaration for lists:reverse/1 completely replaces the auto-extracted type for that function/arity. If a function has a stub declaration, auto-extracted types are ignored for that specific function/arity — not merged.

Resolution Order

When the type checker encounters Erlang <module> <function>: args, it resolves the function's type signature using this precedence (highest wins):

1. Project-local stubs/     — user overrides in their own project
2. Package-bundled stubs/   — library author ships stubs for own native code
3. Distribution stubs/      — curated stubs shipped with the Beamtalk compiler
4. Auto-extracted            — read from .beam abstract_code at build time
5. Dynamic                  — no type info available (same as today)

Note: project-local stubs take highest precedence because the user should always be able to override any type declaration. Package stubs only cover a package's own native code (not its dependencies). Auto-extract covers everything else — including Hex dependencies, which get meaningful keyword names from their source files.

Each layer is a complete override at the function/arity level. If stubs/lists.bt declares reverse/1, that definition is used — the .beam spec is not consulted for that function. But lists:nth/2, not declared in the stub file, still uses the auto-extracted type.

Type Checker Integration

The type checker gains a NativeTypeRegistry that stores resolved function signatures:

/// Registry of Erlang function type signatures, populated at build time.
///
/// Resolution: stub overrides > auto-extracted .beam specs > Dynamic
struct NativeTypeRegistry {
    /// module → function_name → arity → FunctionSignature
    modules: HashMap<EcoString, HashMap<EcoString, Vec<FunctionSignature>>>,
}

struct FunctionSignature {
    params: Vec<ParamType>,
    return_type: InferredType,
    provenance: TypeProvenance,  // Declared(stub span) or Extracted(.beam)
}

struct ParamType {
    keyword: Option<EcoString>,  // meaningful keyword name (from stubs only)
    type_: InferredType,
}

When the type checker encounters a message send on ErlangModule:

  1. Extract the module name from the proxy receiver
  2. Extract the function name from the first keyword and count arguments for arity
  3. Look up (module, function, arity) in NativeTypeRegistry
  4. If found: check argument types positionally against params, return the declared return_type
  5. If not found: return Dynamic (same as today — no regression)

Positional matching — keyword names are not checked:

Erlang FFI calls are positional (ADR 0028 §1 — keywords are stripped, only argument order matters). The type checker matches by (module, function, arity) and checks parameter types by position, regardless of the keywords used at the call site:

// Stub declares: seq: from :: Integer to: to :: Integer -> List(Integer)

Erlang lists seq: 1 to: 10        // ✅ param 1 = Integer, param 2 = Integer
Erlang lists seq: 1 with: 10      // ✅ same positional check — keywords don't matter
Erlang lists seq: "a" to: 10      // ⚠️ param 1 expects Integer, got String

Keyword mismatch warning (footgun prevention):

FFI calls are positional — a fundamental difference from normal Beamtalk dispatch where keyword names ARE the method identity. To prevent users from assuming keyword-name matching at the FFI boundary, the type checker emits a warning when call-site keywords don't match the stub declaration:

> Erlang lists seq: 1 foo: 10
  ⚠️ Warning: FFI keyword 'foo:' does not match stub declaration 'to:' for lists:seq/2 parameter 2
    Hint: FFI calls are positional — keyword names don't affect dispatch.
    Preferred form: Erlang lists seq: <from> to: <to>

This warning is suppressed for the universal with: fallback (ADR 0028's established convention) — Erlang lists seq: 1 with: 10 does not warn.

The warning teaches users that FFI dispatch differs from Beamtalk method dispatch, and nudges them toward the stub-declared keyword form for readability. The keyword names in stubs appear in LSP completions and hover to guide users toward the preferred form.

Module identity tracking:

The type checker must know which Erlang module a proxy represents to look up types. This works for two patterns:

This requires extending ErlangModule's type representation from a simple Known { class_name: "ErlangModule" } to Known { class_name: "ErlangModule", type_args: [Known("lists")] } — using the existing generic type infrastructure from ADR 0068. The module name becomes a phantom type parameter.

Diagnostics from typed FFI calls:

> Erlang lists reverse: 42
  ⚠️ Warning: lists:reverse/1 parameter 1 expects List, got Integer
  (type from stubs/lists.bt:5)

> Erlang lists reverse: #(1, 2, 3)
  // type checker infers: List — downstream code gets typed

Provenance tracking: Types from stub files have TypeProvenance::Declared (with span pointing into the stub file). Types from auto-extraction have a new TypeProvenance::Extracted variant. This distinction appears in diagnostics so users know where the type info came from.

Stub Generation Tool

A beamtalk generate stubs command bootstraps stub files from .beam abstract code:

# Generate stubs for specific OTP modules (curating compiler-distributed stubs)
beamtalk generate stubs lists maps string file io ets

# Output: stubs/lists.bt, stubs/maps.bt, ...

# Package author: generate stubs for own native Erlang modules
beamtalk generate stubs --native-dir native/

# Output: stubs/beamtalk_http.bt, stubs/beamtalk_http_server.bt, ...

The generate stubs command lives under a new beamtalk generate subcommand group (which also absorbs the existing gen-native command as beamtalk generate native). Note: there is no --include-deps flag — Hex dependency types come from auto-extract at build time, which always matches the installed version.

Because auto-extract reads spec variable names from abstract_code, the generated output is already high quality. The author may still want to tighten Dynamic params or adjust keyword names, but the starting point is much closer to the final result.

Example generated stub (from .beam abstract code):

// Auto-generated from lists.erl + lists.beam (OTP 27)
declare native: lists

  reverse: list :: List(T) -> List(T)
  seq: from :: Integer to: to :: Integer -> List(Integer)
  member: elem :: Dynamic in: list :: List -> Boolean

Only member needs human refinement (DynamicT). The keyword names are already meaningful because they came from the Erlang source.

Distribution Model

Auto-extract is the primary mechanism. Because auto-extract reads specs and parameter names directly from .beam abstract_code (no source parsing needed), it produces high-quality types for any well-specced Erlang module — OTP, Hex dependencies, and package native code alike. This eliminates the need for packages to ship type stubs for their dependencies.

Shipped with the compiler:

Package-bundled stubs (own native code only):

Project-local stubs (user overrides):

Auto-extract covers everything else:

REPL Experience

> Erlang lists seq: 1 to: 10
#(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
// type: List(Integer) — from stubs/lists.bt

> Erlang crypto hash: #sha256 with: "hello"
<<44, 242, ...>>
// type: String — auto-extracted from crypto.beam

> Erlang obscure_lib do_thing: 42
// type: Dynamic — no stub, no spec

> Erlang lists reverse: 42
⚠️ Warning: lists:reverse/1 parameter 1 expects List(T), got Integer
  (type from stubs/lists.bt:5)

> :help Erlang lists reverse
  reverse: list :: List(T) -> List(T)

  Returns a list with the elements in List1 in reverse order.

  ## Examples
    lists:reverse([1, 2, 3, 4]) → [4, 3, 2, 1]

  (from lists.beam EEP-48 docs)

Error Examples

Stub parse error:

error: Failed to parse stubs/lists.bt:7
  Expected type annotation after '::', got ')'
  7 |   seq: from :: Integer to: to :: -> List(Integer)
                                         ^

Arity mismatch between stub and actual function:

⚠️ Warning: stubs/lists.bt declares lists:seq/3 but lists.beam exports seq/2 and seq/3
  Stub declaration for lists:seq/3 will be used; lists:seq/2 has no stub (auto-extracted)

Version drift detection:

⚠️ Warning: stubs/lists.bt declares lists:enumerate/1 but it is not exported from lists.beam
  This function may have been added in a newer OTP version
  Stub will be ignored for lists:enumerate/1

Prior Art

TypeScript — .d.ts and DefinitelyTyped

TypeScript's approach is the closest analogy. Libraries written in TypeScript auto-generate .d.ts declarations; JavaScript libraries get community-maintained type definitions via DefinitelyTyped (@types/xxx packages on npm).

What we adopt:

What we adapt:

What we reject:

Gleam — @external with Gleam Types

Gleam requires per-function @external declarations with Gleam type signatures for every Erlang function call. gleam_stdlib is essentially a curated, typed wrapper around OTP.

What we adopt:

What we reject:

Kotlin/Native — cinterop Auto-Generation

Kotlin's cinterop tool reads C headers and auto-generates Kotlin stubs with mapped types. A .def file describes which headers to import.

What we adopt:

What we adapt:

Swift — Clang Module Maps

Swift imports C/Objective-C headers automatically via Clang's module system. Nullability annotations in C headers directly affect Swift's optional types.

What we adopt:

Erlang Dialyzer — PLT Type Inference

Dialyzer builds a Persistent Lookup Table with success typings — types inferred from code analysis, supplementing declared -spec annotations.

What we considered but deferred:

User Impact

Newcomer

Positive: FFI calls that previously produced Dynamic now produce typed results. The LSP can offer typed completions when typing Erlang lists — showing reverse: List(T) -> List(T) instead of just reverse/1. Type errors at FFI boundaries are caught at compile time with actionable messages.

Neutral: Auto-extract means this works out of the box. No new concepts to learn — FFI calls look the same, they just get better checking.

Risk: Users may not understand why some Erlang functions have precise types and others return Dynamic. Mitigation: diagnostic messages include provenance ("type from stubs/lists.bt" vs "type from lists.beam -spec" vs "no type info available").

Smalltalk Developer

Positive: Stubs use familiar Beamtalk keyword syntax — they're valid .bt files. Meaningful keyword names in stubs make FFI calls read more like Smalltalk message sends: Erlang lists seq: 1 to: 10 instead of Erlang lists seq: 1 with: 10.

Concern: The declare native: form is a new concept. Mitigation: stubs are optional — the system works without them. Only library authors or power users need to write stubs. The syntax is a small subset of existing Beamtalk, not a new language.

Erlang/BEAM Developer

Positive: Their existing -spec annotations are automatically used — no duplicate work. Types match their actual OTP version. Hex packages with good specs get automatic typing.

Concern: The Erlang→Beamtalk type mapping may lose precision (e.g., non_neg_integer()Integer). Mitigation: this is a limitation of Beamtalk's type system, not the extraction mechanism. As Beamtalk's types become richer, the mapping improves.

Operator

Positive: Auto-extraction means type info matches the deployed OTP version exactly. No risk of stubs claiming a function exists when it doesn't (or vice versa) — version drift detection warns on mismatches.

Concern: Build time increases slightly (reading .beam files for specs). Mitigation: results are cached per module; only re-extracted when .beam timestamps change. Incremental builds read zero .beam files.

Tooling Developer

Positive: NativeTypeRegistry is a clean, queryable data structure. The LSP can use it for completions, hover, and signature help on FFI calls. Provenance tracking enables "go to type definition" that jumps to the stub file. Because stubs are valid .bt files, all existing LSP features (syntax highlighting, completions, hover) work on stub files for free.

Concern: Two sources of truth (auto-extracted + stubs) means the registry merge logic must be correct. Mitigation: the merge is simple — stubs win per function/arity, everything else is auto-extracted.

Steelman Analysis

Best Argument for Pure Stubs (No Auto-Extract)

CohortStrongest argument
Newcomer"I can read the stub file and see exactly what's available — it's self-documenting, like TypeScript's .d.ts"
Smalltalk purist"Types should be curated by humans who understand the domain, not mechanically extracted from a foreign type system with its own quirks"
BEAM veteran"Erlang specs are sometimes wrong or overly broad — term() everywhere. Hand-written stubs can be more honest about what a function actually accepts"
Operator"No build-time dependency on reading .beam files; stubs are static, deterministic, cacheable"
Language designer"Full control over the FFI type surface — we can evolve the stub format independently of Erlang's type system evolution"

Why we don't choose this: The bootstrapping problem is severe. Beamtalk's community is small. Manually writing stubs for even 20 OTP modules is weeks of work, and the long tail of Hex packages would never get coverage. Auto-extract provides immediate, zero-effort baseline typing.

Best Argument for Pure Auto-Extract (No Stubs)

CohortStrongest argument
Newcomer"It just works — I don't need to find or install stub files. Every Erlang module is automatically typed"
Smalltalk purist"The system should figure it out — I shouldn't write boilerplate declarations for things the machine already knows"
BEAM veteran"My Erlang specs are already correct. Don't make me write them again in a different syntax"
Operator"Types always match the actual OTP version deployed — no version drift, no surprises"
Language designer"Minimal surface area — one mechanism, no new file format to design and maintain"

Why we don't choose this: Auto-extract is limited by what Erlang specs express. term() maps to Dynamic — and for the ~20 most-used OTP modules, Dynamic on key parameters (e.g., ets:lookup/2, lists:member/2) is genuinely unhelpful. Source+beam auto-extract provides meaningful keyword names, but cannot tighten term() types. Curated stubs for the high-traffic modules close this gap.

Tension Points

Alternatives Considered

Alternative A: Pure Stubs (No Auto-Extract)

Require hand-written stub files for every Erlang module that should have type info. Functions without stubs return Dynamic.

// Must write this before lists:reverse gets typed
declare native: lists
  reverse: list :: List(T) -> List(T)
  // ... hundreds more functions

Rejected. The bootstrapping problem is too severe for a small community. TypeScript could do this because millions of developers contributed to DefinitelyTyped. Beamtalk can't rely on community scale. Auto-extract gives immediate value for zero effort.

Alternative B: Pure Auto-Extract (No Override Mechanism)

Read all types from .beam specs. No stub files, no overrides.

Rejected. Quality ceiling is too low. Erlang specs use term() broadly, keyword names are lost, and overloaded specs can produce confusing unions. For the 20 most-used OTP modules, human curation meaningfully improves the developer experience. Without an override mechanism, there's no way to provide that.

Alternative C: Wrapper Classes (Gleam-Style)

Require typed Beamtalk wrapper classes for every Erlang module:

Object subclass: Lists
  reverse: list :: List(T) -> List(T) =>
    Erlang lists reverse: list

Rejected. Conflicts with Principle 1 (Interactive-first) — users must write a wrapper before calling any Erlang function. Beamtalk's Erlang proxy was specifically designed to avoid this. Also duplicates every function call through an unnecessary indirection layer.

Alternative D: Dialyzer PLT Extraction

Read inferred types from Dialyzer's PLT files to supplement missing -spec annotations.

Rejected (for now). PLT format is internal to Dialyzer and changes between OTP versions. The coupling risk exceeds the benefit. Most commonly-used OTP functions have explicit -spec annotations. If we need types for unspecced functions in the future, this can be revisited as an additional layer below auto-extract in the resolution chain.

Consequences

Positive

Negative

Neutral

Implementation

Design principle: The type registry IS the language service (Principle 12). Typed LSP completions ship in the same phase as the type registry — not as a follow-on. The user types Erlang lists r and sees reverse: List(T) -> List(T) the moment auto-extract is working.

Phase 0: Spec Extraction Spike

Validate the core assumption before building full infrastructure. ADR 0028 explicitly deferred this: "Requires a spike first."

  1. Write beamtalk_spec_reader.erl that reads abstract_code from a single .beam file, extracting spec forms and spec variable names (parameter names) in one pass
  2. Run it against lists.beam and maps.beam from the user's OTP installation — verify specs and param names are present and parseable
  3. Implement a minimal Erlang→Beamtalk type mapping for the extracted specs (just the core types: integer(), list(), binary(), boolean(), atom())
  4. Wire one end-to-end lookup: Erlang lists reverse: #(1, 2, 3) should resolve to return type List in the type checker
  5. LSP proof-of-concept — verify that compute_erlang_completions() can query the prototype registry and show reverse: List -> List alongside the existing selector completions
  6. Verify against .beam files with and without +debug_info — confirm graceful fallback to Dynamic

Validates: abstract_code chunk availability, spec format parsing, type mapping correctness, build worker integration, LSP integration path.

Components: beamtalk_spec_reader.erl (new), minimal NativeTypeRegistry prototype, one type checker test, one LSP completion test

Phase 1: Auto-Extract + Basic LSP Completions

Build the full Erlang spec reader, Rust integration, and typed LSP completions — these ship together.

  1. beamtalk_spec_reader.erl — Extend the Phase 0 spike to handle all Erlang type forms, batch-process multiple modules, and emit results as structured terms via the build worker protocol. Parameter names come from spec variable names in abstract_code — no .erl source parsing needed
  2. Erlang→Beamtalk type mapping — Rust module that converts the full range of Erlang abstract type representations to InferredType values (reverse of spec_codegen.rs), including generic type variables, union types, the edge cases table above, and spec variable name → keyword name conversion
  3. NativeTypeRegistry — new struct in the type checker that stores resolved function signatures
  4. Build integration — invoke spec reader during beamtalk build, cache results per module in _build/type_cache/
  5. Type checker integration — look up FFI call types in the registry during inference
  6. LSP typed completions — extend compute_erlang_completions() to query NativeTypeRegistry and display type signatures alongside selectors. The existing detect_erlang_module_context() already identifies Erlang <module> patterns — it just needs to include type info from the registry

Components: beamtalk_spec_reader.erl (new), crates/beamtalk-core/src/semantic_analysis/type_checker/native_types.rs (new), beam_compiler.rs (extended), inference.rs (extended), completion_provider.rs (extended)

Gate: Evaluate Auto-Extract Quality

Before proceeding to Phase 2, evaluate whether auto-extract alone is sufficient:

  1. Run auto-extract against the full OTP installation and the stdlib's Hex dependencies (cowboy, gun, etc.)
  2. Review the generated types for the 20 most-used modules — how many functions have useful types vs Dynamic?
  3. Review keyword name quality from source — are the auto-derived names readable?
  4. Collect real-world feedback: use the typed LSP in daily development for a sprint
  5. Identify which specific functions (if any) need Dynamic tightened via stubs

Decision point: If auto-extract quality is high enough (>90% of commonly-called functions have useful types and keyword names), Phase 2 (stubs) can be deferred indefinitely. Proceed to Phase 3 (CLI cleanup) and Phase 4 (REPL devex) regardless — they don't depend on stubs.

Phase 2: declare native: Parse Form and Stub Files

  1. declare native: top-level form — add to the existing parser as a new top-level declaration (alongside class and protocol definitions). Parses declare native: <ident> header, then reuses the existing protocol method signature parser for the body. No separate stub_parser.rs needed
  2. Build pipeline integrationstubs/ directory is scanned during build; .bt files there are parsed but skip codegen, populating NativeTypeRegistry only
  3. Resolution chain — merge stubs with auto-extracted types (stubs win per function/arity)
  4. Initial OTP stubs — curate stub files for 10 core modules: lists, maps, string, file, io, ets, gen_server, erlang, math, crypto

Components: crates/beamtalk-core/src/source_analysis/parser/declarations.rs (extended — new parse_declare_native), crates/beamtalk-core/src/ast/ (new NativeDeclaration AST node), stubs/*.bt (new), NativeTypeRegistry (extended)

Phase 3: beamtalk generate CLI and Package Integration

Introduce a new beamtalk generate subcommand group, absorbing the existing gen-native command:

beamtalk generate native MyActor                              # existing gen-native, moved here
beamtalk generate stubs lists maps string                     # generate OTP module stubs
beamtalk generate stubs --native-dir native/                  # package author workflow
  1. beamtalk generate subcommand group — new top-level command with native and stubs subcommands. gen-native is removed (not yet shipped in a release, no backward compatibility needed)
  2. beamtalk generate stubs — reads .beam abstract_code and generates declare native: stub files with meaningful keyword names from spec variable names. --native-dir reads a package's native Erlang output
  3. beamtalk.toml integration — packages declare stubs via [stubs] path = "stubs/" for their own native code. Hex dependency types come from auto-extract — no dep stubs needed
  4. Dependency resolution — collect stubs from transitive dependencies during build
  5. Expand curated stubs to ~20 OTP modules

CLI output examples:

$ beamtalk generate stubs lists maps
  Reading lists.beam ... 91 specs found
  Reading maps.beam ... 39 specs found
  Generated stubs/lists.bt (91 functions)
  Generated stubs/maps.bt (39 functions)

  Refine keyword names and tighten types, then commit.

$ beamtalk generate stubs --native-dir native/
  Reading native/beamtalk_http.erl + .beam ... 12 specs, 12 with param names
  Reading native/beamtalk_http_server.erl + .beam ... 8 specs, 8 with param names
  Generated stubs/beamtalk_http.bt (12 functions)
  Generated stubs/beamtalk_http_server.bt (8 functions)

  Ship these in your package's stubs/ directory.
  Hex dep types (cowboy, gun) are auto-extracted at build time — no stubs needed.

Components: crates/beamtalk-cli/src/commands/generate/ (new directory — cli.rs, native.rs moved from gen_native.rs, stubs.rs), main.rs (updated), beamtalk.toml schema (extended)

Phase 4: Advanced LSP and REPL Integration

Basic typed completions ship in Phase 1. This phase adds richer tooling and runtime doc support:

  1. beamtalk_native_docs.erl — runtime module that reads EEP-48 "Docs" chunks from .beam files on demand. Single codepath shared by REPL, LSP, and MCP
  2. REPL :help Erlang lists — extend handle_help_topic() to detect "Erlang " and show type signatures from NativeTypeRegistry plus EEP-48 docs from beamtalk_native_docs. :help Erlang lists reverse shows the type signature plus the full Erlang doc with examples
  3. REPL tab completion — include type signatures in Erlang module completions (complements Phase 1's LSP completions with the same data in the REPL context)
  4. LSP hover — display type signature (from NativeTypeRegistry) and EEP-48 doc (requested from workspace runtime via WebSocket) on hover
  5. LSP signature help — show parameter types as user types arguments
  6. LSP go to type definition — jump to stub file for stub-typed functions
  7. LSP diagnostics — surface type warnings from FFI calls in the editor

Components: runtime/apps/beamtalk_runtime/src/beamtalk_native_docs.erl (new), crates/beamtalk-cli/src/commands/repl/mod.rs (extended), crates/beamtalk-lsp/src/completion_provider.rs (extended), crates/beamtalk-lsp/src/hover_provider.rs (extended)

Future Work

Migration Path

No existing behavior changes. This is purely additive:

Implementation Tracking

Epic: BT-1839 Issues: BT-1840, BT-1841, BT-1842, BT-1843, BT-1844, BT-1845, BT-1846, BT-1847, BT-1848, BT-1849, BT-1850, BT-1851, BT-1852, BT-1853 Status: Phase 1 Complete — Phase 2 Deferred (see gate evaluation)

PhaseIssueTitleSizeStatus
0BT-1840Spike: Extract Erlang specs + param names from .beamSDone
1BT-1841Runtime: Full spec reader with batch processingMDone
1BT-1842Type checker: Erlang→Beamtalk type mapping + NativeTypeRegistryMDone
1BT-1843Type checker: FFI call inference + keyword mismatch warningMDone
1BT-1844Build + LSP: Cache integration + typed completionsMDone
GateBT-1845Gate: Evaluate auto-extract quality — decide Phase 2SDone
2BT-1846Parser: declare native: top-level formMDeferred
2BT-1847Build: Stub resolution chainSDeferred
2BT-1848Curate initial OTP stubs (10 modules)MDeferred
3BT-1849CLI: Create beamtalk generate subcommand groupMPlanned
3BT-1850CLI: Implement beamtalk generate stubsMPlanned
4BT-1851Runtime: beamtalk_native_docs EEP-48 readerSPlanned
4BT-1852REPL: :help Erlang <module> + tab completionMPlanned
4BT-1853LSP: Hover, signature help, go-to-definition for FFIMPlanned

References