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.
extern fn — the boundary
Section titled “extern fn — the boundary”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.
std/json — the typed alternative
Section titled “std/json — the typed alternative”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”).
Going deeper
Section titled “Going deeper”Embedding the runtime
Section titled “Embedding the runtime”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:
| Option | Purpose |
|---|---|
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 type | Go type |
|---|---|
Int | int, int8/16/32/64, uint* (with overflow checks) |
Float | float32, float64 |
String | string |
Bool | bool |
List<'T> | []T (recursive) |
Map<'K, 'V> | map[K]V (recursive) |
Maybe<'T> | *T — nil is None, &x is Some(x) |
Result<'T, 'E> | (T, error) two-return signatures only |
Dynamic | any — opt-in via the Nomi-side Dynamic declaration |
| Opaque distinct types | a 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.
Dynamic decoder surface
Section titled “Dynamic decoder surface”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.
std/json surface and to_dynamic
Section titled “std/json surface and to_dynamic”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.
JSON edge cases
Section titled “JSON edge cases”- Numbers. JSON’s number type doesn’t distinguish ints from
floats;
Json.decodechooses based on the literal —42decodes as.Int(42),42.0as.Float(42.0). Acasearm that only matches one form will miss the other; if you don’t care, match both arms or convert viaInt.to_float. - Integer range. JSON allows arbitrary precision, but Nomi’s
Intis 64-bit. Values outside that range produce a decode error. nullvs missing. A field present with valuenulldecodes to.Null; a missing field is absent from the surrounding.Objmap.Map.get(obj, "field")returningNonedistinguishes them.- Duplicate keys. RFC 8259 leaves this implementation-defined;
Nomi follows last-key-wins (the order Go’s
encoding/jsonhonors). - Round-trip stability.
Json.encode ∘ Json.decodeis not bit-identical — key order inObjfollows the underlying map’s insertion order, whitespace is normalized, and number precision is rendered minimally. UseJson.decodeas 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:
- Deep dives — revisit the longer sections on Dates & Times, Iteration, Interfaces, App Fields, and FFI.
- Standard Library — generated reference for every stdlib module.