Skip to content

FFI & Dynamic

Every Nomi program runs inside a host — a Go program that imports Nomi’s runtime. The nomi CLI is the default host; embedding the runtime in your own Go program lets you provide custom extern fn implementations the standalone CLI doesn’t. From the Nomi side, externs read like any other function. For values whose shape isn’t statically knowable at the boundary (parsed JSON, IPC payloads, configuration from a database), the Dynamic type carries them through the type system without losing them.

An extern fn declaration names a host-provided function and gives it a Nomi signature. The body lives in the Go host, registered when it builds the runtime. From the caller’s side, it reads like any other function:

import std/io: IO

extern fn read_file(path: String): Result<String, String>

fn main() {
  case read_file("/etc/hostname") {
    Ok(contents) -> IO.print(contents)
    Err(e) -> IO.print("error: ${e}")
  }
}

(Shown but not runnable here — the browser tour runtime has no host with read_file registered. The pattern is the same for any host extern: declare the signature in Nomi, register the implementation in Go.)

Dynamic — untyped values at the boundary

Section titled “Dynamic — untyped values at the boundary”

Some host returns don’t have a statically-known shape — an arbitrary JSON blob, a query-result row, a value pulled from a map[string]any. The Dynamic type wraps them as opaque values that user code unpacks with explicit decoders from std/dynamic:

import {
  std/dynamic: Dynamic
  std/io: IO
  std/json: Json
}

fn main() {
  // In real FFI, `dyn` would come back from an `extern fn` returning
  // `Dynamic`. Here we construct one via `Json.to_dynamic` so the
  // example runs in the tour:
  dyn = Json.to_dynamic(Json.String("hello"))

  case Dynamic.as_string(dyn) {
    Ok(s) -> IO.print(s)
    Err(e) -> IO.print("error: ${e}")
  }
}

as_string is one of several decoders (as_int, as_bool, as_list, …) — each returns Result<'T, DecodeError> with a path-prefixed error so failures point at where in the value the shape didn’t match.

When the data is JSON specifically, std/json decodes it eagerly into a Json tagged enum, and you destructure with case — the same pattern matching as any other enum. No decoder ceremony:

import {
  std/io: IO
  std/json: Json
}

fn main() {
  body = """
    {"name": "Ada", "tags": ["admin", "user"]}
    """

  case Json.decode(body) {
    Ok(.Obj{"name" => .String(n), "tags" => .Arr(ts)}) -> {
      IO.print("name: ${n}")
      IO.print("tag count: ${Iter.count(ts)}")
    }
    Ok(_) -> IO.print("unexpected shape")
    Err(e) -> IO.print("decode error: ${e}")
  }
}

Json.decode returns Result<Json, JsonDecodeError> so a malformed body lands in Err. Json is String | Int | Float | Bool | Arr | Obj | Null — the typed-JSON shape — and the case pattern asserts both shape and payload in one site ({"name" => .String(n)} matches “the name field is a JSON string and bind its value to n”).

A Go host that wants to run Nomi code (with or without custom externs) imports nomi/runtime, constructs a Runtime, loads source, and runs:

import (
    "nomi/runtime"
    "os"
)

rt := runtime.New(
    runtime.WithOutput(os.Stdout),
)

if err := rt.LoadSource("main", src); err != nil { … }
if err := rt.Run(); err != nil { … }

runtime.New accepts options:

OptionPurpose
WithOutput(io.Writer)Output sink for IO.print / IO.inspect. Default: os.Stdout.
WithInput(io.Reader)Source for IO.read_line. Default: os.Stdin.
WithVirtualFiles(map)Stage .nomi siblings in memory by bare-module-name. The runtime resolves import math from this map before falling back to disk.
WithVirtualManifest(*Manifest)Pre-parsed nomi.toml for virtual-mode runs (so the manifest-driven checks fire without a real disk root).

For host functions Nomi can call, use RegisterExternFunc:

rt.RegisterExternFunc("read_file", func(path string) (string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return "", err
    }
    return string(data), nil
})

The signature shape — Go side: (args...) (return, error), where the args and return are reflection-compatible types — is matched against the Nomi-side extern fn declaration at LoadSource time. A mismatch surfaces as a load error before user code runs. The extern validator walks every declared extern and confirms a registration exists with a compatible signature.

Marshal types — what crosses the boundary

Section titled “Marshal types — what crosses the boundary”

