Skip to content

Structs, Enums, Distinct Types

Nomi gives you three keyword families for declaring your own data:

  • struct — records with named fields, one field line at a time.
  • type — distinct wrappers around an existing type (type Id Int) and tag-only “bare” types with no payload (type Expired).
  • enum — sum types (a value that’s exactly one of several variants), declared one variant line at a time.

Declaration bodies are newline-separated item lists — no commas between fields, no separators between variants. (Construction literals and anonymous struct types keep their commas.) Struct bodies contain field items plus the type’s own functions and once values; enum bodies contain variant items plus the type’s own functions and once values; type / extern type bodies hold only the type’s own functions and once values. Derives live beside the type as derive Interface for Type declarations. Manual interface implementations live in sibling impl Interface for Type { ... } blocks — that’s the subject of Interfaces & Dispatch. Type declarations do not contain nested declarations, but a type can qualify related sibling types with a dotted PascalCase name:

type Day Int
type Day.Hours Int

Day.Hours is a nominal type in its own right. Its constructor, destructuring pattern, impl blocks, derives, and interface implementations use the full name (Day.Hours(48), Day.Hours(n) = hours, impl Display for Day.Hours { ... }). Use dotted types for domain names that belong under a real type; put broader API hierarchy in a module.

We’ll go in that order. The types you declare in this chapter don’t yet opt in to Display, so we’ll use dbg to inspect them with Nomi’s universal Debug rendering; opting in to IO.print waits for Interfaces & Dispatch.

A struct is a record with named fields. Construct with TypeName{field: value, …}, access fields with .field. Fields can carry default values:

struct User {
  field name: String
  field age: Int = 0
}

fn main(): User {
  alice = User{name: "Alice", age: 30}
  dbg alice.name
  dbg alice.age

  // The default lets the caller omit `age`.
  bob = User{name: "Bob"}
  dbg bob.age

  // Field-name punning: `User{name, age}` is shorthand for
  // `User{name: name, age: age}` when bindings of those names are in scope.
  name = "Carol"
  age = 28
  carol = User{name, age}
  dbg carol
}

A type body also owns that type’s inherent API. Put fields or variants first; then add type-qualified once values and functions that belong to the type itself. Manual interface implementations and derives stay beside the type, not inside it.

pub struct RetryPolicy {
  field retries: Int

  pub once default_retries = 3

  pub fn default(): RetryPolicy {
    RetryPolicy{retries: RetryPolicy.default_retries}
  }

  pub fn exhausted?(policy: RetryPolicy): Bool {
    policy.retries == 0
  }
}

fn main(): Bool {
  policy = RetryPolicy.default()
  dbg RetryPolicy.default_retries
  dbg policy

  RetryPolicy.exhausted?(RetryPolicy{retries: 0})
}

When you want a quick record without declaring a named type, drop the type name and write the struct literally. The value carries its own structural type — {x: Int, y: Int} here — and field access works the same way:

fn main(): Int {
  point = {x: 10, y: 20}
  dbg point
  dbg point.x + point.y
}

type Name UnderlyingType declares a distinct type that wraps an existing one. The two share a runtime representation but the compiler keeps them separate — a function taking Id will refuse a plain Int, even though both use the same representation. This catches whole categories of domain bugs at compile time:

type Id Int
type Email String

fn main(): Int {
  id = Id(42)
  dbg id

  // To pull the inner value out, cast or destructure-bind:
  raw = Int(id)
  dbg raw

  Id(again) = id
  dbg again
}

A type declaration with nothing after the name declares a zero-sized “bare” type — just a tag, no payload. Useful as a sentinel value or as a no-data variant when embedded in an enum (see the deep-dive below), and as the stateless adapter shape when a module-like API needs a value that can implement an interface:

type Expired
type Online

fn main(): Online {
  // Bare types are constructed by name — no parens, no fields.
  state = Expired
  dbg state
  dbg Online
}

