Skip to content

FFI & Dynamic

Nomi modules can bind ordinary Go packages. The usual shape is:

  • write Go adapter code in a normal .go file,
  • declare a compile-time Go package handle with gopkg,
  • bind Nomi declarations to Go symbols with go handle.Symbol,
  • expose domain-shaped Nomi APIs instead of raw Go package details,
  • use nomi run, nomi check, nomi test, and nomi build as the workflow.

For app-specific boundaries, the adapter can live next to the Nomi module in the same codebase: a small .go file that speaks to an existing app service, vendor SDK, or platform API, with a Nomi file exposing the shape the rest of the app uses. Common boundaries belong in the ecosystem: SQL, SQLite, HTTP, and other standard integrations can be ordinary Nomi modules backed by Go adapter code.

gopkg is not a Nomi import. It is a Go package handle used only by FFI bindings:

gopkg "urltools"

pub struct ParsedURL {
  scheme: String
  host: String
  path: String
  raw_query: String
  query: Map<String, List<String>>
}

pub fn escape_query(value: String): String go urltools.EscapeQuery

pub fn unescape_query(value: String): Result<String, String> go urltools.UnescapeQuery

pub fn parse_query(raw: String): Result<Map<String, List<String>>, String> go urltools.ParseQuery

pub fn parse(raw: String): Result<ParsedURL, String> go urltools.Parse

The corresponding Go file is regular Go:

package urltools

import "net/url"

type ParsedURL struct {
	Scheme   string
	Host     string
	Path     string
	RawQuery string
	Query    map[string][]string
}

func EscapeQuery(value string) string {
	return url.QueryEscape(value)
}

func UnescapeQuery(value string) (string, error) {
	return url.QueryUnescape(value)
}

func ParseQuery(raw string) (map[string][]string, error) {
	return url.ParseQuery(raw)
}

func Parse(raw string) (ParsedURL, error) {
	parsed, err := url.Parse(raw)
	if err != nil {
		return ParsedURL{}, err
	}
	return ParsedURL{
		Scheme:   parsed.Scheme,
		Host:     parsed.Host,
		Path:     parsed.Path,
		RawQuery: parsed.RawQuery,
		Query:    map[string][]string(parsed.Query()),
	}, nil
}

The Nomi signature is the contract. The Go function should project to that contract through a small adapter rather than exposing raw Go package details. At the binding boundary, Nomi generates the wrapper code that calls the Go symbol and converts the common shapes: scalars like String, Bool, Int, Float, Byte, and Bytes; containers like List and Map; optional and fallible shapes like Maybe and Result; plain structs by matching exported Go fields to Nomi fields; and opaque handles declared with opaque type.

Shapes outside that set should be normalized in the Go adapter. If Nomi only needs to hold a Go-owned value and pass it back, declare an opaque handle. If Nomi needs to inspect a loose value whose shape is not known at the signature, cross it as Dynamic and decode it deliberately at the edge.

Go boundary shapeNomi surfaceNotes
string, bool, numbers, []byteString, Bool, Int, Float, BytesInteger widths are checked at the boundary.
[]T, map[K]VList<T>, Map<K, V>Keys and values must also be supported shapes.
*TMaybe<T>Explicit opaque handles are passed as the handle type instead.
(T, error) / errorResult<T, String> / Result<Unit, String>The Err side is a string message.
exported Go structsNomi structExported Go fields are matched to Nomi fields.
Go-owned resourcesopaque typeUse when Nomi should hold and return the value, not inspect it.
loose runtime valuesDynamicUse when Nomi will decode or pattern over an unknown shape.
channels, non-empty interfaces, named function typesGo adapter or opaque handleNormalize to a supported shape, or keep the value opaque.

Some boundary values do not have a known Nomi shape: plugin messages, decoded payloads, loosely typed configuration, or data from another runtime. Dynamic is the escape hatch for that case.

Use it at the edge, then decode deliberately when the expected shape is known. When a format has a typed representation, prefer that. For JSON, std/json decodes into a Json enum so ordinary case matching can handle the value.

Dynamic is useful for crossing messy boundaries. It should not become the default way to model data inside Nomi.


That’s the tour. You’ve seen Nomi’s core syntax (Bindings & Expressions, Functions, Pipes), its data story (Scalars, Collections, Structs/Enums, Pattern Matching), its abstraction layer (Interfaces, Modules), its distinctive features (App Fields, Defer, Concurrency, Dates & Times, Typed Literals), and how it talks to host programs. The Standard Library reference is generated from the stdlib’s own docs.