Skip to content

briancavalier/fx

Repository files navigation

fx

A small, strongly typed, scope-centered algebraic runtime for TypeScript.

fx lets programs describe operations, delimit their meaning with named scopes, and interpret those operations later with handlers.


Why fx?

Typical TypeScript apps mix:

  • business logic
  • I/O (DB, HTTP, logging)
  • concurrency
  • dependency wiring

Most solutions rely on dependency injection or implicit runtime behavior.

fx takes a different approach:

Programs describe operations. Named scopes define dynamic regions of meaning. Handlers define semantics.


Core idea

Programs are generator computations:

Fx<E, A>
  • A = result
  • E = effects the program may perform

Effects keep required operations visible in the type until handlers eliminate them:

yield* consoleLog("hello")
yield* Db.query("select * from users")
yield* fail(new Error("boom"))
yield* fork(otherProgram)

Named scopes add an ownership boundary around effects whose meaning depends on a dynamic region. A scope can own cleanup, interruption, early return, yielding, and recovery boundaries.

Handlers progressively eliminate effects until the program can run.


Why named scopes?

Many application concerns are not just dependencies. They are regions of execution:

  • a request lifetime that may be interrupted
  • a resource lifetime that must be finalized
  • a timeout boundary where callers choose the recovery policy
  • a progress or event channel that receives yielded values
  • a concurrent group that must clean up cancelled work
  • a parser or workflow step that may return early

In fx, these regions are named scopes. Scope names make ownership explicit without introducing service containers, global runtimes, or framework wiring.


Example

Application logic performs operations. Handlers decide what those operations mean:

import { consoleLog, defaultConsole, fx, handle, runPromise } from "@briancavalier/fx"

const getUser = fx(function* () {
  yield* consoleLog("fetching user")

  const user = yield* Db.query(
    "select * from users where id = ?",
    [1]
  )

  return user
})

const program =
  getUser.pipe(
    handle(DbQuery, ({ arg: { sql, params } }) => runQuery(sql, params)),
    defaultConsole,
    runPromise
  )

Scopes let lifecycle semantics stay explicit too. An operation timeout uses a private diagnostic-hidden scope for the operation, scoped finalizers observe how the operation exited, and the caller chooses how to recover:

import {
  assert as assertNoFail,
  consoleLog,
  control,
  defaultConsole,
  fx,
  runPromise
} from "@briancavalier/fx"
import { withUnboundedConcurrency } from "@briancavalier/fx/concurrent"
import { andFinallyIn, InterruptFrom, scope, withScope } from "@briancavalier/fx/scope"
import { defaultTime, sleep } from "@briancavalier/fx/time"
import { timeout, timeoutIn } from "@briancavalier/fx/timeout"

const RequestScope = scope("request")

const loadUser = fx(function* () {
  yield* andFinallyIn(RequestScope, exit =>
    consoleLog(`request cleanup after ${exit.type}`)
  )

  yield* sleep(1000)
  return { id: 1, name: "Ada" }
})

const program = fx(function* () {
  const user = yield* loadUser.pipe(
    timeout({ ms: 500, label: "load user" })
  )

  yield* consoleLog("loaded user", user)
})

await program.pipe(
  withScope(RequestScope),
  control(InterruptFrom, () => consoleLog("request timed out")),
  defaultTime,
  withUnboundedConcurrency,
  defaultConsole,
  assertNoFail,
  runPromise
)

Use timeoutIn(scope, options) when the deadline is a delayed interruption of a caller-owned scope rather than a timeout for one operation. The caller still owns the scope boundary, and a fork scheduler outside that boundary schedules the internal timer:

const request = fx(function* () {
  yield* timeoutIn(RequestScope, { ms: 500, label: "request deadline" })
  return yield* loadUser
}).pipe(
  withScope(RequestScope),
  withUnboundedConcurrency
)

That timer is daemon scoped work: it can interrupt the scope while other scoped work keeps the scope alive, but it does not keep the scope alive by itself.

Core primitives are exported from @briancavalier/fx. Optional features are exported from named subpaths, so effect signatures stay concise:

import { tryPromise, type Async, type Fail, type Fx } from "@briancavalier/fx"

const load: Fx<Async | Fail<unknown>, string> =
  tryPromise(() => fetch("/").then(r => r.text()))

Use one import rule: core program construction, handling, failure, async boundaries, env, tasks, interrupts, console, and basic diagnostics come from @briancavalier/fx; optional feature areas and advanced trace tools come from their named subpaths.

