Skip to content

Structs, Enums, Distinct Types

Nomi gives you these building blocks for declaring and grouping your own data:

  • struct — records with named fields, one field line at a time.
  • tuple — fixed positional groupings for small local shapes.
  • 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: struct fields and enum variants do not use commas. Construction literals still do.

We’ll start with named and anonymous records, then tuples, wrappers, tags, and sums. The examples use dbg so you can inspect values before the next chapter introduces Interfaces & Dispatch and Display.

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

struct User {
  name: String
  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
}

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
}

Tuples group a fixed number of values by position instead of by field name. They are handy for small, local pairings where the positions are obvious. Destructure them with a tuple pattern:

fn main(): Int {
  pair = ("Ada", 37)
  (name, score) = pair

  dbg pair
  dbg name

  score
}

Use a struct when the grouped values deserve names at the boundary of an API. (String, Int) is fine while the meaning is local; User{name: String, score: Int} is clearer once the shape starts traveling.

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 for stateless adapters that need a real value to 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 {
  North
  South
  East
  West
}

enum Shape {
  Circle Float
  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 {
  North
  South
  East
  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).

Named types can own helper functions in an impl Type { ... } block. Nomi does not have value.method() syntax; the owner stays visible at the call site: User.full_name(user), String.trim(text), List.concat(xs, ys).

struct User {
  first: String
  last: String
}

impl User {
  fn full_name(user: User): String {
    "${user.first} ${user.last}"
  }

  fn rename(user: User, first: String): User {
    User{first, last: user.last}
  }
}

fn main(): String {
  alice = User{first: "Ada", last: "Lovelace"}
  dbg User.full_name(alice)

  renamed = User.rename(alice, "Augusta")
  dbg User.full_name(renamed)

  User.full_name(renamed)
}

Use impl Type for operations whose natural home is a real value type: constructors, projections, validations, conversions, and transformations. The same shape works for structs, enums, distinct types, opaque types, host types, and generic types such as impl Box<T> { ... }.

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 {
  x: Int
  y: Int
}

struct KeyDown {
  key: String
}

type FocusLost // bare — zero-sized

enum Event {
  embeds Click
  embeds KeyDown
  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<Event> of mixed UI event values is an enum with one embeds declaration per concrete event shape 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 a Map of List values — 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.