hanki

10. Traits and impls

Traits and impls use angle brackets for type application. impl Display<Point> reads "Display applied to Point" - Point is the implementing type.

struct Point
  x: i32
  y: i32
end

trait Display
  def to_string(self) -> string
end

impl Display<Point>
  def to_string(self) -> string
    "(#{self.x}, #{self.y})"
  end
end

An impl's method signatures must conform to the trait's: each method's parameter and return types must equal the trait's declaration (with Self read as the implementing type), and an action's effect row must be a subset of the trait method's declared row - an impl may perform fewer effects than the trait promises, never more. Bounded and static dispatch (below) type a caller against the trait's signature and charge the trait's effect row, so a divergent type or a wider effect row would return a type the caller never expects or perform effects it was never charged for; both are rejected (H0617).

Default methods. A trait method signature may carry a default body. A type that impls the trait without overriding that method uses the default; an inherent or impl override always outranks it (the inherent-wins coherence below). A default body may use the trait's other members on self, which dispatch to the implementing type:

struct Dog
  age: i32
end

trait Greet
  prop name(self) -> string          # required - no default
  prop greeting(self) -> string      # carries a default body
    "hi, #{self.name}"
  end
end

impl Greet<Dog>
  prop name(self) -> string
    "rex"
  end
  # `greeting` is not overridden, so `Dog.greeting` uses the default: "hi, rex"
end

Properties (prop)

A property is a pure, self-only value read exposed with bare-dot syntax and no parentheses: 2.days, point.magnitude. It is declared with the contextual keyword prop inside a trait or impl body — shaped like a def, but read like a field:

trait DurationUnits
  prop days(self) -> Duration        # signature; a trait prop may carry a default body
end

impl DurationUnits<int>
  prop days(self) -> Duration
    Duration.of_days(self)
  end
end

x = 2.days                           # reads the prop — lowers to an ordinary call
y = 2.days()                         # error (H0567): `days` is a property — write `2.days`

Reading one with parentheses is H0567 — here against the real datetime fluent units, which are exactly the shape sketched above:

use datetime

open datetime

def bad() -> datetime.Duration
  2.days()                           # `days` is a property — write `2.days`
end

The call syntax carries the semantics — one spelling per member:

SpellingMember kindMeaning
bare dot x.namestruct field, or propa pure value read — nothing happens
parens x.name()def methodan invocation — asked-for computation
bang x.name!(…)action def name!an invocation with world effects

Exclusivity is total: for any member exactly one spelling is grammatical. A prop read with parentheses (2.days()) is an error (H0567); a def method read without them is a no-field error carrying a "did you mean .name()?" hint.

The stdlib holds itself to the same rule, so its attribute reads are properties: length and is_empty on List / Map / Set / Range / string / bytes (and Range.length) are read bare - xs.length, s.is_empty - never called. The rest of the collection surface asks for computation (xs.sort, s.trim, m.keys) and stays def.

Constraints (enforced at registration):

Props are declarable in traits, trait impls, and inherent impls — not on actors, and not at the top level — and may not declare method-own generics (a bare-dot read has no syntax to supply type arguments, so such a prop could never be instantiated). Bare-dot access works on a bounded generic receiver too (<T: DurationUnits> x.days, <T: Hash> x.hash): the read dispatches through the bound's dictionary exactly like a bounded method call. A prop read crosses the opacity boundary like any method: a prop on an opaque type is readable from any file, since opacity gates construction and field access, not member reads (§9).