Capability Import from
Core programs, effects, handlers, failure, async, env, task, console, basic diagnostics @briancavalier/fx
Encoding and decoding external data with branded codec keys @briancavalier/fx/codec
Advanced trace capture, snapshots, and trace formatting options @briancavalier/fx/trace
Named scopes, abort, finalization, early return, scoped yielding @briancavalier/fx/scope
Sinks for receiving values @briancavalier/fx/sink
Scoped mutable state operations @briancavalier/fx/state
Structured concurrency @briancavalier/fx/concurrent
Time and clock handlers @briancavalier/fx/time
Random effects and handlers @briancavalier/fx/random
Structured logging @briancavalier/fx/log
Retry and timeout helpers @briancavalier/fx/retry, @briancavalier/fx/timeout
HTTP client and transport-neutral HTTP server routes @briancavalier/fx/http-client, @briancavalier/fx/http-server
Node runtime, process, diagnostics, and HTTP transport @briancavalier/fx/platform-node

Design philosophy

Operations over dependencies

There are no privileged concepts like services or environments.

Logging, DB access, concurrency, failure, resource management, and lifecycle control are all operations that programs can request and handlers can interpret.


Programs describe behavior, not dependencies

Application code performs operations:

yield* Db.query(...)
yield* consoleLog(...)
yield* yieldFrom(ProgressEvents, event)

It does not request services.


Handlers are just functions

A handler is essentially:

Fx<E1, A>  Fx<E2, A>

So handler composition is just function composition:

program.pipe(
  handlerA,
  handlerB,
  handlerC
)

No container, no wiring graph—just a pipeline.


Key features

  • Typed algebraic effects Programs expose the operations they may perform as Fx<E, A>

  • Named scopes Scopes delimit lifecycle, cleanup, interruption, early return, and yielding

  • Composable handlers
    Handlers remove effects and can introduce new ones

  • Structured concurrency
    Fork, Task, all, and race provide owned, composable concurrency

  • Guaranteed finalization Finalizers run when a scope succeeds, fails, returns, aborts, or is interrupted

  • Scoped yielding Programs emit values to named channels with yieldFrom

  • External data boundaries Branded codec keys keep parsing, serialization, and validation explicit without coupling reusable programs to a schema library

  • Explicit runtime boundaries Async, platform, HTTP, time, random, trace, and Node behavior are interpreted by handlers near the place a program runs


Design tradeoffs

fx intentionally stays minimal. Some “missing features” are deliberate design choices.


No dependency graph abstraction

There is no built-in concept of:

  • services
  • layers
  • dependency injection

Instead:

  • programs express operations
  • scopes define ownership boundaries
  • handlers provide interpretations

Tradeoff:

  • simpler, more uniform model
  • but large systems require discipline in organizing handlers

Minimal runtime

The runtime is small and focused:

  • no scheduler framework
  • no supervision system
  • no built-in observability stack

Tradeoff:

  • easy to understand and reason about
  • but fewer out-of-the-box capabilities

Cooperative interruption

  • interruption is cooperative and scope-aware
  • uninterruptible and uninterruptibleMask defer interruption across short critical sections
  • masking appears as the lightweight Interrupt effect until a runtime boundary eliminates it
  • scoped interruption gives finalizers the reason the scope exited

Tradeoff:

  • simple runtime-owned interruption model
  • uninterruptible regions must remain small to avoid delaying cancellation

Simple failure model

Failures are values:

Fail<E>

No built-in support for:

  • parallel failure aggregation
  • causal chains
  • defect vs interruption distinction

Tradeoff:

  • easy to understand
  • but less expressive in complex workflows

Some guarantees rely on discipline

Because the core is minimal:

  • some safety properties are cooperative
  • misuse is possible without care

Summary

fx explores a simple idea:

Model operations as effects, delimit their meaning with named scopes, and compose interpretation with handlers.

This leads to:

  • very clean program structure
  • strong composability
  • a small but powerful core

…but also:

  • fewer built-in guarantees
  • more responsibility on the developer

References

The design of fx follows a few active threads in the algebraic effects literature: separating effect syntax from handler semantics, keeping effect requirements visible in types, and using explicit handler capture for higher-order effects such as retry, forking, resource management, and local interpretation.

Releases

No releases published

Packages

 
 
 

Contributors