An enum is a value that’s exactly one of a fixed set of variants. Variants can be bare (no payload), positional (a single anonymous field), or struct-shaped (named fields):

enum Direction {
  variant North
  variant South
  variant East
  variant West
}

enum Shape {
  variant Circle Float
  variant Rectangle {width: Float, height: Float}
}

fn main(): Shape {
  dbg Direction.North

  c = Shape.Circle(3.0)
  dbg c

  r = Shape.Rectangle{width: 4.0, height: 5.0}
  dbg r
}

The fully-qualified Direction.North / Shape.Circle(...) form always works. When the expected type is known — most commonly inside a case whose subject is a known enum, but also a binding annotation, a function parameter, or a return value — you can drop the enum name and use the dot-leading shorthand:

enum Direction {
  variant North
  variant South
  variant East
  variant West
}

fn describe(d: Direction): String {
  // `d` is a Direction, so each arm's dot-leading pattern
  // unambiguously matches one of Direction's variants.
  case d {
    .North -> "up"
    .South -> "down"
    .East -> "right"
    .West -> "left"
  }
}

fn main(): String {
  // `describe` expects a Direction, so `.North` means `Direction.North`.
  dbg describe(.North)
  dbg describe(.West)
}

This previews case, which Pattern Matching covers in full — but the dot-leading rule is the same in any position the compiler can pin the type: pattern arms, function arguments, return values, list element types (walk: List<Direction> = [.North, .East]), annotated bindings (d: Direction = .North).

When a variant’s payload would be a type you’ve already defined as a standalone struct, distinct type, or bare type, declare it with embeds instead of repeating the shape inline. The embedded type stays independently usable, and values of that type flow into the enum without a wrapping constructor:

struct Click {
  field x: Int
  field y: Int
}

struct KeyDown {
  field key: String
}

type FocusLost // bare — zero-sized

enum Event {
  variant embeds Click
  variant embeds KeyDown
  variant embeds FocusLost
}

fn main(): List<Event> {
  // Each event constructed standalone — no `Event.Click{...}` wrapping.
  // Structs use `{...}`; the bare type is just its name.
  events: List<Event> = [Click{x: 10, y: 20}, KeyDown{key: "Enter"}, FocusLost]
  dbg events
}

The benefit is subtype coercion: a Click value flows into any Event-typed slot (list elements, function arguments, return values) without explicit construction. Pattern matching destructures embedded structs the same way as struct variants — case e { Event.Click{x, y} -> … } — shown in Pattern Matching.

embeds is Nomi’s replacement for the OO extends pattern: a List<Node> of supervision-tree members (Worker, Supervisor, DynamicSupervisor) is an enum with three embeds declarations rather than a class hierarchy.

A typealias is a transparent synonym for an existing type — the alias and the original are fully interchangeable, no wrapping or conversion. The payoff is making complex generic signatures readable:

typealias Index Map<String, List<Int>>

fn record(index: Index, bucket: String, n: Int): Index {
  existing: List<Int> = case Map.get(index, bucket) {
    Some(xs) -> xs
    None -> []
  }
  Map.put(index, bucket, [n, ..existing])
}

fn main(): Map<String, List<Int>> {
  start: Index = Map.empty()
  result =
    start
    |> record("evens", 2)
    |> record("evens", 4)
    |> record("odds", 1)

  dbg result
}

record’s signature reads (Index, String, Int) -> Index instead of the noisier (Map<String, List<Int>>, String, Int) -> Map<String, List<Int>>. Index is literally Map<String, List<Int>> — call sites pass plain map literals, and result is the same type whether you spell it Index or the full generic.

Use type when you want a new type the compiler distinguishes from its representation (UserId shouldn’t accidentally be passed where OrderId is expected — both are represented as Int, but the wrapper keeps them apart). Use typealias when a complex type expression has a meaningful name and writing it out everywhere clutters signatures.