A def with a property's shape is an error (H0574). A pure, self-only, zero-argument def in an inherent impl or a trait declaration has the exact shape of a property, and one spelling per member includes the def-vs-prop split — so it fails the check, carrying a machine-applicable defprop fix (hanki check --fix applies it). In a trait declaration the decision cascades: every impl inherits the declared kind (H0568), so the rule — and the diagnostic — lives at the declaration, never at the impls (Hash.hash is a prop for exactly this reason). The escape hatch is semantic: a member with that shape whose meaning is a transformation of the value rather than an attribute of it stays a def — the conversion prefixes to_ / into_ / from_ are exempt by naming convention (to_iso(), to_seconds(), Display.to_string), and any other deliberate transformation is marked @transform def (sort, trim, hash_u64; a bare-dot read must never hide real work behind what looks like a field access) — in trait declarations exactly as in inherent impls. A misplaced @transform — anywhere the def-vs-prop question cannot arise: a top-level def, a member with arguments or generics, a trait impl — is its own error (H0620), so stale markers cannot accumulate. Out of scope for H0574: trait impl members (the kind is mandated by the trait declaration), meta and @encapsulated defs, members with method-own generics (a bare-dot read has no syntax for type arguments), and @intrinsic defs — that surface is the primitive conversion and math set, transformations by name (@intrinsic prop itself is a legal form — str.length and List.length are declared that way). When the member name shadows a field the fix is downgraded to a suggestion — rename the backing field (opaque) or drop the accessor (transparent struct), since the flip would otherwise trip H0566.

Generic implementations introduce type parameters implicitly:

struct Container<T>
  item: T
end
impl Display<Container<T>>    # T introduced from the parameter position
  def to_string(self) -> string
    "a container"
  end
end

Constrained generics — the bound makes T's own Show dictionary available inside the body:

trait Show
  prop show(self) -> string
end

struct Crate<T>
  item: T
end

impl<T: Show> Display<Crate<T>>
  def to_string(self) -> string
    self.item.show
  end
end

Trait type parameters and associated types

A trait may declare type parameters and associated types (docs/design/associated-types.md — the machinery operator overloading builds on):

struct Thing
  n: i32
end

trait Combine<B>              # a type parameter, bound per-impl
  type Out                    # an associated type, bound per-impl
  def mk(self, b: B) -> Out
end

impl Combine<Thing, string>   # positional: Self = Thing, B = string
  type Out = bool             # every declared associated type must be bound
  def mk(self, b: B) -> Out   # sigs may spell the names or the concrete types
    self.n > 0i32
  end
end

Two traits giving one type the same prop name is the ambiguity above, with nothing to select by:

trait First
  prop tag(self) -> string
end

trait Second
  prop tag(self) -> string
end

impl First<int>
  prop tag(self) -> string
    "first"
  end
end

impl Second<int>
  prop tag(self) -> string
    "second"
  end
end

def read(n: int) -> string
  n.tag                      # H0619: resolves through both impls
end

Trait bounds and bounded dispatch

A trait bound on a generic parameter lets the body call that trait's methods on a value of the bounded type. <T: Eq> on a def head, or on an impl head, brings Eq's methods into scope for any T-typed value:

def same<T: Eq>(a: T, b: T) -> bool
  a.eq(b)        # resolves through the `Eq` bound on `T`
end

Without the bound, a.eq(b) is a compile error - a bare T has no methods of its own. The call dispatches to the concrete type's impl of the bound trait: same(3i32, 3i32) runs Eq<i32>'s eq, same("a", "a") runs Eq<string>'s. The concrete type is fixed by inference at each call site (§11), so nothing is named in source - there is no witness/dictionary syntax.

A parameter may carry several bounds, joined by + (<T: Hash + Decode>): each brings its trait's methods into scope on a T-typed value, and each supplies its own dictionary at the call site, in the order written. Reach for a second bound only for an unrelated trait - a supertrait already comes for free (T: Hash brings Eq's methods too, since Hash: Eq).

def labelled<T: Display + Eq>(a: T, b: T) -> string
  if a.eq(b)              # method from the `Eq` bound
    a.to_string()         # method from the `Display` bound
  else
    "differ"
  end
end

Interpolation goes through the same bound: a bare #{a} hole on a T-typed value (T: Display) is equivalent to a.to_string(), dispatching through the param's Display dictionary the same way on both tiers.

An impl-head bound (impl<K: Hash, V> Map<K, V>) is in scope for every method body in the block, and an inherent-impl method's own bound is in scope for that method's body:

struct Holder
  tag: i32
end

