Skip to content

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.

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).

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.

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.

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.

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.

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.

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.

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)
}

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](/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
}

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.

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 wrapperstype Score Int, type Priority Int.
  • Ranked enumsSeverity above; the variant list is the ladder.
  • Place-value structsVersion{major, minor, patch}-shaped types, where field-by-field comparison is the definition of the order (the stdlib’s Date and Time are 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.

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.

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.

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:

  1. Declare an interface — capture the shared contract as a nominal type. With function signatures, you get shared behavior and dispatch through self. With field name: Type requirements, you get the shape contract — but nominally opted in via an impl Iface for Struct declaration, not matched by accident on layout. The Field requirements section above is the field-only flavor; both flavors share the same opt-in mechanism.
  2. 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.
  3. 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.

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 plain fn body 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.

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)).

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 Display conformance 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.

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 x never 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 representation opaque exists to hide. To get richer output, the owning module writes derive Debug or a hand-written impl 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.