ADR-0056: Structurally Typed Interfaces

Status

Implemented

Summary

Add interface declarations: named, structurally typed sets of method requirements (Go-style). The same interface name can be used in two distinct contexts:

  1. As a comptime constraintfn f(comptime T: Drop, t: T) — forcing monomorphization for every concrete T proven to conform.
  2. As a runtime type behind a borrowing parameter — fn f(inout t: Drop) — passed as a fat pointer (data, vtable) and dispatched dynamically.

Conformance is structural: any type whose method set covers the interface's required methods conforms automatically; no impl Drop for Foo declaration exists. The MVP is methods-only (no field requirements). No built-in interfaces are introduced; Drop, Clone, etc. are out of scope and become follow-up ADRs once this primitive is stable.

Context

What we already have

Gruel today has two routes to polymorphic code:

  1. Comptime type parameters (ADR-0025)fn id(comptime T: type, x: T). T is unconstrained; the body is re-checked per specialization, so any operation used inside the body must work for every concrete T actually passed. This produces helpful but late-binding errors ("+ not defined for MyStruct at instantiation site"), with no way to declare the operations a generic depends on.
  2. Anonymous functions (ADR-0055) — lambdas desugar to a struct with a __call method, making "callable thing" expressible without function-pointer or closure types. This solves the higher-order-function problem but only by leaning on the same unconstrained monomorphization.

What is missing:

  • Bounded generics. There is no way to write "T must support drop", short of trusting that the user supplied a type with the right methods and letting specialization fail late.
  • Dynamic dispatch. There is no way to hold "some type that supports drop, I don't care which" at runtime. Heterogeneous collections, plug-in style code, and interface-erased APIs are not expressible.
  • A foundation for Drop. ADR-0010 (destructors) and ADR-0053 (inline-methods-and-Drop) currently rely on the compiler recognizing a hardcoded __drop method on a struct. To make Drop a real interface that user types can choose to participate in — and that the compiler can call generically through inout parameters — we need a structural-conformance mechanism.

Why structural (Go-style) and not nominal (Rust traits)

Nominal traits (impl Trait for Type {}) require a separate declaration step per (type, trait) pair, plus orphan rules to keep coherence. Structural conformance is more in keeping with the rest of Gruel's surface (anonymous structs, anonymous enums, anonymous functions, structural tuple types) and matches the user's stated intent. Conformance becomes a property of the type's existing method set, decided by the compiler at use-site rather than at declaration site.

Why the same syntax for comptime and runtime

Conceptually comptime T: I and t: I are asking the same question: "does this type expose method set I?" The answer is the same; the only difference is whether the answer is consumed by monomorphization (and so erased before codegen) or by vtable-based dispatch (and so reified at runtime). Using one declaration site for both keeps the user's mental model small.

Decision

Syntax

interface Drop {
    fn drop(self);
}

interface Reader {
    fn read(inout self, buf: ptr mut u8, len: usize) -> usize;
    fn close(self);
}

Grammar (added to chapter 6):

InterfaceDecl  := "interface" Identifier "{" InterfaceMember* "}"
InterfaceMember := MethodSig
MethodSig      := "fn" Identifier "(" SelfParam ("," ParamList)? ")"
                  ("->" Type)? ";"
SelfParam      := "self" | "inout" "self" | "borrow" "self"

Each member is a method signature (no body, no associated functions in MVP). Trailing semicolons are required to disambiguate from method definitions.

Conformance

A type T conforms to interface I iff for every required method fn name(<self-mode> self, p1: P1, …, pN: PN) -> R in I, there exists a method on T (in any impl T block — anonymous structs included) with:

  • The same name.
  • The same self mode (self, inout self, or borrow self).
  • An exactly matching parameter list (count, types, modes — including inout/borrow on non-self params).
  • The same return type, modulo the rule that the interface's Self (if we later introduce it) substitutes for T. The MVP does not introduce Self; methods refer to the receiver only via self.

Conformance is checked at the use site, not at the type's declaration site.

Two usage modes

Mode 1: Comptime constraint

fn drop_one(comptime T: Drop, t: T) {
    t.drop();
}

