Interfaces & Dispatch
Nomi has functions, not methods. A function can live in a module, a type body,
an interface body, or an impl Iface for Type block, and
every call uses 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 — a set of functions. A type supplies that
contract with an impl Iface for Type { ... } block. The header names one
interface and one implementing type, so the body holds that interface’s
implementation functions as plain fn / extern fn items. Inherent type
functions live in the type body. Structural derives use sibling
derive Iface for Type declarations, one interface per declaration, in the
same file and scope as the type they derive for. Constrained generic
implementations can put additional narrowing bounds after the receiver with
where, such as impl Iterable for Range<'T> where 'T: Discrete { ... }.
Derives use the same receiver-side shape, such as
derive Display for Box<'T> where 'T: Display (subject to the orphan rule we’ll
see in Modules & Imports).
Modules can host implementation declarations, but modules do not implement
interfaces: interface-signature self always denotes the concrete
implementing type. For a stateless implementation, declare a zero-data type
and implement the interface there.
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”Interface implementation functions are called through a type or interface
qualifier, never as a bare function call: 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 {
"${Formatted.label(value)}!"
}
// Open default — implementors MAY override.
open fn brief(value: self): String {
Formatted.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(Formatted.label(alice))
IO.print(Formatted.shout(alice))
IO.print(Formatted.brief(alice))
}
The polarity (closed-by-default, opt-in to extension) treats a
non-open default as part of the protocol’s stable surface — silently
overriding it would break callers that depended on the published
behavior. open is the explicit “this is meant to be tweaked” signal.
A default’s body can also come from the runtime: an extern fn (no Nomi
body) is a host-backed default, and open extern fn is its overridable
form. Stdlib’s Iterable shows the open-default-plus-override shape: its one
default, known_count, is an open Nomi default returning None.
List overrides it with a host-backed extern fn known_count, while
Vector overrides it by returning Some(Vector.length(vector)); both give
Iter.count an O(1) fast path. So the full
classification inside an interface body is: signature-only fn = required
function; fn / open fn + body = final / overridable Nomi default;
extern fn / open extern fn = final / overridable host-backed default.
There is no pub on interface items — a function’s visibility is the
interface’s.
Iterable is otherwise pure protocol — just next plus known_count.
It holds no algorithms: every generic iteration op (map, filter, reduce,
find, sort, count, to_list, …) is a free function in the Iter
module, reached Iter.map(xs, f) / Iter.reduce(xs, f), working over any
implementor. There is no Iterable.map / Iterable.reduce dispatch spelling,
and no eager collection-adapter functions. Container-specific ops that read or
rebuild structure (Map.map_values, Set.union, List.concat, Vector.at,
native String.reverse) stay functions on the type. The rule: generic over any
iterable → Iter.X, specific to a container → Type.X.
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
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”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: Equatableonly 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](/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:
```nomi-run
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.
Qualified call shapes, one dispatch
Section titled “Qualified call shapes, one dispatch”Int’s Display impl exposes to_string under two
qualified call forms — there is no bare-name dispatch:
- Type-qualified:
Int.to_string(42)— names the concrete type. The everyday form; works for any interface function, alongside the type’s plainfnbody items (inherent functions). - Interface-qualified:
Display.to_string(42)— names the contract; mandatory when the static type is the interface itself (existential code) or when two interfaces share a function name uniquely on the same type.
Both resolve to the same impl and the same eval-time function value.
File-qualified int.to_string(42) is rejected for dispatch functions;
see below.
No bare-name dispatch
Section titled “No bare-name dispatch”A bare to_string(42) does not dispatch — the qualifier is
required. A bare call whose name resolves to an interface function is rejected:
bare-name dispatch is not supported: 'to_string' is an interface function
(of Display); qualify the call as Display.to_string(...) or
<Type>.to_string(...)
When two unrelated interfaces expose the same function name for the same type,
there is no single interface a bare call could honestly choose. Naming the
interface qualifier removes the question. Bare names resolve to ordinary module
functions and local bindings; a local fn or lambda that shares an implementation-function
name is called directly, not dispatched.
File-qualified dispatch is rejected for the same reason: m.foo(x) names an
import source or hosting file, not the dispatch contract. Qualify dispatch by
type (Foo.foo(x)) or interface (Iface.foo(x)) instead. Module functions are
still qualified by their declared module (IO.print(x), Iter.from(0)).
derive generates standard implementations
Section titled “derive generates standard implementations”A derive Equatable for Point, derive Hashable for Point, or
derive Comparable for Point declaration asks Nomi to write the corresponding
structural impl. The resulting functions dispatch exactly like hand-written
implementation functions. A derive declaration belongs next to the type
declaration in the same file and scope, and only the structurally-derivable
interfaces are legal to derive: Equatable, Hashable, Comparable, Display, and
Debug.
The standard derive rules are:
- Equatable — pairwise field equality, short-circuiting on first false.
- Hashable — left-fold mix
Hashable.hash(field) * 31 + …. - Comparable — lexicographic by field declaration order.
- Debug — structural string, literal-shape (
Point{x: 3, y: 4}). - Display — same shape as Debug but nested strings are
unquoted — a convenience default; authors typically hand-write
a hand-written
Displayconformance for real user-facing presentation.
Field-type requirements are checked at the derive site, not deep
inside generated functions: derive Equatable for Wrapper on a struct Wrapper with field id: User errors immediately if User: Equatable doesn’t
hold.
Universal Debug rules
Section titled “Universal Debug rules”Every declared type has Debug output. If the type has no explicit Debug,
Nomi gives it the same structural shape you would get from derive Debug.
An explicit hand-written impl Debug for Type { … } block and derive Debug
both take precedence.
The order is:
explicit hand-written impl > derive Debug > automatic Debug.
Three consequences:
dbg xnever errors for a missing Debug impl. Generics and interface-typed values can be inspected without requiring an explicit Debug conformance at every call site.- Opaque types get a name-only default (
<opaque 'T>), not a structural one — a structural default would leak the representationopaqueexists to hide. To get richer output, the owning module writesderive Debugor a hand-writtenimpl Debug.
For non-structural value kinds — function/builtin, Task,
Channel, Unit, modules, Context — Debug returns a
deterministic placeholder (<function>, <Task>, …). Display still
errors on them. Tuples and anon-struct values render structurally
via language intrinsics.