Context System
The context system replaces ambient state — globals, thread-locals, singletons, "magic" dependency injection — with typed, explicitly declared capabilities.
Two levels
Verum offers two complementary DI mechanisms.
A context must be declared before using [...] will accept it —
error<E605>: undefined context otherwise. The stdlib declares ten:
Logger, Database, Auth, Config, Cache, Metrics, Tracer,
Clock, FileSystem (all in core/context/standard.vr) and Random
(core/context/random.vr). See
stdlib → context.
Every other name you meet in these pages — IO, Http, Store,
Network and friends — is a context the example declares for
itself. They are illustrative, not provided: writing using [Http]
without a context Http { ... } in scope is an error. Declaring one is
a single line:
context Http {
fn get(url: Text) -> Result<Response, HttpError>;
}
Note also that IO is a context name in these examples, not the
computational property IO. Properties (Pure, IO, Async,
Fallible, Mutates) are inferred by the compiler and never appear in
a using clause.
Level 1: static @injectable
Compile-time dependency injection with zero runtime cost.
@injectable(Scope.Singleton)
type Logger is { level: LogLevel };
fn process(msg: Message) {
let log: &Logger = inject Logger;
log.info(f"processing {msg.id}");
}
The inject Logger expression is resolved at compile time. The
compiler walks the injector graph, stitches together singleton /
request / transient instances per scope, and hands process the
already-built &Logger.
Level 2: dynamic provide / using
Runtime DI with ~5–30 ns overhead (task-local lookup).
public context Logger {
fn info(message: Text);
fn error(message: Text);
}
public context Database {
fn query(id: UserId) -> User;
}
fn fetch_user(id: UserId) -> User using [Logger, Database] {
Logger.info(f"fetching {id}");
Database.query(id)
}
fn main() {
let log = ConsoleLogger.new(LogLevel.Info);
let db = PostgresDatabase.connect(...)?;
provide Logger = log;
provide Database = db in {
fetch_user(UserId(42));
};
}
Three syntactic facts worth noting against the stdlib's own contexts
in core/context/standard.vr:
contextdeclarations carrypublic(or no visibility prefix) — notpub, and they do not end with a semicolon. The body is enclosed in{ … }like any other item.- Context methods do not take
&self. They're called statically asLogger.info(msg)— the runtime looks up the installed backend from the current task-local context stack. printis a built-in, sofn main()does not needusing [...]. User-defined contexts (Logger, Database, Clock, Metrics, RateLimiter, ...) do appear there whenever the function depends on them.
The provide ... in { ... } scope injects the value. using [Logger, Database] declares the contexts the function needs. Within the provided scope,
Logger.info(...) routes to the installed backend.
Choosing between the two
Use static @injectable | Use dynamic provide/using |
|---|---|
| Singleton / well-known backends | Request-scoped or runtime-selected |
| Zero-overhead required | Flexible late binding acceptable |
| Protocols with a single sensible instance | Protocols with multiple viable instances |
You can mix them. A Database context might be dynamically provided
per-request; a Metrics context might be a compile-time singleton.
Context clause syntax
fn f() using [A, B, C] { ... } // required contexts
fn f() using [A, !IO] { ... } // required A, forbidden IO
fn f() using [A if cfg.debug] { ... } // conditional
fn f() using [A.readonly()] { ... } // transformed capability
fn f() using [A as primary] { ... } // aliased
fn f() using [db: Database] { ... } // named (identifier binding)
The full grammar allows five kinds of item in a context list:
extended_context_item = negative_context (* !Context *)
| conditional_context (* Context if cond *)
| transformed_context (* Context.transform()*)
| named_context (* name: Context OR Context as alias *)
| simple_context ; (* Context *)
Negative contexts
!Ctx means "this function may not use Ctx, and neither may
anything it calls." A function that declares using [!Ctx] and calls
one whose using clause includes Ctx violates the contract, and the
compiler says so:
Function 'caller' calls 'callee' which requires context 'Zlog',
but 'Zlog' is excluded via `using [!Zlog]` in 'caller'
It is an error, and the build fails. [context] unresolved_policy
in Verum.toml chooses the severity, and all four settings now differ:
unresolved_policy | exit | diagnostic | message |
|---|---|---|---|
| absent (default) | non-zero | error | shown |
"error" | non-zero | error | shown |
"warn" | 0 | none | shown |
"allow" | 0 | none | silent |
Until this date the "error" arm called the same warning routine as
"warn", so the knob had no effect and a violation reached you as a log
line that no exit status reflected.
The exclusion also names a real context: using [!Databse] is
error<E605>: undefined context, not a silent no-op, so a typo cannot
read as a guarantee.
This is how Verum encodes "pure" code:
using Pure = [!IO, !State<_>, !Random];
fn sum(xs: &List<Int>) -> Int using Pure {
xs.iter().sum()
}
Context groups
using WebRequest = [Database, Logger, Cache, Metrics, Clock];
fn handle(req: Request) -> Response using [WebRequest] { ... }
Groups are just named lists. They compose.
Transformed contexts
A using entry may call methods on the context, and the function
body then sees the RESULT of those calls rather than the context as
provided:
context Store {
fn read(key: Text) -> Maybe<Text>;
fn write(key: Text, value: Text);
fn readonly() -> Store;
}
fn analyse() using [Store.readonly()] { ... }
Each segment is an ordinary method on the context, and that is literally how it lowers: the compiler fetches the provided context, calls the named method on it, and installs the result as the context for the body. Segments chain left to right:
fn audited() using [Store.readonly().traced()] { ... }
There is no fixed list of transform names — whatever the context
declares can be used. What a transform means is therefore up to the
context that declares it: readonly() above returns a Store whose
write is a no-op or a panic, and the language does not enforce that
promise for you.
A transform names a method, but the compiler does not verify that the
context declares one — Store.nonexistent() compiles today and fails
when the call is made. Contexts and their methods are resolved from
the function signature, not from a registry of declarations.
Named and aliased contexts
Two instances of the same context type require disambiguation — use
as or a prefixed name:
// Using `as` — the alias is the symbol inside the function body:
fn replicate(data: &Record)
using [Database as primary, Database as replica]
{
primary.write(data);
replica.write(data);
}
// Using `name: Context` — the name is the symbol:
fn handle(req: Request)
using [db: Database, log: Logger]
{
log.info(f"req {req}");
db.query(...)
}
Both forms are equivalent. Inside the function, the alias (or name)
is what you call methods on; the bare context type (Database) is
not in scope when there are aliases.
Conditional contexts (feature flags)
fn maybe_log(msg: &Text)
using [Logger,
Analytics if cfg.analytics_enabled,
Metrics if cfg.metrics_enabled]
{
Logger.info(msg);
if cfg.analytics_enabled { Analytics.track(msg); }
if cfg.metrics_enabled { Metrics.increment("msgs"); }
}
cfg.flag is a compile-time boolean from verum.toml features.
Compiling with --features analytics enables the conditional
capability; without it the context is neither required nor emitted.
The condition can also be:
identifier— a compile-time constant.cfg.identifier— a build feature.platform.linux,platform.windows, etc. — target platform.T: Bound— a type-constraint condition.- Any Boolean combination of these with
&&,||,!,(…).
Propagation across async boundaries
Context stacks are task-local. Async and structured concurrency preserve them:
spawnclones the parent's context stack..awaitpreserves the stack across the suspension.for awaitin a generator preserves stacks per yield.nurseryinherits stacks into child tasks.- Channel
send/recvdoes not propagate — channels are data pipes, not capability pipes.
async fn handle(req: Request) using [Database, Logger] {
nursery {
spawn background_task(); // inherits Database + Logger
primary_flow(req).await
}
}
Context protocols
A context protocol is both a protocol (types implement it) and a
context (functions request it). This is the dual role that lets
Logger appear in both implement Logger for Console {} and using [Logger].
context protocol Clock {
fn now(&self) -> Instant;
fn elapsed(&self, since: Instant) -> Duration {
self.now() - since
}
}
Meta contexts — the compile-time mirror
The context system extends to compile-time programming: meta fn
functions declare their capabilities with the same using [...]
syntax as runtime functions, but the contexts are compiler-provided
and execute at compile time with zero runtime cost.
// Runtime context — provider explicit at the call site
fn handle(req: Request) -> Response
using [Database, Logger, Clock] { ... }
// Meta context — provider is the compiler, implicit
meta fn derive_eq<T>() -> TokenStream
using [TypeInfo, AstAccess, CompileDiag] { ... }
Fourteen meta-specific contexts ship with the language: BuildAssets,
TypeInfo, AstAccess, CompileDiag, MetaRuntime, MacroState,
CodeSearch, ProjectInfo, SourceMap, Schema, DepGraph,
MetaBench, StageInfo, Hygiene. Composite groups like
MetaCore = [TypeInfo, AstAccess, CompileDiag] mirror the
using WebRequest = [...] pattern for runtime contexts. See
stdlib → meta for the full API surface.
Where contexts are erased
Contexts provided statically (via @injectable with compile-time
resolution, or provide in a monomorphic call graph) are entirely
erased — the generated code calls the concrete function directly.
Dynamically provided contexts cost one pointer load from task-local storage per access (~5–30 ns).
What contexts are not: a deliberate alternative to algebraic effects
The Verum context system is capability-based dependency injection, not an algebraic-effect system. The distinction is a conscious design choice, and worth unpacking because the two mechanisms are often conflated.
The effect-system tradition
In languages like Koka, Effekt, Eff, Frank, and OCaml 5, every interaction with the outside world (logging, I/O, non-determinism, mutable state, exceptions) is modelled as an effect operation that a surrounding handler interprets. Handlers can resume the suspended computation with a value, abort it, run it many times (supporting non-deterministic search), or compose with other handlers to stack interpretations. This subsumes dependency injection, exception handling, coroutines, generators, transactional state, and logic programming in a single elegant mechanism.
// Koka — Logger as an algebraic effect
effect log { fun info(msg: string): () }
fun greet(name: string): log () {
info("greeting " ++ name) // this might suspend!
}
fun main() {
with handler {
fun info(msg) { println(msg); resume(()) } // handler chooses what to do
}
greet("world")
}
Why Verum does not do this
Effect operations are not ordinary function calls. An operation can in principle capture its own continuation, so every call site must be compiled as if a stack-switch might happen. Koka's evidence-passing transform, Effekt's capability-passing style, and OCaml 5's fibre-based runtime all reduce this cost, but the price of the full-power effect machinery is paid even by operations that never actually resume.
The empirical observation that drove Verum's design: in real code,
the overwhelming majority of "effectful" operations are plain
dependency injection. "Give me a Logger." "Give me a Database."
"Give me a Clock." Those calls do not want to capture their
continuation. They want a vtable dispatch and a way to be mocked in
tests.
The tradeoff Verum makes
Verum's context system meets that common case with a mechanism every systems programmer already understands:
| Dimension | Algebraic effects (Koka, Effekt, OCaml 5) | Verum contexts (using [...]) |
|---|---|---|
| Power | Handlers may resume, abort, multi-shot | Plain virtual dispatch; no resumption |
| Compilation | Evidence-passing / CPS / fibre stack-switch | Vtable dispatch against task-local storage |
| Cost per call | Tens of nanoseconds even for trivial ops | ~5–30 ns dynamic, zero when monomorphised |
| Cost model | Every effectful op may suspend | Function-call cost, independent of op count |
| Testing | Swap the handler | Swap the provider (provide X = mock) |
| Async interaction | Effects subsume async | Async orthogonal to contexts, compose cleanly |
| Mental model | Learn handler algebra / free monads | "It's DI with a language-level syntax" |
For the 5 % of cases where reinterpretation is the point —
non-deterministic search, probabilistic programming, backtracking
parsers, proof search — Verum offers metaprogramming, tactic
combinators, and explicit continuations (async/await,
coroutine-style generators). None of those impose a runtime cost on
every function in the program.
What this means in practice
- A
Logger.infocall is a vtable dispatch, not a suspension point. - Context providers compose by lexical scoping, not by stacked handlers.
- Adding a context to a function's signature does not change its ABI beyond adding a capability requirement; the generated code path is the same shape.
- Compile-time and runtime DI share one surface —
using [...]for both. Effect systems do not unify compile-time meta with runtime DI in this way.
If you are coming from Koka or OCaml 5, the mental adjustment is:
Verum's contexts do less by design. You lose resume. You gain a
cost model that stays flat as your program grows, the ability to hand
a context stack to a spawn and have it just work, and the full
algebra of dependency injection without any of the framework ceremony
that languages without a language-level DI mechanism have to layer on
top.
Grammar
From the grammar reference:
context_clause = 'using' , context_spec ;
context_spec = single_context_spec | extended_context_list ;
single_context_spec
= [ '!' ] , context_path , [ context_alias ] , [ context_condition ] ;
extended_context_list
= '[' , extended_context_item , { ',' , extended_context_item } , ']' ;
extended_context_item = negative_context
| conditional_context
| transformed_context
| named_context
| simple_context ;
context_group_def = 'using' , identifier , '=' , context_list_def , ';' ;
provide_stmt = 'provide' , ( provide_layer | provide_single | provide_multi ) ;
Worked example — wiring a web service
A typical top-level entry point layers every context once:
fn main() {
provide Logger = ConsoleLogger.new(LogLevel.Info),
Clock = SystemClock.new(),
Database = PostgresDatabase.connect(&db_url),
Metrics = PrometheusMetrics.new()
{
let mut server = HttpServer.bind(&":8080").await?;
server.serve(|req| handle(req)).await?;
}
}
async fn handle(req: Request) -> Response
using [Database, Logger, Clock, Metrics]
{
let now = Clock.now();
Logger.info(&f"req {req.path} at {now}");
Metrics.increment("requests.total");
ok_response(&Database.find_user(req.auth)?)
}
The multi-provide form takes comma-separated bindings and a
MANDATORY block — the bindings are in scope for the block and
nothing else. A single binding may use provide X = v; to run to
the end of the enclosing scope, or provide X = v { ... }.
The handle function declares every capability it needs in its
signature, and Clock.now() inside a function that did NOT declare
[Clock] is refused — error<E801>: context 'Clock' used but not declared in function signature.
The obligation travels. Calling handle(req) from a function that
declares no contexts is refused:
fn caller() { handle(req); }
// error<E801>: context `Database` used but not declared in
// function signature
Declaring a different context does not satisfy it either — the
check compares names, not the presence of a using clause. So a
requirement cannot be lost by wrapping the call in a function, which
is what "no hidden state" has to mean for it to be worth anything.
Tests swap in a mock:
@test
async fn test_handler() {
// Several contexts at once: comma-separated bindings, ONE block.
// `in` scopes a SINGLE binding and does not chain.
provide Database = MockDatabase.new(),
Logger = NullLogger.new(),
Clock = FakeClock.at(epoch()),
Metrics = NullMetrics.new() {
let req = Request.get("/users/42");
let resp = handle(req).await;
assert_eq(resp.status.code(), 200);
}
}
No mocking framework. No DI container. Contexts are just types;
provide is just assignment; the compiler does the rest.
Full build in HTTP service tutorial.
See also
- Stdlib → context — the
Provider,Scope, andContextErrortypes. - Async and concurrency — how contexts flow across tasks.
- Architecture → execution environment (θ+) — how context interacts with memory / recovery / concurrency.
- Architecture → runtime tiers — how the runtime implements task-local storage.
- HTTP service tutorial —
end-to-end use of
Layer.new().with_...(...). - Cookbook → shared state — when
a
Mutex-wrapped context is the right shape.