Here Drop is used in place of type as the bound of a comptime parameter. At each call site:

  1. The argument bound to T is some concrete type C.
  2. The compiler checks C conforms to Drop. If not → compile error at the call site, citing the missing method(s).
  3. Specialization proceeds as today (ADR-0025): T is substituted with C, the body is re-analyzed, and t.drop() resolves to C::drop.

After monomorphization, no trace of the interface remains in AIR/codegen.

Mode 2: Runtime dynamic dispatch

fn drop_one(inout t: Drop) {
    t.drop();
}

Here Drop is used as a type in a parameter position. Such parameters must be passed via a borrowing mode (inout or borrow) — see "Restrictions" below. The parameter's ABI is a fat pointer:

struct InterfaceRef {
    data:   ptr mut/const T_erased,   // mode comes from the borrow
    vtable: ptr const VTable_I,
}

A method call t.drop():

  1. Resolves drop to slot index k in Drop's vtable.
  2. Lowers to (t.vtable->slots[k])(t.data, …args).

Coercion from a concrete C to interface I happens implicitly at call sites where the parameter type is inout I (or borrow I) and the argument has type C:

  1. Compiler checks C conforms to I (same check as comptime mode).
  2. Compiler looks up (or generates) the <C, I> vtable as a static.
  3. The argument lowers to { &mut argument, &VTABLE_C_I }.

Restrictions in MVP

AllowedNot yet
comptime T: IMultiple bounds: comptime T: (I & J)
inout t: I, borrow t: IBy-value t: I (would require boxing)
Method requirementsField requirements (field: T; in interface)
Methods with self/inout/borrowself in by-value form for runtime mode
Return type R with no SelfSelf keyword in interfaces
let r: I = …; rebinding via borrowReturning I from a function (-> I)
Single-file interface useModule visibility / pub interface

By-value self is allowed inside an interface method signature — but it can only be exercised through the comptime path, where the receiver type is known concretely at codegen time. Calling a by-value-self method through a runtime fat pointer is a compile error; the caller must use the comptime form or use a borrow self / inout self method.

Type system integration

Add TypeKind::Interface(InterfaceId) to gruel-air/src/types.rs. Add a parallel InterfaceDef (alongside StructDef/EnumDef) holding the interface's name, method signatures (in declaration order — that order is the vtable layout), and source span.

Add Interface(InterfaceId) to whatever bound representation the comptime parameter machinery uses for comptime T: …. Today the bound on a comptime type parameter is implicitly type (any type). We extend it to allow either type or Interface(InterfaceId).

Conformance check

A single helper:

fn check_conforms(
    sema: &Sema,
    candidate: Type,           // typically a Struct or Enum
    interface: InterfaceId,
    use_span: Span,
) -> Result<ConformanceWitness, CompileError>;

