Skip to content

Modules & Imports

Nomi has three layers of organization. A file is a single .nomi source file — what imports point at, and the unit pub operates on (visibility is file-level). A module is a named API container declared inside a file with module, such as IO or Math: it groups names and can nest, but it is not a value and cannot implement interfaces. A package is a directory tree rooted at nomi.toml — what ships together and what the orphan rule operates on. Today package experiments also use a sibling go.mod (more on that below), but that tooling layer is still settling. Each file imports the names it needs explicitly; the compiler resolves the whole program in one pass, so imports never need to be ordered or untangled.

Single imports stand on their own; two or more imports merge into one block. You can select several exported names from one file, select children from an owner, or alias an imported item to dodge a collision:

import {
  std/calendar: Date.parse
  std/io: IO
}

import std/iter: Iter as Seq

fn main() {
  parsed = parse("2026-05-27")

  IO.print(String.length("hello"))
  IO.print(Seq.count([1, 2, 3]))
  IO.print(Seq.to_string(["a", "b", "c", "d"]))
  IO.inspect(parsed)
}
  • std/io: IO — import the IO module from the std/io file; reach members as IO.print.
  • { … } — block form merges several imports; what fmt produces when two single imports stand next to each other.
  • Date.parse — import a child function from Date as bare parse.
  • as Seq — rename an imported item to dodge a local name conflict.

To select children from an owner, write the owner before .{...}:

import shape: Shape.{Circle, Rectangle}

That binds Circle and Rectangle from the exported Shape enum. Add self when you also want the owner itself:

import shape: Shape.{self, Circle, Rectangle}

That binds Shape, Circle, and Rectangle. The self marker always needs the explicit owner before .{...}; it cannot bind an import path like shape or std/calendar.

For a single-file program (everything in the tour so far) you don’t need anything more. A larger project is a package: a directory with a nomi.toml and one or more .nomi files inside. Files within the package import each other by their bare file name — no path prefix.

The next example is a small package with two .nomi files and a nomi.toml. In a real project, these files would sit next to each other in the same directory.

// FILE: math.nomi
pub module Math

pub fn double(n: Int): Int {
  n * 2
}

// FILE: main.nomi
import {
  std/io: IO
  math: Math
}

fn main() {
  IO.print(Math.double(7))
}

// FILE: nomi.toml
[package]

name = "myproject"

entry_points = ["main"]

entry_points lists the files whose fn main is allowed to be the program’s entry. An internal/ subdirectory creates an access barrier — files inside internal/ can only be imported from elsewhere in the same package.

Top-level declarations are private by default. Private means visible only inside the declaring file. To make a declaration reachable from another file, mark it pub:

// FILE: math.nomi
pub module Math

pub fn double(n: Int): Int {
  n * 2
}

// Private: only math.nomi can call this directly.
fn helper(n: Int): Int {
  n + 1
}

// FILE: main.nomi
import {
  std/io: IO
  math: Math
}

fn main() {
  IO.print(Math.double(7))

  // Uncomment to see the file boundary in action — main.nomi can't
  // see math.nomi's private `helper`:
  // IO.print(Math.helper(7))
}

// FILE: nomi.toml
[package]

name = "vis_demo"

entry_points = ["main"]

pub works on every kind of declaration — pub fn, pub struct, pub enum, pub interface, pub type, pub once.

A file can also start with a module header:

pub module Greeter

import std/io: IO

pub fn greet(name: String): Unit {
  IO.print("hello, ${name}")
}

Everything after the header is qualified by that module from other files (Greeter.greet("Nomi")), while declarations inside the file still refer to siblings bare. Use a module header when a file exposes an importable API. Executable entry files are listed in entry_points and define a top-level fn main() callback; main is the program entry point, not part of the file’s imported API surface.

Module blocks can nest when an API has a natural sub-surface. Each segment you import through is part of the public API, so every crossed module needs its own pub:

// FILE: api.nomi
pub module HTTP {
  pub module Header {
    pub fn canonical_name(name: String): String {
      name
    }
  }
}

// FILE: main.nomi
import {
  std/io: IO
  api: HTTP.Header
}

fn main() {
  IO.print(Header.canonical_name("content-type"))
}

// FILE: nomi.toml
[package]

name = "nested_module_demo"

entry_points = ["main"]

The rule is about the file boundary, not the package boundary: two files in the same package are still different visibility units. The package (the nomi.toml-rooted directory tree) is a separate layer — the orphan-rule and distribution unit, not the visibility unit.

Interface implementation functions don’t take a pub of their own. Their visibility comes from the interface they implement:

  • If the interface is pub, other files can call the implementation functions.
  • If the interface is private, the implementation functions stay private too.
  • The rule applies to every impl Interface for Type { ... } block.

Inherent type functions in type bodies use per-item pub, just like other functions. They belong in the same file as the type they qualify; use impl Interface for Type for cross-file package-level interface implementations, not extension-style inherent APIs.

Use a module when the name is only organization:

module Console {
  fn writeln(msg: String): Unit {
    // ...
  }
}

Callers write Console.writeln("hi"), but there is no Console value to pass, store, or use as an app field. A module may contain types, interfaces, nested modules, functions, and impl ... for ... blocks, but the module itself is not an implementing type.