impl Holder
  def both_eq<U: Eq>(self, x: U, y: U) -> bool
    x.eq(y)        # resolves through the method-own `U: Eq` bound
  end
end

All five forms - free def heads, impl-head bounds, generic trait-impl heads (impl<T: Display> Display<Wrap<T>>), an inherent method's own bound (above), and a trait method's own bound (below) - dispatch end-to-end on both tiers; a method may carry both an impl-head bound and its own. Two restrictions: a method's own generic must not reuse an enclosing impl-head generic's name (impl<K, V> containing def shadow<K>(…) is a compile error) - rename the method's parameter. And trait-impl coherence is one impl per (trait, head type name): a concrete and a generic impl sharing a head (impl Encode<Box<i32>> alongside impl<T: Encode> Encode<Box<T>>) are rejected as overlapping

specialization is deliberately unsupported; fold the concrete case into the generic impl (matching the inherent-impl rule in §10's coherence notes). That name is the module-qualified one, so a stdlib impl never reserves its head's bare name: your own Duration or Pair is a different type from datetime.Duration or pair.Pair, carries its own impls, and leaves theirs intact.

A trait method may declare its own bounded generics (trait Encode { def encode!<S: Serializer>(self, s: S) }, including a static one like trait Decode { def decode<D: Deserializer>(d: D) -> Self }). An impl of that trait must declare the same bounds for that method - param names are local, but the bounds must match in order, or it is a compile error (the call site supplies those dictionaries positionally). The method-own dictionaries are chosen at the call site and passed after the value args, so they are not part of the trait's fixed-arity dictionary: on a dynamic dispatch a generic impl's impl-head dictionaries arrive through the dictionary closure while the method-own ones arrive from the caller.

# main.hk
open io
open list

trait Sink
  def put!(self, x: i32) -> () [io]
end

impl Sink<i32>
  def put!(self, x: i32) -> () [io]
    print!("#{x}")
  end
end

trait Encoder
  def encode_to!<S: Sink>(self, s: S) -> () [io]   # method-own generic `S`
end

impl Encoder<i32>
  def encode_to!<S: Sink>(self, s: S) -> () [io]
    s.put!(self)                  # `S`'s dictionary is supplied by the caller
  end
end

def main!(args: List<string>) -> () [io]
  42i32.encode_to!(0i32)          # prints 42
end

The ==/!= operators on a generic-T operand follow the same rule: a == b where a: T dispatches through T's Eq bound exactly as a.eq(b) does (and != negates it). They therefore require T: Eq

without the bound there is no Eq impl to dispatch through. A concrete == works the same way: it dispatches through the type's Eq (synthesised on demand, or hand-written), with no structural fallback, so it too is a compile error when the type cannot carry Eq (see §20's eq row).

The structural comparison underlying == on an aggregate is depth-bounded: a comparison that recurses past a fixed cap traps as a catchable runtime error - an actor death routed to the supervisor, not a silent whole-process abort. A deeply-nested value would otherwise overflow the actor thread's stack (a value is always finite: cyclic values cannot be constructed - values are acyclic by construction, §12). The cap is a stack-overflow guard set far deeper than any legitimately-nested value, and it fires identically on the bytecode, AOT, and comptime tiers. The same bound governs the other structural trait recursions - Ord comparison (</cmp), Hash, and Display rendering - which walk a value's nesting the same way == does and trap at the same depth rather than exhausting the stack.

Supertraits

A trait may declare a supertrait with trait Sub: Super. The compiler enforces two things:

also exists - you cannot implement the subtrait for a type without implementing its supertrait for that type.

into scope on a T-typed value, and requires T: Super at every instantiation. So a body bounded by T: Sub may call both the subtrait's and the supertrait's methods, each dispatched through the concrete type's impl. When the chain reaches Eq, ==/!= resolve on a T: Sub operand too.

trait Hash: Eq          # every Hash type is an Eq type
  prop hash(self) -> u64
end

def bucket<T: Hash>(x: T, y: T) -> bool
  x.eq(y)               # `Eq`'s method, in scope via the `Hash: Eq` bound
