Errors and Results
Keep failures typed, explicit and compatible with better-result.
better-effect does not recreate Result, TaggedError or exception
normalization. Import those primitives from better-result and use them as the
single source of truth.
Define tagged domain errors
import { TaggedError } from 'better-result'
class UserNotFound extends TaggedError('UserNotFound')<{
readonly id: string
readonly message: string
}> {}
class DatabaseFailure extends TaggedError('DatabaseFailure')<{
readonly operation: string
readonly cause: unknown
readonly message: string
}> {}Tagged errors make a union discriminated and support exhaustive matching in
your application boundary. Effect.matchError delegates to
better-result's matcher while preserving the Effect's success and Service
requirement channels:
const httpError = Effect.matchError(loadUser(userId), {
UserNotFound: (error) => new NotFoundResponse({ message: error.message }),
DatabaseFailure: (error) => new ServiceUnavailableResponse({ cause: error.cause })
})Every known _tag must be handled. Use Effect.matchErrorPartial when a policy
only transforms selected variants; unhandled variants pass through and remain
in the resulting Err union. These helpers use the tagged-error shapes and
matching behavior exported by better-result; they do not add another error
protocol. Effect.tapAsync, Effect.tapErrorAsync, and Effect.tapBothAsync
are separate observers: they accept PromiseLike<void>, run only the active
branch, preserve the original Result on success, and expose observer defects
according to better-result.
Normalize exceptions at the edge
Wrap throwing or rejecting operations with Result.tryPromise:
const response = await Result.tryPromise({
try: () => fetch(url),
catch: (cause) =>
new NetworkFailure({
url,
cause,
message: 'Network request failed'
})
})Then compose the normalized Result inside an Effect:
const load = Effect.fn(async function* () {
const response = yield* Result.await(fetchResponse())
const body = yield* Result.await(parseBody(response))
return Result.ok(body)
})Acquisition helpers such as Effect.acquireRelease normalize unexpected
acquisition failures with better-result's UnhandledException behavior.
Short-circuiting
Yielding an Err stops the generator immediately:
const user = yield * Result.await(repository.findById(id))
// no following line runs when findById returns Err
const profile = yield * Result.await(loadProfile(user))Return an explicit Result.err for a domain decision rather than throwing:
if (!user.active) {
return Result.err(
new InactiveUser({
id: user.id,
message: 'User is inactive'
})
)
}Runtime failure classification
At a Runtime boundary, only nominal better-result values are classified:
Result.err is a failed execution, so Scope finalizers receive
{ status: 'failure', cause: result.error }; Result.ok is success. An
ordinary domain object such as { status: 'error', message: '...' } remains a
successful value. This classification happens only at the boundary.
Intermediate Result errors do not close an execution Scope while a generator
is still running.
Scope.run differs intentionally: it is Result-agnostic and treats returned
values, including Result.err, as a successful Scope outcome.
Cleanup precedence
Resource and Runtime cleanup preserve the primary failure:
program/use failure > cleanup/release failure > successUse cleanup observers for diagnostics. Do not replace a useful domain error with a secondary close error unless the program itself succeeded.
Handle errors explicitly
Use Result.isOk, Result.isError, .match or your application's tagged-error
matcher at the boundary:
const result = await runtime.run(() => getUser(id))
if (Result.isError(result)) {
return toHttpResponse(result.error)
}
return Response.json(result.value)Kysely query and transaction boundaries document their safe error wrappers and failure precedence in the Kysely integration.