ADR-0057: Anonymous Interfaces
Status
Implemented
Summary
Allow interface { fn name(self) -> T; ... } to appear as a TypeExpr inside a comptime function body that returns type. Each unique parameterization produces a distinct, structurally-deduplicated InterfaceId, which slots into the existing ADR-0056 conformance and vtable machinery without changes. This enables parameterized interfaces like fn Sized(comptime T: type) -> type { interface { fn size(self) -> T; } } and unblocks designs that need interfaces over types — iterator shapes, container shapes, comparable-with-key shapes — exactly the same way anonymous structs and enums (ADR-0029, ADR-0039) unblocked parameterized data types.
Context
What we have
- Named interfaces (ADR-0056) — declared at module scope, registered as
InterfaceIdinSema::interfaces, used either as a comptime constraint (comptime T: I) or as a runtime fat-pointer type (borrow t: I/inout t: I). Conformance is structural; vtables are emitted per(StructId, InterfaceId)pair. - Anonymous structs (ADR-0029) and anonymous enums (ADR-0039) — built inside
fn ... -> typebodies viastruct { ... }/enum { ... }type expressions. The comptime interpreter constructs them with the enclosing function's comptime params substituted, and the type pool deduplicates structurally identical results so two call-sites with the same args reuse the same type. - Anonymous functions (ADR-0055) — each lambda site produces a fresh callable struct with a
__callmethod.
What's missing
There's no way to parameterize an interface over types or values:
// Doesn't parse today:
fn Sized(comptime T: type) -> type {
interface {
fn size(self) -> T;
}
}
fn use_sized(comptime T: type, borrow s: Sized(T)) -> T {
s.size()
}
Users wanting "container of T" or "iterator over T" interfaces are forced to either:
- Re-declare the interface as a new top-level decl per
T(duplication scales linearly with the parameter space), or - Drop dynamic dispatch entirely and bounce through
comptime T: typewith re-checked bodies (loses the conformance contract).
Since named interfaces, anonymous structs, and the comptime interpreter are all already implemented, the missing piece is the third corner of the table: anonymous, comptime-constructed interfaces.
Why this is small relative to ADR-0056
ADR-0056 had to land:
- A new keyword and parser productions
- A new
Typevariant,InterfaceDef, vtable storage, conformance algorithm - AIR / CFG / LLVM lowering for
MakeInterfaceRefandMethodCallDyn - Per-(struct, interface) vtable globals
This ADR doesn't touch any of that. The conformance check is type-by- type and doesn't care how the InterfaceId came to be. Vtable emission is keyed on (StructId, InterfaceId) so each unique Sized(i32) vs Sized(i64) instantiation gets its own vtable naturally. The only new work is constructing an InterfaceId from a comptime expression and deduplicating structurally identical results.
Decision
Syntax
Add interface { ... } as a new variant of TypeExpr, parallel to AnonymousStruct and AnonymousEnum:
fn Greeter(comptime T: type) -> type {
interface {
fn greet(self) -> T;
}
}
Grammar (extending chapter 6.5):
interface_type_expr := "interface" "{" { method_sig } "}" ;
method_sig := "fn" IDENT "(" "self" [ "," params ] ")"
[ "->" type ] ";" ;
The body is the same method_sig form already used for named interfaces — bodies are not allowed; receiver is self; trailing semicolon required.
Use sites
The result is a Type::new_interface(iid) value, which slots into all existing interface positions:
// Comptime constraint:
fn use_via_comptime(comptime T: type, comptime U: Sized(T), u: U) -> T {
u.size()
}
// Runtime borrow:
fn use_via_borrow(comptime T: type, borrow s: Sized(T)) -> T {
s.size()
}
Comptime construction
When the comptime interpreter encounters an interface { ... } expression, it constructs an InterfaceDef whose:
methodsare the listed signatures, with parameter and return types resolved against the current comptime substitution map (soTinfn size(self) -> Tresolves toi32insideSized(i32)).nameis a synthetic stable string derived from the surrounding comptime function and its arguments — e.g."__anon_iface_Sized_i32"— used for diagnostics and for the vtable global symbol.
This mirrors how inject_anon_struct already builds anonymous StructDefs with substituted field types.
Structural deduplication
Two interface { ... } expressions evaluated at different call sites must produce the same InterfaceId if their resolved method signatures match. Otherwise, Sized(i32) would have a different InterfaceId per call site, breaking conformance witnesses and causing duplicate vtable globals.
The deduplication key is (method_name, param_types, return_type) for each method in declaration order — i.e. the Vec<InterfaceMethodReq> itself. The type pool already has the lock + hash-map machinery for struct/enum/array dedup; interfaces become a fourth user.
Bound resolution at use sites
Today comptime T: SomeInterface resolves the bound by looking up SomeInterface as a Spur in Sema::interfaces. For comptime T: Sized(i32) and borrow s: Sized(i32), the bound is an expression rather than a name. We extend the bound-resolution path to:
- If the type symbol is a registered interface name → existing behavior (
Type::new_interface(named_id)). - Otherwise, attempt to evaluate the type symbol as a comptime expression. If it produces a
ConstValue::Type(Type::new_interface(_)), use that. - Otherwise, the existing fallback errors apply.
This is symmetric with how comptime T: type works today — the bound is itself a comptime value.
The same path is used for borrow s: Sized(i32)-style runtime params: resolve_param_type already accepts interface names; the only change is to widen "interface name" to "any expression that evaluates to an interface type" in the comptime context.
Vtable layout
No change. (StructId, InterfaceId) is still the key; each unique Sized(i32), Sized(i64), Greeter(bool) gets its own InterfaceId, hence its own vtable global. Conforming types whose methods happen to satisfy multiple parameterizations get a vtable per parameterization.
Restrictions in MVP
| Allowed | Not yet |
|---|---|
Anon iface in fn ... -> type body | Anon iface as inline parameter type |
Comptime parameterization over type | Anon iface with bodied methods |
| Comptime parameterization over values | Self keyword in method signatures |
| Methods over substituted types | Method-level comptime params (yet) |
| Structural dedup across instantiations | Cross-module shared vtable interning |
The "inline parameter type" exclusion means fn foo(borrow t: interface { fn read(self) -> i32 }) is not allowed. The motivating use case for anonymous interfaces is parameterization; ad-hoc inline structural typing is a separate decision (Go-style) that we can revisit if there's demand.
Self in interface method signatures is deferred for the same reason ADR-0056 deferred it — the dyn-dispatch object-safety rules remain an open design question. Anonymous interfaces don't make this easier or harder.
Implementation Phases
Each phase is independently committable. Phases share a single preview flag (anon_interfaces); stable when the last phase lands.
Phase 1: Parser + AST + RIR for
interface { ... }- Add
TypeExpr::AnonymousInterface { methods: Vec<MethodSig>, span }parallel toAnonymousStruct/AnonymousEnum. - Extend
chumsky_parserto recognizeinterfacein type-expression position (currently it's only parsed as a top-level item). - Add
InstData::AnonInterfaceTypeto RIR so it can flow through the comptime interpreter. - Tests (preview, allowed-to-fail-codegen):
interface { ... }parses inside afn -> typebody without crashing the frontend.
- Add
Phase 2: Comptime construction of
InterfaceDef- In sema's comptime evaluator, add an arm for
AnonInterfaceTypethat builds anInterfaceDefwith method-sig types resolved under the current substitution map. - Synthesize a stable name like
__anon_iface_<n>(the same scheme anon structs use, but with their own counter so dump output is distinguishable). - Hand the
InterfaceDefto the type pool's intern path; on cache-hit, return the existingInterfaceId; on miss, register a new one. - Tests: identical anon-iface expressions evaluated twice produce the same
InterfaceId.
- In sema's comptime evaluator, add an arm for
Phase 3: Bound resolution from expressions
- Extend
resolve_param_typeand the comptime-bound machinery socomptime T: <expr>andborrow t: <expr>accept any expression whose comptime value isType::new_interface(_). - Wire through the existing
try_evaluate_const/resolve_type_for_comptimepipelines. - Tests:
Sized(i32)works as both a comptime bound and a runtime param type;Sized(i32)andSized(i64)are distinct interfaces; method calls on receivers of typeSized(i32)typecheck against the substituted signature (-> i32, not-> T).
- Extend
Phase 4: Vtable emission for parameterized pairs
- Vtable globals are already keyed on
(StructId, InterfaceId), so no new mechanism is needed — but verify dedup actually fires acrossSized(i32)instantiations from multiple call sites. - Add a spec test that calls the same parameterized borrow from two sites and asserts (via exit code) the dispatch lands in the right methods.
- End-to-end runnable: a parameterized interface program compiles and runs, dispatching dynamically through a vtable named after the instantiated interface.
- Vtable globals are already keyed on
Phase 5: Spec, traceability, stabilization
- New paragraphs under chapter 6.5 covering anon-iface syntax, comptime construction, structural dedup, and bound-resolution semantics.
- Cover every normative paragraph with spec tests.
- Remove the
AnonInterfacespreview flag, droppreview = "anon_interfaces"from spec tests, mark this ADR implemented.
Consequences
Positive
- Parameterized interfaces, completing the symmetry started by ADR-0029 (anon structs) and ADR-0039 (anon enums). The pattern
fn TypeCtor(comptime T: type) -> type { struct/enum/interface { ... } }becomes uniform. - No new runtime concept. Vtables, fat pointers, conformance — all unchanged. The new code is small (~700 lines plus tests) and localized to the comptime interpreter and the bound-resolution path.
- Foundation for stdlib interface design. Things like
Iterator(T),IntoIter(T),Comparable(T)become expressible directly without re-declaration per element type.
Negative
- Vtable proliferation. One vtable per
(StructId, InterfaceId)pair, per parameterization. For a conforming type used through three parameterizations of the same shape, that's three vtables. Same trade-off as monomorphized struct generics; quantifiable but not pathological. - Diagnostic surface. "Type
Foodoes not conform to interface__anon_iface_42" is unhelpful — diagnostics need to render the parameterization in source-shape (Sized(i32)rather than the synthetic name). Mitigated by reusing the anon-struct rendering helper, which already does this for struct types. - Adds another comptime construction path. The comptime evaluator grows another arm; bugs in the substitution map propagate to interface signatures the same way they do for struct fields. Worth testing the substitution thoroughly.
Neutral
- No ABI change. Anon interfaces use the same fat-pointer layout and vtable scheme as named ones.
- No interaction with
Self. Both named and anonymous interfaces deferSelfuntil the object-safety design question is resolved. - Method-level comptime params. Generic methods inside an anon interface (e.g.
fn map(self, comptime U: type, f: ...) -> ...) are out of scope here — the original ADR-0056 deferred them and this ADR inherits that posture.
Open Questions
Vtable dedup across modules. Once cross-module compilation lands (ADR-0026), is the type pool shared so two modules using
Sized(i32)share one vtable, or does each module emit its own? Tentative: per-module emission with linker-level merge via weak-linkage; revisit when the module system is ready to consume shared interface IDs.Inline anonymous interfaces at param positions.
fn foo(borrow t: interface { fn read(self) -> i32 })is conceivable but doesn't fit the comptime construction model. Skip until there's demand? Tentative: skip. Users can wrap with a one-linefnif they want the inline shape.Method-name collisions with multiple parameterizations. If
Sized(i32)andSized(i64)both expectfn size(self) -> T, can one type conform to both? Yes, by having bothfn size(self) -> i32and... wait, you can't have twosizemethods with different return types. Worth a diagnostic that names the collision precisely.Anonymous-interface diagnostics. Should the synthetic name leak into error messages, or should we always render the source-level parameterization? Tentative: render the source form when possible (existing anon-struct path); fall back to the synthetic name when no source form is recoverable.
Future Work
Selfin interface signatures. Tracked separately as part of the larger interface-extensions design.- Inline interfaces at parameter positions. Re-evaluate after this ADR's parameterized form has shipped and seen real use.
- Method-level comptime generics on anon interfaces. Currently no interface (named or anon) supports method-level comptime params; when that lands for named interfaces, anon interfaces should pick it up for free.
- Interface inheritance / extension.
interface Bigger extends Smaller { ... }is its own design. Anon interfaces don't change the decision but make a parameterized version easy if/when it's wanted.
References
- ADR-0025: Compile-Time Execution — comptime interpreter and the
fn ... -> typemachinery this ADR extends. - ADR-0029: Anonymous Struct Methods — the architectural precedent: comptime-built nominal types with structural dedup.
- ADR-0039: Anonymous Enum Methods — symmetric precedent for enums.
- ADR-0055: Anonymous Functions — the third "anonymous comptime type" (callable structs).
- ADR-0056: Structurally Typed Interfaces — the named-interface foundation this ADR extends.