A small, strongly-typed algebraic effects and handlers system for TypeScript.
fx lets you write programs in terms of what they do (effects), and interpret them later with how they do it (handlers).
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. Handlers define semantics.
Everything is an effect.
yield* log("hello")
yield* Db.query("select * from users")
yield* fail(new Error("boom"))
yield* fork(otherProgram)A program is:
Fx<E, A>A= resultE= effects it may perform
Handlers progressively eliminate effects until the program can run.
import { fx, handle, runPromise } from "@briancavalier/fx"
import { defaultConsole, log } from "@briancavalier/fx/Console"
const getUser = fx(function* () {
yield* log("fetching user")
const user = yield* Db.query(
"select * from users where id = ?",
[1]
)
return user
})
const program =
getUser.pipe(
handle(DbQuery, ({ sql, params }) => runQuery(sql, params)),
defaultConsole,
runPromise
)Core primitives are exported from @briancavalier/fx. Built-in effects are
exported from named subpaths, so effect signatures stay concise:
import { Fx } from "@briancavalier/fx"
import { Async, tryPromise } from "@briancavalier/fx/Async"
import { Fail } from "@briancavalier/fx/Fail"
const load: Fx<Async | Fail<unknown>, string> =
tryPromise(() => fetch("/").then(r => r.text()))There are no privileged concepts like services or environments.
Logging, DB access, concurrency, failure, and resource management are all effects.
Application code performs operations:
yield* Db.query(...)It does not request services.
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.
-
Algebraic effects with static typing
Effects are explicit inFx<E, A> -
Composable handlers
Handlers remove effects and can introduce new ones -
Structured concurrency
ForkandTaskprovide owned, composable concurrency -
Resource safety
bracketandScopeensure cleanup -
Async stack traces
Logical stacks across async and fork boundaries
fx intentionally stays minimal. Some “missing features” are deliberate design choices.
There is no built-in concept of:
- services
- layers
- dependency injection
Instead:
- programs express operations
- handlers provide interpretations
Tradeoff:
- simpler, more uniform model
- but large systems require discipline in organizing handlers
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
- cancellation is cooperative (disposal/abort)
- no masking (
uninterruptible, etc.)
Tradeoff:
- simpler model
- but weaker guarantees under complex concurrency
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
Because the core is minimal:
- some safety properties are cooperative
- misuse is possible without care
fx explores a simple idea:
Model everything as effects, and compose meaning 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