Returns either a witness (a vector mapping each interface slot to the concrete method's (StructId, Spur)) or an error listing every missing/mismatched requirement at once (so the user sees the whole gap, not one method at a time).

The witness is the input both to monomorphization (to resolve method calls on T inside the generic body) and to vtable generation.

Vtable generation

For each (concrete type C, interface I) pair actually used at runtime (i.e. that flows into a coercion), emit a static LLVM constant:

@__vtable__C__I = constant { i8*, i8*, … } {
    bitcast (ConcreteSig* @C__m1 to i8*),
    bitcast (ConcreteSig* @C__m2 to i8*),
}

Slot order is the interface's method order. Generation is keyed by (StructId, InterfaceId) and deduplicated.

The fat pointer is passed as two pointer-sized values in the C ABI sense (no struct return / no spilling). On 64-bit targets this is two registers.

Self consumption and Drop

The user's headline example —

interface Drop {
    fn drop(self);
}

— uses by-value self. Under the MVP that means Drop-with-fn drop(self) can be used as a comptime constraint (where each specialization has a concrete receiver type and consumption is fine), but cannot be used as a runtime fat-pointer parameter, because dispatching a consuming method through a inout/borrow fat pointer is incoherent.

That is acceptable for this ADR — the goal is to land the interface machinery, not to land Drop itself. A future ADR (or revision of ADR-0010) can decide whether the canonical Drop interface uses self, inout self, or both, and how that interacts with the affine system.

What interface is not (in MVP)

  • Not nominal: no impl Drop for Foo.
  • Not inheriting: no interface Reader: Closer.
  • Not extending: no default-method bodies, no associated constants, no associated types.
  • Not boxing: no Box<dyn I>-equivalent. Owned interface values do not exist.
  • Not negative: no T: !Send-style anti-bounds.
  • Not coherence-checked: structural conformance is intrinsic to the type's method set; the orphan-rule problem does not apply.

Implementation Phases

Each phase ends in a committable, runnable state with the preview flag interfaces enabled. Phases 1–4 can ship sequentially; spec/tests are folded into each phase but the formal spec chapter lands in Phase 5.

  • Phase 1: Parsing and RIR

    • Add interface keyword to gruel-lexer.
    • Parse InterfaceDecl items in gruel-parser; reject method bodies and associated functions with a clear diagnostic.
    • Add Item::Interface to AST and the corresponding RIR InterfaceDecl instruction. No semantic checking yet beyond duplicate-name detection.
    • Tests: parser-only spec tests verifying the AST/RIR shape and rejection of method bodies / associated fns.
  • Phase 2: AIR representation and conformance check

    • Add TypeKind::Interface(InterfaceId) and InterfaceDef in gruel-air/src/types.rs. Plumb through intern pool / printers / Type::new_interface.
    • Gather pass: register each interface declaration into a HashMap<Spur, InterfaceId> parallel to Sema::structs.
    • Implement check_conforms(candidate, interface) -> ConformanceWitness, matching against the existing Sema::methods table (and anon-struct captures) by name + self-mode + param list + return type.
    • Add the new error variants: InterfaceMethodMissing, InterfaceMethodSignatureMismatch, with rich diagnostics that show the full required signature next to what the type actually has.
    • Add PreviewFeature::Interfaces to gruel-error. Gate interface declarations and any use of an interface name in a type/bound position on this flag.
    • Tests: positive and negative conformance — missing method, wrong arity, wrong return type, wrong self-mode, etc.
  • Phase 3: Comptime constraint usage (comptime T: I)

    • Extend the comptime parameter bound representation to allow an interface in addition to type.
    • Parser: comptime T: SomeInterface parses as a bounded type param.
    • Specialization (gruel-air/src/specialize.rs): when binding T, run the conformance check against the concrete type. On failure, emit a call-site error.
    • Tests: monomorphization with interface bound, conformance failure at the call site (missing method, wrong return type), distinct specializations per concrete type.
    • Deferred (still works correctly via per-specialization re-checking): method resolution that checks the body once against the interface instead of per-specialization. Tracked in ADR Open Questions.

Phase 4 is split into smaller sub-phases for staged delivery. Each sub-phase ends in a runnable, committable state. Sub-phases must land in order — they share the fat-pointer ABI groundwork.

  • Phase 4a: improved diagnostic for runtime-type usage

    • When a parameter type names a registered interface, the unknown-type error includes a help line redirecting users to the comptime path (comptime T: I) and noting that runtime dispatch is the rest of Phase 4.
    • UI test under diagnostics/interfaces.toml pins this guidance.
  • Phase 4b: sema accepts interface-typed parameters

    • New resolve_param_type accepts interface names when the parameter mode is inout or borrow; the general resolve_type path rejects them with a tailored diagnostic redirecting to either the comptime path (comptime T: I) or the borrow form.
    • Reject by-value t: I with a tailored error pointing at borrow t: I / inout t: I.
    • validate_interface_decls now runs before struct/enum field resolution so the helpful interface-as-field-type error fires correctly.
    • Functions with empty bodies and borrow t: I parameters compile end-to-end (the parameter is unused, so codegen emits a zero-param LLVM function).
    • Call-site coercion (passing a concrete type) is not done — that's Phase 4c. UI tests cover the rejection paths (interface as field type, by-value param).
  • Phase 4c (partial): AIR variant + call-site conformance

    • Add AirInstData::MakeInterfaceRef { value, struct_id, interface_id } (codegen lowering deferred to Phase 4d).
    • Inference skips the equality constraint when the parameter type is an interface — sema applies structural conformance instead.
    • At every function call where a parameter has interface type, sema runs check_conforms(arg_type, interface_id); non-conforming arguments surface the structured InterfaceMethodMissing / InterfaceMethodSignatureMismatch error at the call site.
    • Successful conformance currently emits a "Phase 4d not yet implemented" stop point — MakeInterfaceRef is not actually emitted yet. This avoids creating AIR that CFG/codegen can't lower.
    • CFG: MakeInterfaceRef arm exists but unreachable until Phase 4d.
    • Method calls on interface-typed receivers (t.method()) and MethodCallDyn are deferred — they're orthogonal to the parameter coercion path and slot in cleanly once codegen lands.
  • Phase 4d: full runtime dispatch — fat pointers, vtables, dispatch

    • LLVM type for Type::new_interface(iid) is the canonical { ptr, ptr } struct (data + vtable). is_param_by_ref returns false for interface-typed params: the fat pointer is by-value at the LLVM ABI; the borrow lives in the data field. abi_slot_count returns 2.
    • Vtable globals: build_module emits one @__vtable__s<S>__i<I> = constant [N x ptr] per (StructId, InterfaceId) pair recorded by sema, with function pointers in interface declaration order.
    • MakeInterfaceRef lowers to { &arg, &VTABLE_S_I }.
    • MethodCallDyn (new AIR/CFG variant) lowers to GEP-into-vtable + load + indirect call. Receiver data_ptr is passed as the implicit first argument; subsequent args follow.
    • Interface receivers are @copy (the fat pointer is two pointers — bitwise-copying it is sound and lets t.method() work without "move out of borrow" complaints).
    • End-to-end runnable: programs that pass concrete structs through borrow t: I parameters AND invoke methods via the vtable now compile and execute correctly. Distinct conforming types route to distinct vtables.
    • Three new spec tests pass end-to-end: empty-interface borrow, method dispatch, two-types dispatch.
  • Phase 4e: stabilization polish

    • Diagnostic for "interface used as field type" and "interface used as return type" both fire the redirecting help text from resolve_type (pinned by UI tests under diagnostics/interfaces.toml).
    • Vtable deduplication: interface_vtables_needed is keyed on (StructId, InterfaceId) so repeated coercions of the same pair collapse to a single witness; codegen emits one global per pair. interface_runtime_vtable_dedup exercises this with three borrow coercions of the same pair — if dedup were broken, the LLVM verifier would reject the duplicate global symbol.
    • Decision on conformance error reporting: fail-fast on the first missing/mismatched method (current behavior). Reasoning: most interfaces have small method sets, the first failure is usually the actionable one, and collecting all failures requires changing check_conforms to return a Vec<CompileError>. Easy to revisit if real-world feedback shows users hitting cascading failures.
  • Phase 5: Specification, traceability, and stabilization

    • Spec chapter 6.5 covers interface declarations, conformance, comptime bounds, and runtime dispatch (paragraphs 6.5:1 through 6.5:17).
    • All normative paragraphs are covered by spec tests; traceability check is at 100%.
    • PreviewFeature::Interfaces removed; --preview interfaces is no longer required and preview = "interfaces" was dropped from all spec and UI tests. Interfaces are now a stable language feature.

Consequences

Positive

  • Bounded generics without a trait system. Comptime type params can now carry method-set requirements, so generic bodies typecheck against a real contract instead of being re-checked per specialization.
  • Dynamic dispatch becomes possible. Heterogeneous collections, plug-in APIs, and erased callbacks are now expressible (within inout/borrow).
  • Single mechanism, two modes. Users learn interface once and choose monomorphization vs. dispatch by where they put the keyword (comptime vs. inout/borrow). No second concept.
  • Foundation for first-class Drop. Lets ADR-0010/0053 stop hardcoding __drop and instead phrase destruction through an actual interface.
  • No ABI lock-in. Vtable layout is internal to the compiler; nothing about the language commits us to a particular fat-pointer convention if we want to revisit it.

Negative

  • Accidental conformance. Structural conformance means a type can satisfy an interface unintentionally because two methods happen to share a name. Mitigation: name interfaces narrowly; consider an opt-in nominal modifier later if this proves painful.
  • Compile-time cost. Conformance checks run at every use site of an interface bound or coercion; method tables are walked. Likely cheap in practice but not free.
  • Vtable code-size cost. Each (C, I) pair in use emits a constant. Mitigation: deduplicate aggressively, defer cross-crate concerns to the module-system ADR.
  • Sharper edges around self. Allowing self (consuming) in interface signatures but not in runtime dispatch means the same interface can be partially-usable in one mode. We document this clearly; a future ADR can unify it (boxing, or a consuming calling convention in vtables).
  • Error message surface. "Type Foo does not conform to Reader: missing method read(inout self, …)" needs to be high quality or users will hate this feature; this is real diagnostic work, not free.

Neutral

  • No change to existing concrete-type method resolution. All current obj.method() calls keep their current resolution path. The new path only fires for interface-typed and interface-bounded receivers.
  • No change to ABI for non-interface code.
  • Aligned with ADR-0055. Anonymous functions (callable structs) compose with interfaces: an interface Callable<T, U> { fn __call(self, x: T) -> U; } would let comptime higher-order code take a real bound. Out of scope here, but the path exists.

Open Questions

  1. Which spec section does this live in? Tentative: a new section under chapter 6 (items): 6.5 Interfaces. Alternative: chapter 4 alongside types. Decide while writing the spec.

  2. Should the interface keyword include trailing semicolons on method signatures, or use no terminator? The grammar above uses ;. Alternative: no terminator and rely on fn being unambiguous. Semicolons are clearer; keep unless they grate.

  3. Do we want a non-bound use of Interface as a type alias for comptime T: type where T conforms to I? I.e., is fn f(comptime T: Drop, t: T) the canonical spelling, or could we shorten to fn f(comptime t: Drop) with T implicit? Tentative: explicit T in MVP; revisit if it's a common pattern.

  4. What is the canonical receiver mode for the eventual Drop? This ADR deliberately does not answer. Once interfaces land, the Drop ADR can decide between fn drop(self) (consuming, comptime-only), fn drop(inout self) (works with runtime dispatch), or both via overload.

  5. Should anonymous-struct/anonymous-enum types be able to satisfy interfaces? Tentative: yes, automatically, via the existing anon-method machinery. Worth a dedicated test in Phase 3.

  6. Vtable layout stability across compilation units. Once the module system (ADR-0026) is real, the same interface declared in one file may be referenced from another. We need vtable layout to be deterministic from the interface's declared method order. This is fine inside one compilation unit today; flagged for the module ADR follow-up.

  7. Coercion sites. Implicit C → I coercion at call boundaries is the obvious case. Do we also allow it in let bindings (let r: borrow Drop = &foo;) and in return expressions for interface-typed return values? Phase 4 starts with call boundaries only; expand if the ergonomics demand it.

Future Work

Out of scope for this ADR; each becomes a candidate follow-up:

  • Built-in interfaces. Drop, Clone, Copy, Eq, Ord, etc. — each is a separate design conversation and tied to existing affine/copy machinery. None ship with this ADR.
  • Field requirements in interfaces. Would require either uniform layout (impractical with structural conformance) or per-conformance offset slots in the vtable.
  • Multiple interface bounds. comptime T: (I & J). Conceptually a conjunction over conformance witnesses; gated on demand.
  • Default method bodies. Interfaces with method implementations shared across all conforming types. Pulls in a lot of trait-system surface.
  • Self keyword inside interface signatures. Useful for things like fn clone(borrow self) -> Self. Requires a substitution rule both in comptime and dynamic-dispatch modes.
  • Owned dynamic dispatch (Box<dyn I> analog). Requires either heap boxing or a sized-erasure scheme.
  • Returning interface types from functions. fn make() -> impl I and fn make() -> dyn I flavors.
  • Cross-module conformance and visibility. Folds into ADR-0026.
  • Negative bounds / specialization. Out of scope indefinitely.
  • Anonymous interfaces to allow monomorphization over generics.

References