Interfaces & Dispatch
Nomi has functions, not methods. Calls use a qualified prefix form such as
User.rename(user), Display.to_string(value), or IO.print(value). There is
no value-dot value.method() syntax.
An interface declares a contract: the functions a type must provide. A type
supplies that contract with an impl Iface for Type { ... } block. This
chapter starts with the direct shape, then shows how qualified calls, defaults,
generic bounds, operators, derives, and the orphan rule fit around it.
Defining and implementing an interface
Section titled “Defining and implementing an interface”import std/io: IO
interface Speech {
fn speak(animal: self): String
}
struct Dog {
field name: String
}
impl Speech for Dog {
fn speak(d: Dog): String {
"${d.name} says woof"
}
}
struct Cat {
field name: String
}
impl Speech for Cat {
fn speak(c: Cat): String {
"${c.name} says meow"
}
}
fn main() {
IO.print(Speech.speak(Dog{name: "Rex"}))
IO.print(Speech.speak(Cat{name: "Whiskers"}))
}
In an interface signature, self is a type placeholder for the concrete
implementing type. It is not a value, not an implicit parameter, and not a
qualifier. Every self in one interface function signature denotes that same
concrete type for a given implementation.
The concrete functions spell their real implementing types (Dog, Cat)
directly. The parameter’s name (animal) is documentation; the impl can
rename it (d, c).
Qualified call shapes
Section titled “Qualified call shapes”At ordinary call sites, interface implementation functions are called through
a type or interface qualifier: Dog.speak(dog) or Speech.speak(dog), not
speak(dog). The qualifier is the name before .. For dispatch, it is
either a concrete type or an interface. Both forms select the implementation
for the concrete type bound to self:
import std/io: IO
interface Speech {
fn speak(animal: self): String
}
struct Dog {
field name: String
}
impl Speech for Dog {
fn speak(d: Dog): String {
"${d.name} says woof"
}
}
fn main() {
rex = Dog{name: "Rex"}
// Type-qualified — names the concrete type. The everyday form.
IO.print(Dog.speak(rex))
// Interface-qualified — names the contract. Always available; required
// when the static type IS the interface (existential / generic code).
IO.print(Speech.speak(rex))
// Stdlib types dispatch the same way.
IO.print(Int.to_string(42))
}
Reach for the concrete type as the default. For interface functions, that means
the type-qualified form (Dog.speak(rex), Int.to_string(n)). Inherent type
functions use the same Type.function shape (List.concat(xs, ys),
Set.size(s)), but they are not dispatch calls. Module functions are qualified
by the module they’re declared in, as in IO.print(x) and Iter.map(xs, f).
Use interface-qualified when the static type is the interface (existential /
generic code) or when two interfaces share a function name on the same type.
File-qualified dispatch (int.to_string(42)) is rejected — a file path is
an import source, not a call qualifier or dispatch contract.
Default Functions
Section titled “Default Functions”Interface functions can include a body — that body becomes the
default implementation. Every type implementing the interface gets
the default for free; the type only needs to provide functions
for the ones that don’t have a default. Defaults are final by default —
implementors can’t override them. Mark a default open to
explicitly permit override:
import std/io: IO
interface Formatted {
fn label(value: self): String
// Final default — overriding it is a compile error.
fn shout(value: self): String {
"${label(value)}!"
}
// Open default — implementors MAY override.
open fn brief(value: self): String {
label(value)
}
}
struct User {
field name: String
}
impl Formatted for User {
fn label(u: User): String {
u.name
}
// `brief` is `open`, so this override is allowed.
fn brief(u: User): String {
"user:${u.name}"
}
}
fn main() {
alice = User{name: "Alice"}
IO.print(User.label(alice))
IO.print(User.shout(alice))
IO.print(User.brief(alice))
}
Defaults are closed unless marked open. A closed default is part of the
interface’s promised behavior; an open default says implementors may replace
it. An interface item with no body is required, and interface items do not use
pub — they share the interface’s visibility.
Inside an interface default, a bare call such as label(value) names a sibling
function from the same interface. Inside a type’s own function block or an
impl block, the same-owner rule applies too: sibling functions can be called
bare from that body. At outside call sites, keep the qualifier.
Field and variant requirements
Section titled “Field and variant requirements”An interface body can declare field name: Type requirements alongside
its functions. A struct that opts in with an impl Interface for Struct
declaration must declare a field of that name and exact type, and a
default function can then read the field from a value typed self knowing
it’s there:
import std/io: IO
interface HasName {
field name: String
// Default — reads the required field from the implementing value.
fn greet(value: self): String {
"Hello, ${value.name}"
}
}
struct User {
field name: String
field age: Int
}
impl HasName for User
struct Pet {
field name: String
field species: String
}
impl HasName for Pet
fn main() {
alice = User{name: "Alice", age: 30}
rex = Pet{name: "Rex", species: "Dog"}
IO.print(HasName.greet(alice))
IO.print(HasName.greet(rex))
}
If User lacked a field name: String (or had field name: Int), the
compiler would reject User’s impl HasName entry — no runtime
surprise. Field types match nominally: name: UserName
where type UserName String is not the same as name: String.
Interfaces can also declare enum variant requirements with the same payload syntax enum declarations use:
interface EventLike {
variant Idle
variant Ready Int
variant Card {rank: Int, suit: String}
}
enum Event {
variant Idle
variant Ready Int
variant Card {rank: Int, suit: String}
}
impl EventLike for Event
fn main(): Event {
ready = Event.Ready(4)
card = Event.Card{rank: 2, suit: "hearts"}
dbg Event.Idle
dbg ready
dbg card
}
An enum that opts in must provide each required variant with the same name
and payload shape. A variant Ready Int requirement is not satisfied by
variant Ready String, and a struct-shaped variant must provide the declared
field set with the declared types.
This is the same mechanism App Fields, Resources & Context
uses for the App contract’s required context: Context field. Project-specific
app fields then live on the concrete app type. The natural follow-up question —
“why not just write fn greet(v: {name: String}) and match on the shape?” — is
covered in Why not structural field matching?
in the deep-dive section below.
Universal Debug
Section titled “Universal Debug”Debug is the only interface that’s automatic. Every value can be
debugged — including the types you just declared, with no impl
conformance, no derive. Nomi provides structural Debug output for every
declared type:
struct Point {
field x: Int
field y: Int
}
enum Status {
variant Active
variant Pending Int
}
type Email String
fn main(): Email {
dbg Point{x: 3, y: 4}
dbg Status.Pending(7)
dbg Email("a@b.com")
}
That’s why dbg has been working everywhere in the tour with no ceremony.
IO.print, in contrast, requires Display — which is opt-in.
Automatic Debug is a default, not a fixed rule. When you want custom
formatting, write impl Debug for Type { ... } (or derive Debug for the
structural one). Your impl takes precedence.
Opting in to Display
Section titled “Opting in to Display”IO.print requires Display — the user-facing format. For your own types,
you provide it with an impl Display for Type { ... } block containing a
to_string function:
import std/io: IO
struct User {
field name: String
field age: Int
}
impl Display for User {
fn to_string(u: User): String {
"${u.name} (${u.age})"
}
}
fn main() {
IO.print(User{name: "Alice", age: 30})
}
The function name (to_string) matches the function in the Display
interface; the implementation spells the concrete implementing type, User.
Interface-bounded generics
Section titled “Interface-bounded generics”A generic function can require its type parameter to implement an interface. The bound is checked at the call site:
fn first_two_sorted<'T>(xs: List<'T>): List<'T> where 'T: Comparable {
xs
|> Iter.sort()
|> Iter.take(2)
|> Iter.to_list()
}
fn main(): List<String> {
first_two_sorted([3, 1, 4, 1, 5, 9, 2, 6])
|> dbg
first_two_sorted(["banana", "apple", "cherry"])
|> dbg
}
Both Int and String already implement Comparable (in stdlib), so both
calls satisfy the bound automatically.
Generic headers introduce names; where constrains those names. See
Generics for the full shape, including multiple and relational
bounds. The short version is:
where 'T: Comparable, where 'T: Display and Debug,
where 'T: Steppable<'S>.
Interface signatures use the same function-level form. A required interface
function can carry a where clause when only that function needs an extra
bound; a default function writes the clause in the same place, before its body.
If the interface function needs its own type parameter, introduce it explicitly
on the function before constraining it:
interface Ranked<'T> { fn item(value: self): 'T fn prefer<'K>(value: self, lhs: 'K, rhs: 'K): Bool where 'K: Comparable { case Comparable.compare(lhs, rhs) { .Less -> False _ -> True } } }
T comes from Ranked<'T>; K comes from prefer<'K>. A type variable that is
not introduced by the interface or the function’s own <...> header is an
error.
Operators that dispatch
Section titled “Operators that dispatch”Most interface functions are called directly through their qualifier:
Display.to_string(value), Dog.speak(dog), List.next(xs). A few operators
are also backed by interfaces. Equality can route through Equatable, ordering
requires Comparable, and arithmetic uses Add, Subtract, Multiply, and
Divide.
The arithmetic interfaces are deliberately two-parameter: the impl chooses both the right-hand operand type and the expression result. That means same-type arithmetic and domain-specific stepping use the same protocol:
type Score Int {
fn number(score: Score): Int {
Score(n) = score
n
}
}
type Day Int {
fn number(day: Day): Int {
Day(n) = day
n
}
}
type Days Int {
fn number(days: Days): Int {
Days(n) = days
n
}
}
impl Add<Score, Score> for Score {
fn add(lhs: Score, rhs: Score): Score {
Score(Score.number(lhs) + Score.number(rhs))
}
}
impl Add<Days, Day> for Day {
fn add(lhs: Day, rhs: Days): Day {
Day(Day.number(lhs) + Days.number(rhs))
}
}
fn main(): Int {
score = Score(2) + Score(3)
day = Day(10) + Days(4)
dbg Score.number(score)
dbg Day.number(day)
}
derive entries — structural shortcuts
Section titled “derive entries — structural shortcuts”derive asks Nomi to write the obvious structural implementation for a common
interface. The main derives are:
- Display and Debug for rendering values.
- Equatable and Hashable for equality and hash-based collections.
- Comparable for ordering with
<,>, andIter.sort.
Display is often hand-written for real user-facing text, and Debug is automatic unless you override it. The derives that need the most explanation are the comparison and collection ones.
Equality is structural out of the box. Two values are == when they have
the same shape and equal parts — no impl, no derive, no ceremony. The same
goes for hashing: any value can be a Map key.
struct Point {
field x: Int
field y: Int
}
fn main(): Map<Point, String> {
dbg Point{x: 1, y: 2} == Point{x: 1, y: 2}
dbg Point{x: 1, y: 2} == Point{x: 1, y: 3}
dbg {Point{x: 1, y: 2} => "home"}
}
So what are derive Equatable and derive Hashable for? Two things:
Generic bounds. A function that demands where 'T: Equatable only accepts
types that declare conformance — structural equality working at runtime
isn’t a declaration. Without the derive, this is a compile error:
fn contains<'T>(xs: List<'T>, needle: 'T): Bool where 'T: Equatable { Iter.any?(xs, |x| x == needle) } contains([Point{x: 1, y: 2}], Point{x: 1, y: 2}) // error: Point does not implement Equatable (required by `where 'T: Equatable`)
Add derive Equatable for Point and the bound is satisfied — the compiler
generates the field-by-field impl.
Custom equality. A hand-written Equatable implementation in an
impl Equatable for Type { ... } block overrides the structural default.
That’s how DateTime compares by instant — the same moment in two time zones
is == (see Dates and times).
Ordering is the exception: there is no structural fallback for <. The
compiler can’t guess whether a Point orders by x, by y, or by distance
from the origin, so comparing values of your own type without a Comparable
impl is a compile error. derive Comparable generates field-by-field
lexicographic ordering:
struct Money {
field amount: Int
}
derive Comparable for Money
fn main(): List<Money> {
dbg Money{amount: 100} < Money{amount: 250}
ms = [Money{amount: 3}, Money{amount: 1}, Money{amount: 2}]
Iter.sort(ms) |> dbg
}
Ranked enums
Section titled “Ranked enums”On an enum, derive Comparable uses variant declaration order as the
ranking — earlier variants sort first. Payloads break ties only within
the same variant; the rank always dominates:
enum Severity {
variant Info
variant Warning Int
variant Error Int
}
derive Comparable for Severity
fn main(): Bool {
dbg Severity.Info < Severity.Warning(1)
dbg Severity.Warning(9) < Severity.Error(1)
dbg Severity.Warning(1) < Severity.Warning(2)
}
Warning(9) < Error(1) is True — the payload never outranks the variant.
This makes the variant list itself the single place the ordering is read
and changed, which is exactly what you want for severity levels, lifecycle
states, and priority ladders.
Deriving vs sorting by key
Section titled “Deriving vs sorting by key”Most domain types shouldn’t implement Comparable at all. A User or an
Order has no intrinsic order — it has many contextual ones (by name in
the directory, by date in the feed). That’s a use-site decision, and the
use-site tool is Iter.sort_by with a key projection (or Iter.sort_with
with a full comparator):
struct User {
field name: String
field age: Int
}
fn main(): List<User> {
users = [User{name: "Cara", age: 35}, User{name: "Ann", age: 41}]
sorted = users |> Iter.sort_by(|u| u.name)
dbg sorted
}
Reach for derive Comparable only when declaration order is the
ordering — the type has one true ranking and the declaration states it:
- Single-field wrappers —
type Score Int,type Priority Int. - Ranked enums —
Severityabove; the variant list is the ladder. - Place-value structs —
Version{major, minor, patch}-shaped types, where field-by-field comparison is the definition of the order (the stdlib’sDateandTimeare this case).
Deriving Comparable on an ordinary domain struct bakes one arbitrary
order into the type — call sites quietly start depending on it, and
reordering the fields (an otherwise meaningless refactor) silently changes
every sort. When the compiler tells you a type has no Comparable impl,
the right response is usually sort_by, not the derive.
Going deeper
Section titled “Going deeper”Coherence: at most one impl per (Iface, T)
Section titled “Coherence: at most one impl per (Iface, T)”A type implements an interface at most once program-wide. Two
impl Display for User { ... } blocks, anywhere in the build graph, are a
compile error. Nomi checks the whole build graph, so collisions are caught
before the program runs.
The check is base-name based: a Display impl for one Date colliding
with a Display impl for another Date triggers it regardless of which
packages the two Dates came from.
The orphan rule
Section titled “The orphan rule”Coherence alone isn’t enough — two libraries each reopening Int with an
impl Display for Int { … } block would silently collide only when both are
linked into the same program. The orphan rule prevents the
situation upstream: an impl Iface for T { ... } block must
live in the package that owns either Iface or T. Third-party code
that owns neither can’t write the impl directly.
An impl’s home package is the directory tree rooted at its nomi.toml and
go.mod. The escape valve is the newtype wrapper: declare type MyId Int in
your package, then impl Display for MyId { ... } is legal because
MyId is yours.
Inherent type functions are stricter. A type’s own qualified API is declared in the type body, so a sibling file cannot add extension-style inherent functions to an imported type.
Why not structural field matching?
Section titled “Why not structural field matching?”The natural question after seeing field name: Type requirements is:
why not just write fn greet(v: {name: String}) and accept any
struct with a name field? Other languages do this — TypeScript and
Go (interface satisfaction by shape), OCaml row polymorphism.
Nomi rejects it explicitly. A function parameter typed {x: Int, y: Int}
accepts only values with that exact shape — not anon structs with
additional fields, and not nominal structs that happen to have
matching fields. Width is part of the type’s identity.
The reasoning: field-name coincidence is not a semantic contract. An
id: String field on User, Order, and Invoice shares a type
but rarely shares a meaning, and silent acceptance erodes trust as
the codebase grows. So Nomi gives three explicit alternatives instead
of a structural-acceptance default:
- Declare an interface — capture the shared contract as a
nominal type. With function signatures, you get shared behavior and
dispatch through
self. Withfield name: Typerequirements, you get the shape contract — but nominally opted in via animpl Iface for Structdeclaration, not matched by accident on layout. The Field requirements section above is the field-only flavor; both flavors share the same opt-in mechanism. - Construct an anon struct at the call site — e.g.
distance({x: p.x, y: p.y}, {x: q.x, y: q.y}). Explicit shape adaptation; the call site shows exactly which fields are borrowed. - Define a nominal domain type + conversion function — e.g.
fn to_coord(p: Point): Coord2D { … }. Mismatches surface where a reviewer can see them.
The choice is usually clear from context: if several types should share a contract long-term, (1); if one call site needs a one-shot reshape, (2); if there’s a meaningful domain conversion, (3).
The same intuition is why Nomi requires an explicit impl Iface for Type
block instead of Go’s “satisfies any interface whose method set
you happen to match.” Coincidence of functions, like coincidence of
field names, isn’t a contract — the impl block is the type opting in,
visibly and grep-ably, to a published protocol. Field requirements
and function requirements are the same design principle applied to
shape and behavior respectively.
Dispatch uses explicit qualifiers for the same reason. Outside a same-owner
body, a bare name might be a local binding or a module function;
Type.fn(value) and Iface.fn(value) say which contract is being used.