App Fields, Defer & Context
An app field is a value on the runtime app value produced by fn boot: config
data, dependency handlers, the per-life Context, or whatever else the
program chooses to carry. Read one as MyApp.field; use
with MyApp.field = value to rebind it for the rest of the current block.
No globals, no service locators, no DI containers — just a typed app value
and scoped overrides.
The basic shape
Section titled “The basic shape”The smallest end-to-end example is plain config. fn boot produces the app
value, and main reads a field from the active app type:
import {
std/app.App
std/context.Context
std/io
}
struct MyApp {
context: Context
port: Int
}
impl App for MyApp
fn boot(): App {
MyApp{context: Context.root(), port: 8080}
}
fn main() {
io.print("listening on :${MyApp.port}")
}
The pieces:
MyApp— your project’s concrete app struct. ItsimplforAppentry is stdlib’s signal that this is the app type; the compiler infers the field set soMyApp.fieldreads andwith MyApp.field = ...overrides are type-checked.fn boot(): App— the optional app-entry companion tofn main. Define it when your program wants app fields beyond the default rootContext. The runtime calls it once beforefn mainand stages the returned struct as the implicit app value forfn mainand everything below it.MyApp.port— a qualified read from the active app value. There is no bareportbinding; the app type stays visible at the use site.
Process environment access follows the same shape. os.get
is only available while boot builds the app value; read environment variables
there, convert them to typed config fields, and let the rest of the program read
those fields through MyApp.
In a multi-file module, concrete app types may carry app-specific names such
as MyApp, AdminApp, or WorkerApp.
with rebinds for a block
Section titled “with rebinds for a block”A with MyApp.field = expr statement changes an active app field for the
rest of the current block. Callees that read MyApp.field see the rebound
value. Put the with near the top of a helper when a scoped override has a
name in your program.
App fields can hold dependencies as well as config. Here logger is an
interface value, and with swaps in a different implementation for one block:
import {
std/app.App
std/context.Context
std/io
}
interface Logger {
fn log(logger: self, msg: String): Unit
}
type Stdout
impl Logger for Stdout {
fn log(logger: Stdout, msg: String): Unit {
io.print("[log] ${msg}")
}
}
struct PrefixedLogger {
tag: String
}
impl Logger for PrefixedLogger {
fn log(logger: PrefixedLogger, msg: String): Unit {
io.print("[${logger.tag}] ${msg}")
}
}
struct MyApp {
context: Context
logger: Logger
}
impl App for MyApp
fn boot(): App {
MyApp{context: Context.root(), logger: Stdout}
}
fn main() {
greet("World")
audit_greet("Alice")
greet("Bob")
}
fn audit_greet(name: String) {
with MyApp.logger = PrefixedLogger{tag: "audit"}
greet(name)
}
fn greet(name: String) {
Logger.log(MyApp.logger, "hello, ${name}")
}
The with statement makes app-backed dependencies testable without test
doubles or dependency injection scaffolding — just rebind. Multiple rebinds
are just multiple sequential with statements.
defer runs block-scoped cleanup
Section titled “defer runs block-scoped cleanup”Use defer cleanup(value) for values that need guaranteed cleanup at the
end of the current block. A deferred call must return Unit; Nomi runs
deferred calls automatically in reverse order. A function body is a block
too; use an ordinary nested block when cleanup should happen before later
statements in the same function continue:
import std/io
struct Conn {
name: String
}
fn close(conn: Conn): Unit {
io.print("close ${conn.name}")
}
fn main() {
{
first = Conn{name: "first"}
defer close(first)
second = Conn{name: "second"}
defer close(second)
io.print("body")
}
io.print("after")
}
Acquisition failure is ordinary control flow: write
db = try Sqlite.temp(); defer Sqlite.close(db) when setup returns
Result. If the acquisition fails, the try returns before the defer
statement runs, and any earlier deferred calls in the same block still run
while the failure returns.
Context — the per-life execution carrier
Section titled “Context — the per-life execution carrier”One app field is there whether or not you ask for it: every program has a
Context, and a program with no fn boot gets a default root one.
The Context type in std/context carries two
things through every Nomi program: a deadline, and typed
execution-scoped values. A context field is still an ordinary app
field, so with MyApp.context = Context.with_timeout(MyApp.context, …)
threads the rebound context to every callee reached after the statement —
the same with from a moment ago, applied to the field every program has.
The std/context reference carries the signatures.
They do four jobs:
- Construct —
Context.root, whichbootuses to seed the app value’scontextfield. - Read the deadline —
Context.deadlinefor the absolute instant, orContext.deadline_remainingfor the time left. - Derive a tighter one —
Context.with_deadlineandContext.with_timeout. - Carry typed values —
Context.with_valueandContext.value, keyed by the value’s own Nomi type.
Of the two derivers, with_deadline is the primitive and with_timeout
is sugar for with_deadline(c, Instant.now() + dur). A
derived Context inherits its parent’s deadline: the earliest one anywhere
along the chain is the one in force, so a child can tighten the bound and
never loosen it.
User code can’t construct Context from scratch — only Context.root(),
the with_* derivers, and the runtime mint Context values.
Context and concurrency
Section titled “Context and concurrency”A deadline on the Context bounds every blocking operation reached while
it is in force — timer.sleep, channel sends and receives, Task.await,
Supervisor.flush — and a concurrent block passes it to everything it
spawns. That is the whole of the rule. What it costs in practice depends
on work that is already running when the deadline lands, and on the fact
that exceeding one aborts rather than returning a value you can inspect.
Going deeper
Section titled “Going deeper”Boot and the entry frame
Section titled “Boot and the entry frame”The runtime calls app-entry fn boot(): App once before fn main. The
returned struct (any type with an impl App entry,
typically your project’s concrete app struct) becomes the active app value.
A qualified read such as MyApp.logger resolves against that value when
MyApp is the active app type.
Scoped overrides propagate down the call chain. When a function calls another
function, the callee sees any active with MyApp.field = ... values from the
caller frame.
A program with no fn boot still runs — it just has nothing but the
default root context to read. Define an app type and boot as soon as you
want a field of your own.
with: rebinding, not introduction
Section titled “with: rebinding, not introduction”with MyApp.field = expr rebinds an existing active app field for the
remainder of the current block. You can’t pull a fresh app field out of thin
air with with; the target must be a field on the active app type.
Multiple with statements are evaluated sequentially — a later rebind can
reference an earlier one in scope:
with MyApp.logger = TaggedLogger{tag: "audit"} with MyApp.clock = FakeClock{at: 12345} audit_step()
After those statements, callees reading MyApp.logger see the new
TaggedLogger; callees reading MyApp.clock see the new FakeClock. When
the enclosing block exits, the rebindings revert.