end

One supertrait per trait. The relationship is transitive: a chain A: B, B: C makes C's methods available to a T: A bound and requires impl C<T> wherever impl A<T> appears (each link's impl requirement chains to the next). The supertrait position accepts a module-qualified name like every other cross-module item reference (§14): trait Comparable: eq.Eq works without an open eq.

Inherent impls

impl <Type> ... end (no trait, no <args>) is an inherent impl - methods and associated functions attached to a type without a trait. The implementing type can be a built-in (impl i32), a user struct or sum (impl Email), or a module-qualified name (impl foo.Bar).

struct Email
  text: string
end

impl i32
  @transform def doubled(self) -> i32
    self + self
  end
end

impl Email
  prop is_empty(self) -> bool
    self.text.length == 0
  end
end

A method (first parameter is self) dispatches via value.method() — note the two members above follow §10's one-spelling rule: the attribute read is a prop, and the prop-shaped transformation carries @transform (H0574). An associated function (no self parameter) dispatches via <Type>.fn(args) - the receiver position names the type, the call resolves to the inherent assoc fn of that name:

impl int
  def parse(s: string) -> Option<int>
    ...
  end
end

x = int.parse("42")     # Some(42)
e = Email.parse("a@b")  # Email("a@b") — same syntax on user types

Cross-tier conversion methods (i32.to_f64, int.to_i64, …) live as inherent methods on the source type, following this same convention.

A trait may also declare an associated function - a method whose first parameter is not self - and <Type>.fn(args) resolves it through the type's impl of that trait, exactly as for an inherent one. An inherent associated function of the same name takes precedence (it is chosen, not reported ambiguous). Because the type is named in the receiver position, a Self in the function's signature is pinned to it, so no return-type inference is needed. The same call works through a bounded type parameter: inside a <T: Trait> body, T.fn(args) dispatches to the concrete type's impl via the trait dictionary - the no-self counterpart of the value.method() dispatch above (and what lets a generic codec construct a value of its own type parameter, T.decode(bytes, pos)). There is no inline-type-argument form (§11), so the type is always named; a generic instantiation (List<i32>) is reached through a type parameter, not spelled in receiver position.

# main.hk
open io
open list

trait FromByte
  def from_byte(b: u8) -> Self
end

impl FromByte<u32>
  def from_byte(b: u8) -> Self
    b.to_u32()
  end
end

def widen<T: FromByte>(b: u8) -> T
  T.from_byte(b)
end

def main!(args: List<string>) -> () [io]
  direct = u32.from_byte(5u8)   # concrete: resolves FromByte<u32>
  generic: u32 = widen(7u8)     # T pinned to u32 by the binding; T.from_byte dispatches via the bound
  print!("#{direct == 5u32 and generic == 7u32}")   # true
end

Coherence: at most one inherent item per (target type, name) pair. Methods and associated functions share one namespace per target type. An inherent method named the same as a trait method that also matches the receiver's type takes precedence (it is chosen, not reported ambiguous) - the same inherent-wins rule the associated-function dispatch above already follows. Only a clash between two trait methods (two traits defining the same method for one type) stays ambiguous; rename one or qualify explicitly.

Generic inherent impls

impl<T> Container<T> ... end attaches inherent methods to a generic container. The impl's type parameters are written in angle brackets on the impl head AND referenced in the head's instantiation; methods inside use them in their signatures the same way a generic def<T> does. At each method call site, the impl's parameters are unified with the receiver's concrete arguments, and the method's signature is substituted accordingly.

impl<T> Option<T>
  def unwrap_or(self, default: T) -> T
    match self
      Some(v) -> v
      None    -> default
    end
  end
end

Some(7i32).unwrap_or(0i32)   # T pinned to i32 from the receiver

One impl per base target type (Option, List, …) - a generic impl and a concrete impl Option<i32> on the same target are rejected as mixing generic shapes; pick one. Bounded generic inherent impls (impl<T: Show> Container<T>) are deferred.