better-effect
Core concepts

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-result Err, 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 Result operations;
  • 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 | PermissionService

Use 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.

On this page