Error handling with fault

npx skills add Thourum/Fault --skill fault-error-handling

and* runs on Ok. or* runs on Err. E is always Fault.

Signatures

function parsePort(raw: string): Result<number, Fault>
function charge(id: string): ResultAsync<Receipt, Fault>
function fullName(user: User): string

Sync fallible → Result<T, Fault>. Async fallible → ResultAsync<T, Fault>. Infallible → plain T.

Never Promise<Result<...>>. Never async function that returns a Result. ResultAsync is PromiseLike<Result<T, E>>, so await yields Result.

const result: Result<Receipt, Fault> = await charge(id)

Constructing failures

err(new Fault('email required').withTag('VALIDATION_ERROR'))
err(new Fault(e).withTag('DATABASE_ERROR').withCause(e).withMetadata('userId', id))
err(ServiceError('NOT_FOUND', 'User not found', `id=${id}`))

ServiceError(tag, message, description?) is new Fault(message).withTag(tag).withDescription(description ?? message). Use it for tag + message. The third arg becomes developer details, not a user-facing override.

Use new Fault(...) when wrapping an Error, attaching metadata/cause, or calling two-arg withDescription(details, userMessage). Builders return a new Fault without changing the original; use mapErr to replace a Result's error.

FaultTag is a fixed union of SCREAMING_SNAKE tags plus (string & {}) — withTag accepts any string; known tags autocomplete. statusCode is derived from the tag (default 500). Custom tags are 500.

Tags

One tag per failure mode. Narrow at the boundary by fault.tag, not instanceof.

.withTag('VALIDATION_ERROR')
.withDetails('pg refused the connection')          // developer context
.withDescription(devDetails, 'Please try again')   // details + optional user message
.withMetadata('userId', id)                        // or withMetadata({ userId: id })
.withCause(e)                                      // wrap the thrown value

withDetails sets developer context only. withDescription(details, message?) sets the same details field; the second arg overrides the user-facing message.

Reading a chain

ok(raw).andThen(parseEmail)           // transform, may fail → Result
ok(user).map((u) => u.id)             // transform, cannot fail
ok(user).andCheck(assertActive)       // validate; keep original value
ok(user).andInspect((u) => log(u.id)) // side effect on Ok
err(f).orInspect((e) => e.capture())  // side effect on Err
err(f).orElse(() => loadFallback())   // recover
err(f).mapErr((e) => e.withTag('INTERNAL_ERROR'))
result.match((v) => v, (f) => f.tag)
result.unwrapOr(0)

Same names exist on ResultAsync. There andThen / orElse / andCheck accept a Result or a ResultAsync. match and unwrapOr return a Promise.

Mixing sync and async

On Result only:

ok(id).asyncAndThen(loadUser)     // (t) => ResultAsync<U, F>
ok(user).asyncMap(async (u) => u.email)
ok(user).asyncAndCheck(ensureUnique)  // async validate; keep value

ResultAsync has no async* methods — its andThen / map / andCheck already take sync or async callbacks.

loadUser(id).andThen((u) => ok(u.email))   // Result callback is fine
loadUser(id).andThen((u) => fetchBio(u))   // ResultAsync callback too

Parallel and sequential

Result.combine([parseA(), parseB()])                 // first Err wins
Result.combineWithAllErrors([parseA(), parseB()])    // Err is Fault[]
ResultAsync.combine([loadA(), loadB()])
ResultAsync.combineWithAllErrors([loadA(), loadB()])

safeTry is Rust ?. Sync generator → Result. Async generator → ResultAsync.

const r = safeTry(function* () {
  const user = yield* findUser(id)
  const tx = yield* debit(user, cents)
  return ok(toReceipt(tx))
})
const r = safeTry(async function* () {
  const user = yield* loadUser(id)
  return ok(user)
})

Wrapping throwers

import { fromThrowable, fromPromise, fromAsyncThrowable, retry } from '@thourum/fault'
fromThrowable(JSON.parse, (e) => Fault.from(e as Error).withTag('PARSE_ERROR'))
fromPromise(fetch(url), (e) => Fault.from(e as Error).withTag('NETWORK_ERROR'))
fromAsyncThrowable(async (id) => db.find(id), (e) => Fault.from(e as Error).withTag('DATABASE_ERROR'))
retry(() => fetchThing(), { times: 3, delayMs: 200, when: (f) => f.tag === 'NETWORK_ERROR' })

RetryOptions: times (total attempts, required), delayMs? (default 0), when?: (error: E) => boolean (default always). fn must return ResultAsync; throws from fn or when become Err(Fault) with the thrown value as cause and stop retrying.

import { safeFetch, safeFetchJSON } from '@thourum/fault/fetch' // ResultAsync<Response, Fault> / ResultAsync<T, Fault>
import { safeZodParse } from '@thourum/fault/zod'         // Result<infer, Fault>
import { safeDb } from '@thourum/fault/drizzle'           // ResultAsync<T, Fault>; query/params in metadata
import { parsePgError } from '@thourum/fault/pg'          // Fault; unknown codes retain driver message
import { safeJsonParse, safeEnv } from '@thourum/fault/std' // Result<T, Fault>

Capturing

Set once at app start. Call .capture() only at the edge (HTTP handler, job runner, CLI main). Never in library or service code. toJSON() is the payload.

Fault.onCapture = (f) => Sentry.captureException(f, { extra: f.toJSON() })

await charge(id).orInspect((f) => f.capture())

Anti-patterns

  • Throwing inside a Result-returning function. Return err(...).
  • try / catch around Result code. The Result is the error channel.
  • if (r.isErr()) ladders where andThen / andCheck read better.
  • _unsafeUnwrap / _unsafeUnwrapErr outside tests. They exist on Result only and throw.
  • catch (e: unknown) then instanceof to branch. Branch on fault.tag.
  • async function foo(): Promise<Result<T, Fault>>. Return ResultAsync.
  • .capture() in every layer. Once, at the edge.

Template

export function chargeUser(id: string, cents: number): ResultAsync<Receipt, Fault> {
  if (cents <= 0) return errAsync(ServiceError('VALIDATION_ERROR', 'amount must be positive'))
  return loadUser(id)
    .andThen((user) => debit(user, cents))
    .map(toReceipt)
}