The marshal layer translates between Go’s reflect-types and Nomi’s runtime values. These shapes can cross the boundary:

Nomi typeGo type
Intint, int8/16/32/64, uint* (with overflow checks)
Floatfloat32, float64
Stringstring
Boolbool
List<'T>[]T (recursive)
Map<'K, 'V>map[K]V (recursive)
Maybe<'T>*Tnil is None, &x is Some(x)
Result<'T, 'E>(T, error) two-return signatures only
Dynamicany — opt-in via the Nomi-side Dynamic declaration
Opaque distinct typesa marker Go struct pointer registered via RegisterExternType

Context is intentionally not marshalable — rt.Marshal / rt.Unmarshal reject it. Context is the per-life capability; host code injects it via RegisterExternFunc of a context-aware extern or RunOptions{RootContext: root}, not by passing values across the boundary.

User code decodes a Dynamic with the std/dynamic accessor library. The surface:

// Atomic extractors.
pub fn as_int(d: Dynamic): Result<Int, DecodeError>
pub fn as_float(d: Dynamic): Result<Float, DecodeError>
pub fn as_string(d: Dynamic): Result<String, DecodeError>
pub fn as_bool(d: Dynamic): Result<Bool, DecodeError>

// Navigators — descend into objects / lists.
pub fn field(d: Dynamic, key: String): Result<Dynamic, DecodeError>
pub fn index(d: Dynamic, idx: Int): Result<Dynamic, DecodeError>
pub fn path(d: Dynamic, dotted: String): Result<Dynamic, DecodeError>

// Bulk shape extractors and probes.
pub fn as_list(d: Dynamic): Result<List<Dynamic>, DecodeError>
pub fn as_dict(d: Dynamic): Result<Map<String, Dynamic>, DecodeError>
pub fn null?(d: Dynamic): Bool
pub fn has?(d: Dynamic, key: String): Bool

A failure (field("missing") on a Dynamic that doesn’t have it, or as_int on a Dynamic that’s actually a String) returns Err(DecodeError{...}) — a structured error carrying the path walked from the root:

pub struct DecodeError {
  field path: List<PathSegment>
  field expected: String
  field got: String
}

pub enum PathSegment {
  variant Field String
  variant Index Int
}

The Display impl renders as decode error at user.emails[0]: expected String, got Null — easy to print, easy to log. Chain decoders with try so the path accumulates naturally:

import std/dynamic: Dynamic

fn user_first_email(dyn: Dynamic): Result<String, DecodeError> {
  dyn
  |> Dynamic.at_field(
    "user",
    |user| user
    |> Dynamic.at_field(
      "emails",
      |emails| emails
      |> Dynamic.at_index(0, Dynamic.as_string),
    ),
  )
}

Build larger decoders by composing these primitives with try; each failed step preserves the path to the value that failed to decode.

pub enum Json {
  String String
  Int Int
  Float Float
  Bool Bool
  Arr List<Json>
  Obj Map<String, Json>
  Null
}

pub fn decode(body: String): Result<Json, JsonDecodeError>
pub fn encode(value: Json): String
pub fn to_dynamic(value: Json): Dynamic

to_dynamic bridges between the two worlds: pass a Json to code expecting a Dynamic, or vice versa, without re-serializing. The Dynamic side sees the same underlying host value the Json carries.

  • Numbers. JSON’s number type doesn’t distinguish ints from floats; Json.decode chooses based on the literal — 42 decodes as .Int(42), 42.0 as .Float(42.0). A case arm that only matches one form will miss the other; if you don’t care, match both arms or convert via Int.to_float.
  • Integer range. JSON allows arbitrary precision, but Nomi’s Int is 64-bit. Values outside that range produce a decode error.
  • null vs missing. A field present with value null decodes to .Null; a missing field is absent from the surrounding .Obj map. Map.get(obj, "field") returning None distinguishes them.
  • Duplicate keys. RFC 8259 leaves this implementation-defined; Nomi follows last-key-wins (the order Go’s encoding/json honors).
  • Round-trip stability. Json.encode ∘ Json.decode is not bit-identical — key order in Obj follows the underlying map’s insertion order, whitespace is normalized, and number precision is rendered minimally. Use Json.decode as a parser, not a canonicalizer.

For runnable host-side examples see examples/embedded-ffi/json/ is a Dynamic-decoder round-trip, json-typed/ uses the Json enum.


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, Resources, Concurrency, Dates & Times, Typed Literals), and how it talks to the host. From here: