Effects
Compose better-result workflows while carrying Service requirements.
Effect is a small namespace around better-result composition. Its canonical
Effect<A, E, R> annotation is a type-only facade over a normal Result<A, E>;
it does not introduce a runtime value class or an Effect TS instruction tree.
R is a union of tagged Service instance requirements.
Lazy Programs
Effect.gen runs when it is called. Keep that eager form when the surrounding
resolver and Scope are already active; use Effect.fn when the computation
should start at a Runtime boundary:
const greet = Effect.fn(async function* () {
const greeting = yield* GreetingService
return Result.ok(greeting.greet('Ada'))
})
const result = await runtime.run(greet)Parameterized programs keep their arguments outside the generator factory:
const findUser = (id: string) =>
Effect.fn(async function* () {
const repository = yield* UserRepository
const user = yield* Result.await(repository.findById(id))
return Result.ok(user)
})
const result = await runtime.run(findUser(userId))Effect.fn does not create a scheduler or instruction tree. It returns a
nominal Program<A, E, R> function whose invocation produces an Effect (or a
Promise of one), preserving the generator's success, error and Service
requirements. Runtime callbacks remain supported for compatibility.
Name a Program lazily for runtime diagnostics. Program.named is data-first or
pipe-compatible and never invokes the source:
const loadUser = pipe(findUser(userId), Program.named('user.load'))
const result = await runtime.run(loadUser, {
attributes: { userId, requestId }
})Names are preserved by the single-source Program transformations (map,
mapError, andThen, tap, tapError, and recover); a later
Program.named overrides the source name. Collection Programs use only their
optional collection name, rather than concatenating child names:
const loadUsers = Program.all(userPrograms, {
concurrency: 8,
name: 'users.load'
})Execution attributes are copied into readonly observer metadata. The library does not serialize or inspect them automatically; do not attach secrets, large mutable objects, or sensitive values intended for logs or metric labels.
Effect.gen
Use a generator when a workflow has multiple steps or contextual dependencies:
import { Effect } from 'better-effect'
import { Result } from 'better-result'
const loadDashboard = Effect.fn(async function* () {
const users = yield* UserRepository
const permissions = yield* PermissionService
const user = yield* Result.await(users.findById('user-1'))
const access = yield* Result.await(permissions.forUser(user.id))
if (!access.canReadDashboard) {
return Result.err(
new Forbidden({
message: 'Dashboard access is required'
})
)
}
return Result.ok({ user, access })
})
type LoadDashboard = typeof loadDashboard
// Program<{ user: User; access: Access }, Forbidden, UserRepository | PermissionService>The generator may yield:
- a Service constructor token, resolved by the active Runtime and recorded publicly as its instance type;
- a
better-resultErr, which short-circuits the workflow.
The generator must finish with Result.ok(...) or Result.err(...) (or another
Result). Do not return a raw object from Result.gen/Effect.gen.
Sync and async generators
The sync overload returns an Effect result immediately. The async overload returns a Promise of an Effect result:
const parseId = Effect.gen(function* () {
const id = yield* parseInput()
return Result.ok(id)
})Use Result.await for a Promise of a Result. It makes asynchronous operations
participate in the same short-circuiting generator control flow:
const user = yield * Result.await(repository.findById(id))Thrown and rejected operations should be normalized by better-result helpers
such as Result.tryPromise before they enter the workflow.
Compose and observe Effects
The namespace helpers keep better-result semantics while retaining Service
requirements in their declarations:
import { Effect, pipe, Program } from 'better-effect'
import { Result } from 'better-result'
const checked = pipe(
loadUser(userId),
Effect.tap((user) => logger.info(`loaded ${user.id}`)),
Effect.asVoid
)
const recovered = Effect.recover(fetchUser, (error) => Result.ok(cachedUser(error)))
const dashboard = Effect.all([loadUser(userId), loadPermissions(userId)])
const pair = Effect.zip(loadUser(userId), loadPermissions(userId))tap, tapError, and tapBoth run only the active branch and return the
original Result. Their asynchronous counterparts, tapAsync, tapErrorAsync,
and tapBothAsync, always return a Promise and accept observers returning
PromiseLike<void>. They use the corresponding better-result combinators:
only the selected observer runs, a successful observation preserves the exact
original Result object, and a throwing or rejected observer follows
better-result's defect (Panic) behavior. Observers do not create a Scope or
resolve Services; use Program.tap/Effect.andThen when an observation needs a
contextual Service or can produce a typed Result.
const observed = pipe(
loadUser(userId),
Effect.tapAsync((user) => metrics.recordUserLoaded(user.id)),
Effect.tapErrorAsync((error) => metrics.recordUserFailure(error))
)Effect.matchError and Effect.matchErrorPartial map the Err channel using
better-result's tagged-error matchers. matchError requires a handler for
every _tag in the known error union. matchErrorPartial transforms selected
tags and keeps every unhandled tagged error in the resulting error union. Both
leave Ok values and Service requirements intact; they do not define a second
tagged-error protocol.
const httpError = Effect.matchError(loadUser(userId), {
UserNotFound: (error) => new NotFoundResponse({ message: error.message }),
AccessDenied: (error) => new ForbiddenResponse({ message: error.message })
})
const withFallbackPolicy = Effect.matchErrorPartial(loadUser(userId), {
UserNotFound: (error) => new NotFoundResponse({ message: error.message })
})
// AccessDenied remains in the error type of withFallbackPolicy.recover and recoverAsync evaluate a fallback only for an error.
flatten removes one nested Effect, while as and asVoid replace successful
values without changing errors or requirements.
Effect.match accepts either plain branch values or Effect-valued handlers.
Only the selected handler runs; both possible handler channels are included in
the static type. Effect.all and Effect.zip collect already-created Effects
in input order. Use Program.all when the work itself must remain lazy:
const loadEverything = Program.all([loadUserProgram(userId), loadPermissionsProgram(userId)], {
concurrency: 2
})
const result = await runtime.run(loadEverything)Program.all invokes no Program while it is being constructed and starts at
most the configured number at a time. When a Program returns an error or throws,
it stops scheduling new work, lets already-started Programs settle, and keeps
a deterministic primary failure. No cancellation or Fiber scheduler is
involved; all started work remains owned by the enclosing Runtime Scope.
Use Program.forEach for a dynamic or readonly collection whose Programs are
created from each item. Its callback receives the item and input index, and the
successful values remain in input order:
const synchronized = Program.forEach(userIds, (userId, index) => synchronizeUser(userId, index), {
concurrency: 8
})
const result = await runtime.run(synchronized)Use Program.allResults for accumulated validation. Typed Err values become
successful elements containing the original Result; a thrown or rejected
Program remains a defect, stops new work, and rejects the outer Program:
const validations = Program.allResults(
[validateIdentity, validateAddress, validateDocuments] as const,
{ concurrency: 3 }
)
const results = await runtime.run(validations)Both helpers share Program.all's lazy FIFO scheduling, stable output order,
and wait-for-started-work behavior.
Compose lazy Programs
Use Effect.* when you already have an Effect (a Result or a Promise of a
Result) and want to transform that value now. Use Program.* when you have an
Effect.fn Program and need to preserve its Runtime-boundary laziness:
const loadUserName = pipe(
findUser(userId),
Program.map((user: User) => user.name),
Program.tap((name: string) => audit.debug('user.loaded', { name }))
)
const result = await runtime.run(loadUserName)Program.map, mapError, tap, and tapError mirror their Effect.*
counterparts. They return a new Program without invoking the source or either
callback; every invocation runs the source exactly once. Taps select only their
matching Result branch and preserve the exact original Result object when the
observer succeeds.
Program.andThen continues an Ok, while Program.recover handles an Err.
Their callbacks may return an Effect, a Promise of an Effect, or another Program:
const loadProfile = Program.andThen(findUser(userId), (user) => fetchProfile(user.id))
const profileOrCached = Program.recover(loadProfile, (error) => loadCachedProfile(error))A continuation or recovery Program is also invoked only after its matching
branch is selected. Program.andThen unions its source and continuation error
channels and Service requirements. Program.recover handles and removes the
source Err channel, exposes the recovery error channel, and still unions the
source and recovery Service requirements. These helpers do not create a Scope,
install a resolver, or add cancellation behavior during composition.
Acquire a resource inside an Effect
Effect.acquireRelease acquires through the current Scope and registers an
outcome-aware release callback:
const readFile = Effect.fn(async function* () {
const file = yield* Effect.acquireRelease(
() => openFile('notes.txt'),
(file, outcome) => file.close(outcome)
)
const contents = yield* Result.await(file.read())
return Result.ok(contents)
})Acquisition failures are part of the Effect's UnhandledException Result
error channel. Release failures belong to Scope cleanup, so a Runtime can
report them without silently changing a primary program failure.
Acquire a Result-valued resource
When a database pool or another adapter already returns a Result,
acquireReleaseResult keeps the typed acquisition error and registers only an
Ok value:
const connection =
yield *
Effect.acquireReleaseResult(
() => pool.connect(),
(connection, outcome) => connection.close(outcome)
)An Err is returned unchanged and its release callback is not run. Thrown or
rejected acquisition defects use UnhandledException; release failures stay
with Scope cleanup.
Acquire a disposable resource
Use acquireDisposable for a file, socket, or client that implements
Symbol.asyncDispose or Symbol.dispose:
const file = yield * Effect.acquireDisposable(() => openFile('notes.txt'))
const contents = yield * Result.await(file.read())The helper acquires lazily when the generator reaches it, preserves the exact resource type, and delegates validation and async-first disposal to the current execution Scope.
Register an already-acquired disposable
Effect.add does not acquire anything and does not create a Scope. It registers
the exact object in the current Scope and yields that same object:
const program = Effect.fn(async function* () {
const socket = await connectSocket()
const managedSocket = yield* Effect.add(socket)
const sent = yield* Result.await(managedSocket.send('hello'))
return Result.ok(sent)
})The value must implement a callable Symbol.asyncDispose or Symbol.dispose.
Without an active Scope, Effect.add preserves the missing-Scope failure.
Prefer Effect.acquireRelease when acquisition is part of the workflow or the
cleanup callback needs the final ScopeOutcome.
Error and requirement inference
EffectFromGenerator combines:
- errors yielded from
Resultoperations; - errors returned by the final
Result; - tagged Service instances corresponding to constructors yielded by the generator;
- instance requirements already carried by nested Effects.
type Program = typeof loadDashboard
type Success = Effect.Success<Program>
type Failure = Effect.Error<Program>
type Needs = Effect.Requirements<Program>
// UserRepository | PermissionServiceUse these types at integration boundaries when you need to name a contract;
ordinary application code usually lets inference carry them for you. The
prefixed EffectSuccess, EffectError and EffectRequirements spellings
remain compatible top-level aliases.
What happens at runtime
The Service requirement metadata is erased. Effect.gen delegates Result
control flow to better-result, while Service iterators resolve their instance
from the active ServiceRuntime. There is no fiber, scheduler, or hidden task
registry. Cooperative cancellation is available only when an explicit
AbortSignal is supplied at the Runtime boundary. For the native Promise
boundary used by Kysely queries, see the Kysely integration.