Eta is a small OCaml 5 effect/runtime library. It exposes a typed
('a, 'err) Effect.t for describing pure values, failures, concurrency,
resource scopes, and observability, plus backend-neutral interpreters.
Eta is shaped by TypeScript Effect and Scala ZIO, but it is not a compatibility layer for their full API surface. Deliberate differences are documented in ZIO / Effect Boundaries. The recommended application style is documented in Eta API Style.
Core principle: applications own state; Eta owns effect description and interpretation. There is no global environment, layer graph, service locator, or state container. Pass dependencies as ordinary OCaml values.
Optional capabilities live in separate eta_<feature> opam packages. The
root eta package contains only the core effect model and interpreter
contract. Add eta_eio for an Eio backend, eta_http for HTTP, eta_sql
for SQLite, eta_otel for OpenTelemetry, and so on. See
docs/packages.md for the full map.
Install dependencies and build with Nix (recommended):
nix develop -c dune build @installOr with opam:
opam install . --deps-only --with-test
dune build @installA minimal executable uses (libraries eta eta_eio eio_main):
(executable
(name hello)
(libraries eta eta_eio eio_main))open Eta
let program () =
let open Syntax in
(let* n =
Effect.sync_result (fun () -> Ok (1 + 1))
in
if n < 3 then Effect.fail `Too_small else Effect.pure n)
|> Effect.fold ~ok:Fun.id ~error:(fun `Too_small -> 3)
let () =
Eio_main.run @@ fun stdenv ->
Eio.Switch.run @@ fun sw ->
let rt = Eta_eio.Runtime.create ~sw ~clock:(Eio.Stdenv.clock stdenv) () in
match Eta.Runtime.run rt (program ()) with
| Exit.Ok n -> Format.printf "%d@." n
| Exit.Error _ -> assert falseEta.Runtime is the backend-neutral interpreter; Eta_eio.Runtime.create
is the Eio-backed constructor. The root eta package does not include a
runtime backend.
Core effect model and runtime boundaries:
| Module | Purpose |
|---|---|
Effect |
Abstract description for pure values, typed failure, sync leaves, mapping/sequencing primitives, error recovery, timeout, race, repeat, retry, uninterruptible regions, scopes. |
Syntax |
Binding operators for Effect.t: let*, let+, let@, and*, and and+. |
Supervisor |
Scope-bound nursery for child effects with observable failures, typed await, and cancellation. |
Cause |
Slim failure tree: typed failure, unchecked exception, interruption, and parallel failures. |
Exit |
Runtime boundary result: success or failure cause. |
Runtime |
Backend-neutral interpreter for Effect.t, built from a runtime module. |
Duration |
Millisecond-precision durations. |
Schedule |
Pure recurrence descriptions for repeat and retry. |
Eta_cache.Refreshable |
Optional refreshable cached values with explicit or lexical scheduled refresh. |
Capabilities |
Small object-type traits for runtime services and explicit dependencies. |
Trace_context |
W3C traceparent/tracestate/baggage extract and inject helpers for distributed tracing. |
Concurrency, observability, and data primitives in the root package:
| Module | Purpose |
|---|---|
Tracer |
In-memory and noop tracer implementations for tests and disabled tracing. |
Logger |
In-memory and noop logger implementations. |
Meter |
In-memory and noop meter implementations. |
Sampler |
Trace sampling policies: always-on, always-off, ratio, parent-based. |
Log_level |
Severity levels for log records. |
Random |
Deterministic random helpers over Capabilities.random. |
Promise |
Backend-neutral one-shot result with cancellation-safe broadcast wait. |
Queue |
Same-domain unbounded FIFO with close/error fences. |
Channel |
Same-domain bounded channel with backpressure. |
Pubsub |
Same-domain scoped broadcast hub with explicit overflow policy. |
Pool |
Same-domain bounded resource pool with lifecycle and health checks. |
Semaphore |
Cancellation-safe counting semaphore. |
Mutable_ref |
Named shared mutable cell backed by Atomic.t. |
Sensitive-value redaction lives in the optional eta_redacted package, not in
eta core.
Effect.syncexceptions are unchecked defects (Cause.Die), not typed failures. Catch expected errors by returningresultfrom the synchronous leaf, then useEffect.sync_result f; for anoptionleaf, useEffect.sync_option ~if_none(Noneis typed failure, ordinary raises stay defects).Effect.bind_errorhandles typed failures only; it does not catch defects, interruption, or finalizer failures. UseEffect.catch_somewhen only some typed failures should recover and non-matches must preserve the original cause.Effect.discarddrops a success value without recovering any cause.Effect.ignore_errorsis only for best-effort effects. It discards the success value and suppresses typed failures, but defects, interruption, and finalizer failures still surface.Effect.to_resultturns the typed failure channel into an ordinary OCamlresultvalue inside the workflow. It does not catch defects, interruption, or finalizer failures.Effect.par,all,race, andmap_parrun child effects as fibers on the current runtime substrate, not on CPU worker domains. Useeta_parfor CPU parallelism.Effect.uninterruptibledefers cancellation; it does not turn interruption into a typed failure.Queue,Channel,Pubsub, andPoolare same-runtime primitives. Do not pass them acrosseta_pardomain boundaries.Supervisor.scopedchildren cannot escape their nursery; handles are rank-2 scoped to the body.Runtime.run_exnraisesFailurefor typed failures and interruption. UseRuntime.runwhen you need to inspect the cause.
When the compiler or the runtime pushes back, see Eta type errors, translated for the common messages, what they mean, and the canonical fixes.
The optional eta_par package contains Eta's native worker-domain scheduler.
It is deliberately outside the root eta package so the core effect model can
be interpreted by other runtimes.
let pool = Eta_par.Island.Pool.create ~domains:2 ()
let program =
Eta_par.Island.run ~name:"square" ~pool (fun n -> n * n) 7Finite island batches use Eta_par.Island.map, map_result, or
all_settled. Island pools are explicit native resources; the root
Eta.Runtime does not carry an ambient island pool or a per-run island
override.
For structured CPU parallelism over arrays or recursive fork/join algorithms,
use Eta_par.run, Eta_par.join, Eta_par.par_map, and
Eta_par.Iter. See Concurrency primitives in Eta
for the decision flow between Effect.sync, islands, fork-join parallelism,
and blocking work.
The optional eta_blocking package contains bounded OS-thread worker pools for
synchronous native calls such as database engines, syscalls, and blocking C
libraries. Runtime packages provide the worker substrate; for example
eta_eio installs an Eio run_in_systhread runner by default.
Use Eta_blocking.run when the callback returns an ordinary value, and
Eta_blocking.run_result when the callback returns expected typed failures as
result.
The optional ppx_eta package provides small syntax helpers. They expand to
ordinary Eta.Effect, Eta_observability, and object code; they do not infer
services, build dependency graphs, or add runtime semantics.
let load_user id =
[%eta.fn
(Eta_observability.named "db.query" (Effect.sync (fun () -> Db.user id)))]It expands to:
Eta_observability.fn __POS__ __FUNCTION__
(Eta_observability.named "db.query" (Effect.sync (fun () -> Db.user id)))Leaf effects use ordinary OCaml captures:
let current_user auth =
[%eta.sync "auth.current_user" (Auth.current_user auth)]This expands to Eta_observability.fn __POS__ __FUNCTION__ (Eta_observability.named ... (Effect.sync ...)), with a zero-argument callback.
Result-returning leaves use the same shape with [%eta.result]:
let user = [%eta.result "db.find" (Db.find db id)]It expands to Eta_observability.fn __POS__ __FUNCTION__ (Eta_observability.named "db.find" (Effect.sync_result (fun () -> Db.find db id))). The string is the span name;
Ok succeeds, Error is typed failure, and raises become Cause.Die. Use
Effect.from_result for already-computed results; keep hand-written
named/fn when you need ~error_pp, dynamic names, or other kwargs.
Use it by adding ppx_eta to your test or executable preprocessors:
(preprocess
(pps ppx_eta))The rewritten target must also list eta_observability in its libraries,
because generated fn and named calls use that optional package.
[@@deriving eta_error] generates an ordinary Format printer for a public,
explicit-tag closed polymorphic-variant alias:
type err =
[ `Not_found of string
| `Db of int
| `Unavailable ]
[@@deriving eta_error]
let save =
Eta_observability.named ~error_pp:pp_err "db.save" (Effect.fail (`Db 7))Put the deriving annotation on the declaration in both the .ml and .mli.
The structure generator defines pp_<type-name> as a plain typed match:
let pp_err : Format.formatter -> err -> unit = fun fmt -> function
| `Not_found id -> Format.fprintf fmt "not_found:%s" id
| `Db code -> Format.fprintf fmt "db:%d" code
| `Unavailable -> Format.pp_print_string fmt "unavailable"The signature generator exposes the matching public value. For the err
declaration above, the generated interface item is:
val pp_err : Format.formatter -> err -> unitConstructor text is lowercase with underscores preserved: Not_found becomes
not_found. These strings are stable telemetry, not display-only prose;
renaming a constructor changes span statuses and may require dashboard changes.
Version 1 supports nullary tags and one payload of type string, int,
int64, float, or bool. Name a printer for any other single payload with
[@eta.render f]:
type err =
[ `Decode of payload [@eta.render Payload.pp] ]
[@@deriving eta_error]The attribute must name a Format.formatter -> payload -> unit printer.
Unsupported payloads without this attribute fail during PPX expansion;
the error identifies the constructor and tells you to use a built-in payload or
add [@eta.render f]. Nominal variants, private aliases, open or restricted
rows, inherited rows, and multi-value/tuple payloads are outside version 1 and
are rejected at PPX time rather than rendered as placeholders.
Derivation does not install ambient policy. Pass the generated function through
?error_pp on Eta_observability.named / Eta_observability.fn, or explicitly scope it with
Eta_observability.with_error_pp. As with every Eta error printer, it must be total: a
raising printer becomes a defect through the ordinary runtime capture path.
For Eta SQL (eta_sql), the separate ppx_eta_sql package provides optional
table declaration sugar. Add it to the SQL consumer's preprocessors:
(preprocess
(pps ppx_eta_sql))[%%eta.sql.table
type users = {
id : int [@primary_key];
name : string [@not_null];
active : bool [@not_null];
}]It expands to the ordinary Sql.Table.Make module shape, typed columns, a
users_row record, Users.all, and Users.schema. The input declaration is
consumed by the PPX; application code refers to users_row for all-column
result records. Partial projections still use the ordinary tuple-returning
builder helpers.
Both PPXs are deliberately syntactic. They do not provide Layer, Context,
Tag, implicit service lookup, inferred dependency construction, or argument
conversion.
Use Effect.with_resource for body-bounded resource lifetimes. Finalizers run
on success, typed failure, unchecked defect, and cancellation.
let with_db =
let acquire = Eta_observability.named "db.open" (Effect.sync (fun () -> Db.open_)) in
let release handle =
Eta_observability.named "db.close" (Effect.sync (fun () -> Db.close handle))
in
Effect.with_resource ~acquire ~releaseUse let@ from Eta.Syntax to keep callback-shaped lifecycle code flat:
let load_user id =
let open Eta.Syntax in
let@ db = with_db in
Effect.sync_result (fun () -> Db.load_user db id)Use Effect.acquire_release directly when a resource should live until an
enclosing runtime or Effect.with_scope boundary rather than just one callback body.
For one-shot cleanup around a single effect, use Effect.finally:
let write_then_flush writer bytes =
Writer.write writer bytes
|> Effect.finally (Effect.sync (fun () -> Writer.flush writer))The cleanup runs after success, typed failure, unchecked defect, or
cancellation. If both the body and cleanup fail, Eta reports the cleanup failure
as suppressed under the body failure, using the same cause shape as
acquire_release.
Eta does not ship Layer.t, Tag, Context, or Effect.provide.
Build service graphs with ordinary OCaml functions and keep resource lifetime
inside Effect.with_scope.
See Services Without Layer for the project convention and
failure modes. Why no env channel: object-row environments are not portable
across OxCaml domain boundaries, and every Layer/provide/env-row component
was measured and found worse than ordinary OCaml (evidence:
docs/services.md and .scratch/research/envless-verdict-2026-07-26.md).
Use Supervisor.scoped when a parent needs handles for child effects without
letting those handles escape their owning scope.
The { run = ... } body is intentional: it gives OCaml a rank-2 scope token, so
the type checker rejects returning a child handle after the nursery closes.
let supervised =
Supervisor.scoped {
run =
fun sup ->
let open Supervisor.Scope in
let* child = start sup (lift (Effect.pure 42)) in
await child
}Child failures are recorded on the supervisor and do not fail the parent unless
you await the child or explicitly check a failure threshold.
let observed =
Supervisor.scoped {
run =
fun sup ->
let open Supervisor.Scope in
let* _child = start sup (fail `Refresh_failed) in
let* () = yield in
failures sup
}Supervisor.scoped is the public way to start child work. Runtime-owned
background work stays internal to modules that own that lifecycle. For cached
values that refresh while a workflow runs,
Eta_cache.Refreshable.with_auto owns the refresh loop lexically and records
refresh failures through Eta_cache.Refreshable.failures.
For background work that exists only while a body runs, prefer
Effect.with_background:
let with_stream_reader flow use =
Effect.with_background
~name:"stream.reader"
(Effect.sync (fun () -> read_loop flow))
(fun () -> use flow)This is the structured shape for daemon-like application work without using a
runtime-owned daemon: accept loops scoped to a server lifetime, stream readers
scoped to a handle, heartbeat/ticker loops scoped to a session, and resource
readers scoped by acquire_release. The background child is cancelled when the
body returns or fails. If the child fails first, with_background cancels the
body and propagates the child's cause. Use with_supervised_background when
child failure should be recorded without interrupting the body; suppress typed
failures explicitly with ignore_errors when that work is best-effort.
Choose the smallest primitive that owns the required portability or lifecycle:
| Need | Use |
|---|---|
| Bounded producer/consumer queue | Eio.Stream |
| Backend-neutral one-shot signal or shared result | Eta.Promise |
| Countdown or wait-for-condition | Eio.Condition with Eio.Mutex |
| Eta-owned FIFO with close/error fences | Eta.Queue or Eta.Channel |
| Scoped broadcast with drop/backpressure policy | Eta.Pubsub |
Use Eta.Promise when the same one-shot coordination must run on native and
js_of_ocaml backends. Eio.Promise remains the right direct primitive for
Eio-only code; this wrapper is a portability fence, not a takeover of local Eio
coordination.
Pubsub uses a shared hub buffer with scoped subscriptions. Published messages
are admitted once at the hub, then retained until current subscribers receive
them or unsubscribe. The overflow policy is explicit:
let hub = Pubsub.create ~overflow:(Pubsub.Backpressure { capacity = 64 }) ()
let use_events =
Pubsub.subscribe hub @@ fun sub ->
let open Syntax in
let rec loop () =
let* event = Pubsub.recv sub in
let* () = handle event in
loop ()
in
loop ()Use Unbounded only for low-volume signals where unbounded retention is an
intentional choice. Drop_new drops a new message for all current subscribers
when the hub is full. Backpressure waits before admitting a message, and
publisher cancellation while waiting cannot partially publish to some
subscribers.
Wrap Eio operations in Effect.sync at the leaf when they need Eta tracing
names or defect diagnostics. If a synchronous leaf has expected failures, return
an ordinary OCaml result and lift it with Effect.sync_result;
exceptions remain unchecked defects. If a protocol is reusable and owns
lifecycle semantics, prefer a focused module such as Eta_cache.Refreshable or
Pubsub rather than a generic concurrency-data wrapper.
The optional eta_redacted package provides Redacted.t, which wraps
sensitive values so that formatting and JSON output show <redacted>
instead of the underlying data. Equality and hash use the original value,
so redacted strings can still be used as map keys.
let token = Redacted.make ~label:"api_key" "secret-token"
let auth_header = "Bearer " ^ Redacted.value tokenThe type is abstract and has no [@@deriving show] hooks; accidental
formatter derivation is blocked at the API level. wipe_unsafe best-effort
erases the cell, but the value may still exist in other references.
When adding values to tracer attributes or logs, explicitly extract or
render them. The tracer attribute API remains (string * string) list;
wrap secrets in Redacted.t at the source and call Redacted.value or
Format.asprintf "%a" Redacted.pp only when constructing the attribute list.
Tracing is configured on the runtime. eta_observability ships the public
in-memory tracer; root eta uses a private noop capability when none is
installed:
let tracer = Eta_observability.Tracer.in_memory ()
let rt =
Eta_eio.Runtime.create ~sw ~clock ~tracer:(Eta_observability.Tracer.as_capability tracer) ()Production exporters such as OpenTelemetry live in optional packages:
let rt =
Eta_eio.Runtime.create ~sw ~clock ~tracer:(Eta_otel.tracer exporter) ()Observability is pay-as-you-go. A runtime created without tracer, logger, or
meter capabilities uses Eta's noop sinks and cuts off tracing/logging/metrics
inside the core interpreter before records enter eta_otel queues or OTLP/JSON
encoding. Eta_observability.named still keeps Eta diagnostics such as defect span names
and annotations, so use it where that context is useful rather than as a
per-element marker in the hottest loops.
Typed failures render as "<typed failure>" in span status and exception events
unless a named effect supplies a typed renderer:
let save =
Eta_observability.named
~error_pp:(fun fmt -> function
| `Db code -> Format.fprintf fmt "db:%d" code)
"db.save"
(Effect.fail (`Db 42))Use Eta_observability.with_error_pp when several named spans share the same error
channel. The renderer is scoped to that effect subtree; caught inner errors keep
the conservative default unless they provide their own renderer.
Span attributes can be attached one at a time or as a list:
let load_rows : (int list, [ `Fetch_failed ]) Effect.t =
Effect.pure [ 1; 2; 3 ]
let load_assets =
Eta_observability.fn
~attrs:[ ("component", "ingest"); ("source", "yahoo") ]
__POS__ __FUNCTION__
(Eta_observability.with_result_attrs
~ok_attrs:(fun rows ->
[ ("result", "ok"); ("row_count", string_of_int (List.length rows)) ])
~err_attrs:(fun `Fetch_failed -> [ ("result", "fetch_failed") ])
load_rows)Use Eta_observability.event for structured markers on the active span:
let symbol = "AAPL"
let progress =
Eta_observability.event ~attrs:[ ("asset", symbol) ] "ingest.assets.progress"Eta_observability.event is not a log record. It is dropped when no span is active. Put
with_result_attrs inside Eta_observability.named or Eta_observability.fn; outcome attributes are
also dropped when the wrapped effect settles outside an active span.
At service boundaries, extract W3C headers and install the context around the request effect:
let handle headers =
let body = Eta_observability.named ~kind:Capabilities.Server "http.request" work in
match Eta_observability.Trace_context.extract headers with
| None -> body
| Some ctx -> Eta_observability.with_context ctx bodyFor eta_http request values, use the request helper instead of reaching into the header list:
let handle_request request =
let body = Eta_observability.named ~kind:Capabilities.Server "http.request" work in
match Eta_http.Trace_context.extract_request request with
| None -> body
| Some ctx -> Eta_observability.with_context ctx bodyInside the request, outbound clients can read and inject the current context:
let outbound_headers =
Eta_observability.current_context
|> Effect.map (function
| None -> []
| Some ctx -> Eta_observability.Trace_context.inject ctx)Eta_observability.with_context preserves the W3C sampled flag, tracestate, and
baggage. Eta_observability.with_external_parent remains as a compatibility helper
when only a trace ID and parent span ID are available.
Eta uses a Nix-managed OxCaml 5.2.0+ox toolchain. Enter the shell with:
nix developFirst-time setup creates the local opam switch:
eta-oxcaml-initThe handoff gate is:
nix develop -c dune runtest --forceEta targets OxCaml 5.2.0+ox only. Eta source can use OxCaml modes, stack
allocation, unboxed layouts, zero-allocation checks, and other OxCaml
extensions. Eta does not provide an upstream OCaml compatibility gate.
The active OxCaml toolchain does not build the js_of_ocaml packages. The native gates do not verify those adapters.
Build all installable packages without running tests:
nix develop -c dune build @installBenchmarks are opt-in and are not run by dune runtest:
nix develop -c bash bench/run.sh --quick # quick snapshot
nix develop -c dune build @bench # build runtime benchmark executablesSee bench/README.md for the JSON format, comparison tool, and bisect workflow.
Without Nix, use an OCaml 5.2.0+ox switch, install dependencies, then run the same Dune gates:
opam install . --deps-only --with-test
dune build @install
dune runtest --forceFootguns:
dune buildwithout an alias also builds tests, examples, and benchmark executables. Usedune build @installwhen you only need installable packages.test/httpis the low-level protocol test target.test/http_eiois the green Eio transport gate.
The historical research journal lives at .scratch/research/journal.md. It records
project history and local design reasoning, but it is not part of the published
package.