Skip to content

ADR 0054 — Generic impls, and impls for types with region parameters ​

  • Status: Accepted by the owner, 2026-11-02 ("Yes, I accept all five ADRs with your recommendations"): every question of §5 as recommended. Asked for by the owner for CeeSafe.Web (~/dev/CeeSafe.Web/docs/ceesafe-upstream-work.md, item U10). CeeSafe.Web's M0 designed around the gap: Box<dyn Transport> at the connection edge, concrete body types instead of an interface, hook-style middleware instead of generic wrappers.
  • Scope: the parser, the checker's impls, bounds and dyn tables, the backends' view of instantiated impl methods.

1. Context ​

An impl names one concrete, unscoped type (type-system.md §21; src/check/interfaces.cee, processImpl):

  • impl<T: Transport> Transport for Wrap<T> does not parse: N0105, since parseImpl reads a type right after impl.
  • impl Body for RequestBody, where RequestBody<region r>, is N0267: "v0 implements interfaces only for concrete, unscoped types". The reason is §37: a dyn I value carries no loans of its own.

What exists to build on:

  • Monomorphisation. A generic function or struct is instantiated per type argument (instantiateFunction, instantiateStruct), and calls through a bound resolve to the concrete type's impl method (findMethod, then implMethodFor).
  • Impls are found by exact type id (implements, implMethodFor), so an impl for Pair<u8> exists and one for Pair<T> cannot be written.
  • dyn I tables are recorded per (implementing type, interface type) when a value is coerced (coerceToDyn), and the backend emits one table per pair.

2. Options ​

Overlap. A generic impl impl<T> I for Wrap<T> and a concrete impl I for Wrap<u8> would both apply to Wrap<u8>. Choosing between them is specialisation, which needs rules for "more specific". Proposed: no overlap, at most one impl per (interface, type constructor). A later ADR may add specialisation.

Where an impl may be written. With generic impls, two packages could each write impl I for Wrap<T> and both be linked. Proposed: the orphan rule. An impl is written in the package that declares the interface or the type constructor, so two packages never both provide one.

3. Proposed decision ​

3.1 Generic impls ​

ceesafe
impl<T: Transport> Transport for Logged<T> {
    async fn read(borrow mut this, buffer: SliceMut<u8>) -> Result<usize, NetError> => await this.inner.read(buffer);
    // ...
}
  • Syntax: impl<T: B, U> I<…> for C<…T…, …U…> { … }. The impl's type parameters must each appear in the target type; their bounds are as a generic function's (§20).
  • Matching. A type C<A> implements I when an impl's target unifies with it and the bounds hold for the arguments found. implements and implMethodFor unify against impl templates as well as concrete impls.
  • Instantiation. An impl's methods are instantiated per type argument, as a generic struct's are, and cached. A call through a bound, a call on the concrete type, and a dyn I table all use the instance. The backend sees ordinary functions and needs no change.
  • Overlap (§2): two impls of one interface whose targets have the same type constructor are N0267, generic or not. So impl I for Wrap<u8> beside impl<T> I for Wrap<T> is refused.
  • Orphan rule (§2): an impl whose package declares neither the interface nor the target's type constructor is N0267. Built-in types (integers, String, Vector) count as lib/core's, so a program implements its own interfaces for them, as today.

3.2 Impls for types with region parameters ​

ceesafe
impl<region r> Body for RequestBody<r> { … }
  • A struct or enum with region parameters may implement an interface, with the regions named in the impl header. The methods' signatures use them as the struct's own methods do (§25).
  • Such a type is used through bounds, statically. Coercing it to dyn I stays N0268, since a dyn value carries no loans (§37). Scoped dyn is OD-10a, separate.
  • An interface method that declares its own region parameter (async fn handle<region r>(…), N0216 today) stays refused: methods declare no type or region parameters of their own in v0 (src/check/declare.cee, N0216).

3.3 Compile time ​

Each instance of a generic impl is checked and compiled once per distinct type argument list, as generic functions are. The depth limit for instantiation (N0266, 64) applies to impls too.

4. Consequences ​

  • CeeSafe.Web can wrap a transport generically (Logged<T: Transport>), and implement Body for its scoped request body.
  • The orphan rule may refuse an impl that compiles today: one written in a package that declares neither the interface nor the type. The implementation searches the corpus, the tests and CeeSafe.Web for such impls and reports each before the rule lands. The check is N0267, with a message saying where the impl belongs.
  • New diagnostics: none. N0267 and N0268 gain the cases above.

5. Questions for the owner ​

  1. impl<T: B> I for C<T>, monomorphised per type argument, with at most one impl per (interface, type constructor) and no specialisation? Recommended: yes.
  2. The orphan rule: an impl lives in the package of its interface or of its type constructor? Recommended: yes.
  3. Impls for types with region parameters, usable through bounds but not as dyn I? Recommended: yes.

6. Implementation plan (once accepted) ​

  1. Spec: §21 (impls, matching, overlap, the orphan rule), §25 (scoped impls), §37 (dyn unchanged), diagnostics.md (N0267, N0268 cases), the guide's generics page.
  2. src/:
    • the parser (impl<…>);
    • the checker:
      • impl templates;
      • unification in implements and implMethodFor;
      • instantiation and caching of impl methods;
      • the overlap and orphan checks;
      • scoped impls and the dyn refusal;
    • the HIR printer.
  3. Tests:
    • check-pass with .hir snapshots: a generic impl used through a bound, on a concrete instance and as dyn I; an impl with two parameters; a scoped impl through a bound;
    • run-pass: the same, natively and in the interpreter, with expected output from the program's specification (a wrapper that counts calls);
    • check-fail: overlap; an orphan impl; a scoped type coerced to dyn I; a parameter that does not appear in the target; an unmet bound;
    • the contract suite, and CeeSafe.Web's three probes (tests/probes/impl-generic-fail.cee, impl-scoped-fail.cee) turned into passing tests.
  4. ROADMAP §10.

7. Implementation notes (2026-11-02) ​

  • Parsing. ItemKind.Impl carries the impl's type and region parameters; the formatter and the AST printer print them, and the documentation generator lists a generic impl under its struct's name, as any impl.
  • Templates. processImpl declares the impl's type parameters (with their bounds) and region names, resolves the target and the interface with them in scope, and checks the impl once for its target, C<T…> with the parameters opaque, as a generic struct's methods are checked. The template (ImplTemplate) is then implemented for each instance of C that the target matches (unification, then substitution equal to the instance) and whose arguments meet the bounds (checkBounds, quietly): its methods are declared again for the instance with the parameters' environment (attachGenericImpls). Instances made while the declarations are read are handled once every impl is known (attachToInstances, twice, so a bound met through another instance's impl does not depend on their order); later ones as they are made (instantiateStruct, instantiateEnum). So implements and implMethodFor keep looking up exact types, and the backends see ordinary methods.
  • Overlap is checked as §3.1 says: with a generic impl on either side, two impls of one interface (with arguments that could be equal) whose targets are the same struct or enum are N0267.
  • The orphan rule compares packages: the impl's file's, the interface's and the target struct's or enum's. The libraries are one package for it (the built-in types, lib/core and lib/std). The search §4 asked for found nothing it breaks: the corpus, the support and package tests, and every package of CeeSafe.Web check as before.
  • Regions. A method of impl<region r> … has the impl's region names in scope (regionScope, through implRegionsOf); regions are erased, so nothing else changes. A scoped type that implements an interface coerced to dyn I is N0268 (coerceToDyn).
  • Tests: run-pass/impl-generic (a counting wrapper used directly, through a bound, nested and as dyn; two parameters; a generic interface implemented generically; a scoped impl through a bound) and run-pass/impl-generic-async (CeeSafe.Web's two probes as passing programs: an async transport wrapper, an async body over borrowed bytes); check-fail/impl-generic (overlap, a parameter missing from the target, a generic impl for u32, the orphan rule with lib/std's HashKey for f64, an unmet bound, a scoped type as dyn); the guide's generics page. No .hir snapshot was added: the run-pass programs' output is specified by the programs.

Pre-1.0: the language and its tools are still changing.