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.
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.
Constructors, chosen by what the body does
| Constructor | Gives you | Use it when |
|---|---|---|
gen(function* () { … }) | an Fx | the body sequences other effects with yield*. The workhorse. |
fx(function* (a, b) { … }) | a function (a, b) => Fx | the 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 Fx | building 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. |
const load = gen(function* () {
const config = yield* parseConfig(raw) // a plain Result
const port = yield* parsePort(config)
return { ...config, port }
})
// Fx<Config, ParseError | RangeError>
a body with nothing to yieldconst now = sync(() => Date.now())
// Fx<number>
// the same thing, needlessly, with a generator:
const now2 = gen(function* () {
return Date.now()
})
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 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.
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
Errwins, else a tuple of values - Result.attempt(() => …)
- a throwing function as a
Result - partition(results)
- values and errors, separated
- fromNullable(value, onNull)
nullorundefinedas anErr
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;catchAllandattemptFxsee it.Die, a defect: a throw insidesync, an unmapped promise rejection, a missing provider at runtime. Not in the type, and deliberately invisible tocatchAll, because folding defects into the error channel would force every call site to handleError | unknown.Interrupt, the fiber was cancelled. Also invisible tocatchAll.
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
}
}
Curried, so they compose; or reach for them as Fx.map
| Name | Signature, informally | Notes |
|---|---|---|
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.
Five entry points
| Entry point | Returns | Defects |
|---|---|---|
run(effect, providers) | Promise<Result> | reject |
runExit(effect, providers) | Promise<Exit> | in the Cause, never rejects |
runSync(effect, providers) | Result | thrown |
runSyncExit(effect, providers) | Exit | in 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.
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.
| Mistake | Caught by |
|---|---|
const u = db.query(id); u.name | types |
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 statement | lint |
if (someFx), !someFx, someFx ? a : b | lint |
await someFx | lint |
[...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.