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:
| Spelling | Member kind | Meaning |
|---|---|---|
bare dot x.name | struct field, or prop | a pure value read — nothing happens |
parens x.name() | def method | an 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):
- Self-only. A
proptakes exactly one parameter,self, and no others (H0566). A member that needs arguments is adef. - Pure. A
propis a pure value read: its name may not end with!and it carries no effect row. Effects belong to actions. - No field collision. A
propmay not share a name with a field of the same type —x.namewould be ambiguous (H0566). This holds onopaquetypes too: apropshadowing a hidden field would makex.valuemean different things inside vs. outside the defining file. To re-expose a hidden field under a public name, rename the backing field and add apropthat returns it. - Kind conformance. An impl must declare a member with the same kind (
propvsdef) as its trait; adefimplementing aprop, or vice versa, isH0568.
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 def→prop 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
- The impl head is positional,
Selffirst. A trailing default (trait Add<Rhs = Self>) lets an impl omit the arg:impl Add<Period>meansAdd<Period, Period>— the same impl, everywhere (coherence, dispatch, api-diff), not a different one. A default is a bare type name —Self, an earlier parameter, or a concrete type; a composite default (C = List<Self>) is not yet supported (H0569). Arity outside the declared range, an unbound associated type, a binding the trait doesn't declare, or a binding in an inherent impl are allH0570. - Inside the trait and its impls the trait's names (
B,Out) are in scope and resolve to that impl's bindings; a binding may referenceSelf(type Out = Self), and a generic impl's binding projects through the instantiation (impl<T> Un<Box<T>>withtype Out = T:Box<i32>.un()isi32). An associated type is referable only there — callers never name it; they observe it as the checked result type of a call. Methods andtypebindings share the impl's member namespace (a duplicate member is an error). - Coherence extends per-slot: two impls of one trait conflict iff every type-arg slot has equal base names or a bare generic param on either side — so
Add<Instant, Duration>andAdd<Instant, Instant>coexist, while a duplicate or a wildcard overlap isH0516. - Argument-directed selection. When several impls of one parameterized trait share the receiver's type, the argument types select the impl:
x.mk(y)trial-matches each impl's parameter types againsty's type, and coherence guarantees at most one fits. Selection requires the argument types to be concrete — an unsolved argument (an unsuffixed literal, an unannotated hole) or arguments no impl matches areH0571, never an arbitrary pick. The selected impl's associated-type bindings give the call its result type (Add<Instant, Duration>'stype Output = Instantmakesinstant.add(duration)anInstant). A same-named method from a different trait is still the ordinary ambiguity error. A property read has no argument to select by, so a bare-dotpropthat resolves through more than one impl — same trait or not — isH0619, which names the competing impls; rename your ownprop. One of them may come from a stdlib module the file never imported: the merged program carries every stdlib impl, so a fluent-units namedatetimealready defines onint(days,hours,minutes,seconds,millis,micros,nanos,weeks,months,years) collides even wheredatetimeis not in scope. - Operators.
a + b/a - bon non-numeric operands are the flagship consumers: they select among the coreAdd/Subimpls (trait Add<Rhs = Self> { type Output; def add(self, rhs: Rhs) -> Output }, moduleops) by the operand pair — each side defaults on its own, never unified across — and the expression's type is the winner'sOutput. No matching impl isH0572naming both operand types; an impl with a builtin-primitiveSelfisH0573. The methods are puredefs (an impl cannot widen a trait row), so an operator can never perform effects. Compound assignment inherits the dispatch (t += dist = t.add(d)), and the §12 rebind rule pins theOutputto the target's type —t -= otherwhereSub'sOutputdiffers fromSelfis a type mismatch, not a silent rebind. One staging note: an operator-trait compound on a field target (p.f += d) is rejected with the spelled-out form (p.f = p.f + d), which works. - Still staged (
H0569): bounds on parameterized traits (<T: Add>, soa + bon a generic param is rejected with that message), static dispatch through them,module.load!<T>whereTis parameterized, operator-trait compound assignment on field targets, and default method bodies on a trait that declares type parameters or associated types.
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
- trait dispatch resolves impls by the head's type name, so
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
==/!=on an unbounded generic param is a compile error, because
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:
- Impl requirement.
impl Sub<T>is valid only whenimpl Super<T>
also exists - you cannot implement the subtrait for a type without implementing its supertrait for that type.
- Transitive bound. A
<T: Sub>bound brings the supertrait's methods
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.