Use a zero-data type when the name should also denote a value type:

interface Logger {
  fn log(value: self, msg: String): Unit
}

type Console

impl Logger for Console {
  fn log(console: Console, msg: String): Unit {
    // ...
  }
}

Now Console is a stateless value type. It can implement Logger, appear in fields, be passed to functions, and be used wherever a Logger is expected. That value-ness is the difference: modules provide hierarchy; types provide nominal values and interface conformance.

A pub declaration exposes both the type’s name and its representation — its fields (struct), variants (enum), or wrapped inner type (distinct type). For an abstract data type whose representation you want to protect — invariants the constructor enforces, a representation you might change later — opaque publishes the name while keeping the construction surface (constructor, unwrap, destructuring, and field access) private:

// FILE: ids.nomi
pub opaque type UserId Int {
  pub fn new(n: Int): Result<UserId, String> {
    if n > 0 {
      Ok(UserId(n))
    } else {
      Err("user IDs must be positive")
    }
  }

  pub fn value(id: UserId): Int {
    UserId(n) = id // only ids.nomi can destructure UserId
    n
  }
}

// FILE: main.nomi
import {
  std/io: IO
  ids: UserId
}

fn main() {
  case UserId.new(42) {
    Ok(id) -> IO.print(UserId.value(id))
    Err(e) -> IO.print("error: ${e}")
  }
  case UserId.new(-1) {
    Ok(_) -> IO.print("unexpected")
    Err(e) -> IO.print("error: ${e}")
  }

  // Uncomment to see the opaque barrier — main.nomi can hold a
  // UserId, but can't construct or destructure one directly:
  // bad = UserId(7)
}

// FILE: nomi.toml
[package]

name = "ids_demo"

entry_points = ["main"]

From main.nomi, UserId is a type you can hold, pass, and store in collections — but you can’t construct one (UserId(7) is rejected), destructure one (UserId(raw) = id errors), or read the inner Int directly. The only way in and out is through UserId’s pub functions. The validation in UserId.new becomes a real invariant: no UserId value can exist with a non-positive inner.

opaque composes with each of the three type-declaration keywords:

  • pub opaque struct — hides the field list.
  • pub opaque enum — hides the variants.
  • pub opaque type Name InnerType — hides the wrapped representation (the form shown above).

opaque is rejected on declarations without a representation — functions, type aliases, interfaces, once bindings, and zero-sized type markers — since there’s nothing to hide.

A file can re-publish names it imports — promoting them into its own public surface — with the export modifier on the import. That’s how a facade file curates an API out of sibling files or dependencies without making consumers chase the underlying source:

// Re-export an imported module — consumers of this file see `Parser`.
import other/parser: Parser export

// Re-export an imported module under a different name.
import other/parser: Parser export as ParserLib

// Per-item — `add` re-exported under its own name; `subtract`
// re-exported under the alias `minus`; `helper` stays private.
import calc: {add export, subtract export as minus, helper}

// Line-level shorthand — every selected name re-exported.
import std/io: IO.{self, IOError} export

Re-exports are explicit by item — there’s no import path.* glob — so an upstream dependency adding new public names never silently expands your file’s API. The name being re-exported must already be pub upstream; re-exporting a private name is an error.

export as Pub applies only to imported names, not to your file’s own declarations. To expose a local definition under a different name, rename the declaration itself or write a thin wrapper.

Package management is one of Nomi’s younger layers. The language-level idea is stable enough to use: a package is a directory rooted at nomi.toml, with files inside forming the import graph. The current implementation also puts a go.mod next to nomi.toml and leans on Go tooling for dependency experiments. That is current reality, not a promise that Nomi package management is forever Go module management.

Creating a package today uses the standard Go pair:

mkdir myproject
cd myproject
go mod init myproject
# then add nomi.toml + your .nomi files

After that, nomi.toml describes the Nomi package and go.mod carries the current dependency mechanics. For local and Git-hosted package experiments, use Go’s familiar tooling — go get github.com/user/somelib, then import files from somelib in your Nomi code. The long-term ergonomics are still an open design area.

The terminology overlap is the only awkward part: Go calls the directory tree a “module,” while Nomi calls that layer a package. In Nomi, a file is the import source and visibility boundary, and a module is a named API container declared inside a file. When the tour says “Go module,” it means the implementation/tooling artifact in go.mod, not Nomi’s module declaration.

The compiler resolves names whole-program in two phases, so a module can refer to a name declared in any other module without forward declarations or careful ordering. No header files, no “include guard” ceremony. (As a side effect, cyclic imports between modules aren’t build errors either — though they’re usually a sign package boundaries want rethinking.)

The flip side of the impl/interface decoupling is the orphan rule: an impl must be declared in the package that owns either Iface or T. That rule applies uniformly to impl Iface for T { ... } blocks, including local, foreign, generic, and constrained-generic implementations such as impl Iface for Box<'T> where 'T: Bound { ... }. You can’t add an impl in your own code that bridges two third-party libraries’ types and interfaces together — that would create coherence conflicts when both libraries are linked. The compiler reports the violation as a build error.

The next chapter — App Fields, Resources & Context — covers Nomi’s answer to “how do you pass runtime dependencies around without a tangle of globals or DI containers”: app fields, scoped with rebinding, resources, and context.