e2 · effects

An effect is a description of work, typed by what it produces, how it fails, and what it needs

Every operation in e2 is an Fx. This page is about the value itself: how to build one, what yield* means, how failure is represented, and the one mistake the type system cannot catch alone.

The type

Fx<Value, Error, Dependency>

Three parameters, named for what they hold. Value is what a successful run produces. Error is the union of expected failures, never when there are none. Dependency is a union of service names, string literals like 'Db' | 'Logger', again never when there are none.

Names rather than service types are what keep every hover short. Fx<User, DbError, 'Db'> is what you see in the editor and in the compiler's messages, not Fx<User, DbError, Service<'Db', DbApi>>. The trade is that two services may not share a name; the runtime rejects that when a list is assembled.

All three parameters are covariant. An Fx<User, never, never> goes anywhere an Fx<User, DbError, 'Db'> is expected. That is why a plain Result, which is an Fx with no failure and no dependency, can be yielded inside any body.

Building one

Constructors, chosen by what the body does

ConstructorGives youUse it when
gen(function* () { … })an Fxthe body sequences other effects with yield*. The workhorse.
fx(function* (a, b) { … })a function (a, b) => Fxthe same body takes arguments. Sugar for an arrow returning gen; you never need it.
sync(() => v)an Fx<V>the body is a plain synchronous computation with nothing to yield. A throw inside is a defect.
suspend(() => fx)an Fxbuilding the effect must be deferred until it is reached, which is how recursion stays lazy.
async_((resume, signal) => …)an Fx<V, E>you are wrapping a callback API. Call resume once with an effect; return a cleanup to run on interruption.
fromPromise(make, onReject?)an Fx<V, E>you have a promise-returning function. It receives the fiber's AbortSignal; without onReject a rejection is a defect.
ok(v) / err(e)an Ok<V> / Err<E>you already hold the outcome. Also exported as succeed and fail.
gena body that yieldsrun it →
const load = gen(function* () {
  const config = yield* parseConfig(raw)  // a plain Result
  const port   = yield* parsePort(config)
  return { ...config, port }
})
// Fx<Config, ParseError | RangeError>
synca body with nothing to yield
const now = sync(() => Date.now())
// Fx<number>

// the same thing, needlessly, with a generator:
const now2 = gen(function* () {
  return Date.now()
})
Do not annotate a generator body's return type Writing function* (): Generator<…, User> supplies a contextual type that disables inference of the yielded token, and Error and Dependency collapse to whatever you wrote. Let the body infer; annotate the contract the result is assigned to instead.
Result

Result is the pure subset of Fx

Ok is succeed and Err is fail. They are the interpreter's own success and failure instructions, so a Result you already hold can be yielded inside a body with no adapter, and yield* someResult short-circuits on Err. That is the ? operator TypeScript does not have.

Fx<Value, Error, Dependency> Ok<Value> = succeed(value) Err<Error> = fail(error) Result<Value, Error> = Ok | Err inside a body const port = yield* parsePort(raw) Ok → continues with the value · Err → the body fails with it
Outside a body, if (!result.ok) return result typechecks with no cast, because Err<E> is a member of the declared return union.
ok, err
construct
isOk, isErr, isResult
narrow
Result.all([…])
first Err wins, else a tuple of values
Result.attempt(() => …)
a throwing function as a Result
partition(results)
values and errors, separated
fromNullable(value, onNull)
null or undefined as an Err
Failure

Three channels, and only one of them is yours to handle

A run ends in an Exit<Value, Error>, which is Result<Value, Cause<Error>>. A Cause is one of three things:

  • Fail<Error>, an expected failure, the one in the type. err(new NotFound(id)) produces it; catchAll and attemptFx see it.
  • Die, a defect: a throw inside sync, an unmapped promise rejection, a missing provider at runtime. Not in the type, and deliberately invisible to catchAll, because folding defects into the error channel would force every call site to handle Error | unknown.
  • Interrupt, the fiber was cancelled. Also invisible to catchAll.

run returns a Result<Value, Error>: expected failures come back as Err, while defects and interruption reject, because they are bugs rather than outcomes. Use runExit to observe the whole Cause. Cause.pretty(cause) prints it; Cause.squash(cause) reduces it to the underlying error or defect.

Domain errors are ordinary classes with a _tag. TaggedError<'Tag'> is a base that extends the global Error so stacks and instanceof behave.

class NotFound extends TaggedError<'NotFound'> {
  readonly _tag = 'NotFound' as const
  constructor(readonly id: string) { super(`no message ${id}`) }
}

const exit = await runExit(program, providers)
if (!exit.ok) {
  switch (exit.error._tag) {
    case 'Fail':      // exit.error.error is NotFound | …
    case 'Die':       // exit.error.defect is unknown
    case 'Interrupt': // exit.error.reason
  }
}
Combinators

Curried, so they compose; or reach for them as Fx.map

NameSignature, informallyNotes
map(f)(fx)Fx<A,E,D> → Fx<B,E,D>transform the value
flatMap(f)(fx)Fx<A,E,D> → Fx<B, E|E2, D|D2>sequence a dependent effect; inside a body, prefer yield*
catchAll(f)(fx)Fx<A,E,D> → Fx<A|B, E2, D|D2>recover from an expected failure only
mapError(f)(fx)Fx<A,E,D> → Fx<A,E2,D>translate the failure
attemptFx(fx)Fx<A,E,D> → Fx<Result<A,E>, never, D>the outcome as a value, so a body can inspect it

The namespaced form is the same functions under one name: import { Fx } from 'e2' then Fx.map, Fx.retry, Fx.acquire, and so on. Fx is both the type and that object, so Fx<User, DbError, 'Db'> and Fx.map(…) read the same way.

Running

Five entry points

Entry pointReturnsDefects
run(effect, providers)Promise<Result>reject
runExit(effect, providers)Promise<Exit>in the Cause, never rejects
runSync(effect, providers)Resultthrown
runSyncExit(effect, providers)Exitin the Cause, never throws
runtime(providers)Promise<Runtime>see Services

The synchronous pair never suspends. Reaching an asynchronous operation, in the effect or in any provider it builds, produces Die(AsyncBoundary) rather than a silent hang. Synchronicity is not tracked in the type; that would double the type surface on every constructor. Use runSync for tests and genuinely synchronous pipelines.

Every entry point builds the providers it is given (the flat list), runs the effect, and closes the scope. run per request would rebuild the graph per request; that is what runtime is for.

The footgun

An effect you build and do not yield* does nothing, silently

Most shapes of that mistake are type errors, because Fx carries a unique symbol brand. Four are not solvable in the type system and are covered by the lint rule e2/no-floating-fx from eslint-plugin-xnim-e2, which recognises an effect by its brand without resolving the module.

MistakeCaught by
const u = db.query(id); u.nametypes
f(db.query(id)) where f(u: User)types
return db.query(id) without yield*types, at the use site: the value's type becomes 'e2: this generator returns an Fx - did you forget `yield*`?'
const db = yield Db (no *)types
db.query(id) as a bare statementlint
if (someFx), !someFx, someFx ? a : blint
await someFxlint
[...someFx], Promise.all([someFx])lint

The rule deliberately does not flag a discarded Result: it is eager, so by the time you hold one the work has happened. A forgotten yield* on a return is silent only if the effect is never used anywhere, which is not a realistic program.