catch-chain
v1.1.0
Published
Fluent, composable, type-safe error matching and recovery for promises
Readme
catch-chain
Fluent error matching and recovery for JavaScript promises.
await fetchUser(id)
.catch(onErr(isNotFound).return(null))
.catch(onErr(isRateLimited).do(logWarning).throw())
.catch(onAnyErr.throw(err => new AppError(`Unexpected: ${err.message}`)))No more catch blocks with nested if/else chains. Define what to match, what to do, and how to recover - in a readable, composable chain.
Install
npm install catch-chainWhy
A typical promise .catch() quickly turns into this:
await fetchUser(id).catch(err => {
if (err instanceof NotFoundError) {
return null
}
if (err instanceof RateLimitError) {
logWarning(err)
throw new RetryableError()
}
reportToSentry(err)
throw new AppError('Unexpected failure')
})Every handler mixes matching, side effects, and recovery in one block. As cases grow, readability suffers. catch-chain separates these concerns:
await fetchUser(id)
.catch(onErr(isNotFound).return(null))
.catch(onErr(isRateLimited).do(logWarning).throw(new RetryableError()))
.catch(onAnyErr.do(reportToSentry).throw(err => new AppError(`Unexpected: ${err.message}`)))- Each
.catch()handles one case. - Unmatched errors pass through to the next handler - just like multi-catch in Java or pattern matching in Rust.
API
onErr(matcher)
Creates a chain that only activates when matcher returns true for the rejected value.
import { onErr } from 'catch-chain'
const isNotFound: ErrorMatcher<NotFoundError> =
(reason): reason is NotFoundError => reason instanceof NotFoundError
await fetchUser(id).catch(onErr(isNotFound).return(null))A matcher is a type guard - it receives unknown and narrows to a specific error type. This gives you full type safety in .do() and .return()/.throw() callbacks.
onAnyErr
A pre-built chain that matches any Error instance. Shorthand for onErr(reason => reason instanceof Error).
import { onAnyErr } from 'catch-chain'
await riskyOperation().catch(onAnyErr.return('fallback'))Non-Error rejections (strings, objects, etc.) pass through unhandled.
.do(effect)
[Pipe operation] Run a side effect when the error matches. Returns the chain for further chaining. The effect can be sync or async.
// Sync effect - log and recover
.catch(onAnyErr
.do(err => console.error(err))
.return(defaults))
// Async effect
.catch(onAnyErr
.do(async err => await alertOncall(err))
.throw(new ServiceUnavailableError()))
// Multiple effects, executed in order
.catch(onAnyErr
.do(err => metrics.increment('errors'))
.do(logger.error)
.throw()).return(value)
[Terminal operation] Recover from the error by resolving the promise with value. If the error doesn't match, it's re-thrown unchanged.
// Static value
.catch(onErr(isNotFound).return(null))
// Sync mapper - derive a value from the error
.catch(onErr(isNotFound).return(err => defaultItemFor(err.resourceId)))
// Async mapper - derive a value from the error
.catch(onErr(isNotFound).return(async err => await fetchFallback(err.resourceId)))When value is a function, it's called with the matched error and its return value is used.
.throw(error?)
[Terminal operation] Re-throw the original error, replace it, or map it. If the error doesn't match, the original is re-thrown unchanged.
// No args - re-throw original (useful after side effects)
.catch(onErr(isDatabaseError)
.do(err => logger.error(err))
.throw())
// Constant - replace with a different error
.catch(onErr(isDatabaseError)
.throw(new AppError('Something went wrong')))
// Sync mapper - transform the error
.catch(onErr(isDatabaseError)
.throw(err => new AppError(`DB failed: ${err.code}`)))
// Async mapper - transform the error
.catch(onErr(isDatabaseError)
.throw(async err => new AppError(`DB failed: ${await resolveCode(err)}`)))Effects and producers should not throw
Effects (.do()) and value/error producers (.return() / .throw() callbacks) are expected to not throw or reject.
The library does not catch errors from these functions - if one throws, the thrown error replaces the original and propagates to the caller.
Patterns
Multi-catch
Chain multiple .catch() calls - each handles one error type. Unmatched errors fall through.
const user = await fetchUser(id)
.catch(onErr(isNotFound).return(null))
.catch(onErr(isTimeout).throw(new RetryableError()))
.catch(onAnyErr.throw(new AppError('Unexpected')))Log and re-throw
Use .do() for side effects, then .throw() to re-throw (same or different error).
await processPayment(order)
.catch(onAnyErr
.do(err => logger.error('Payment failed', { orderId: order.id, err }))
.throw(err => new PaymentError(err.message)))Graceful degradation
Return a default value when a non-critical operation fails.
const preferences = await loadPreferences(userId)
.catch(onAnyErr.return(DEFAULT_PREFERENCES))Custom matchers
A matcher is any function with the signature (reason: unknown) => reason is E. Build matchers that check properties, codes, or any condition.
const isConflict: ErrorMatcher<HttpError> =
(reason): reason is HttpError =>
reason instanceof HttpError && reason.status === 409
await saveDocument(doc)
.catch(onErr(isConflict).return(await mergeAndRetry(doc)))Effect-only handling
Use .do() with .throw() when you want to observe the error without changing the outcome.
await backgroundSync()
.catch(onAnyErr
.do(err => telemetry.record('sync_failed', err))
.throw())Types
All types are inferred automatically. You don't need to import anything beyond onErr and onAnyErr.
A custom matcher is a type guard with this shape:
type ErrorMatcher<E extends Error> = (reason: unknown) => reason is EThe .do(), .return(), and .throw() callbacks receive the narrowed type from your matcher - no manual annotations needed.
Design
- Match or pass through - unmatched errors propagate unchanged, enabling composable multi-catch chains
- Type-safe - matchers are type guards, so
.do(),.return(), and.throw()callbacks receive the narrowed error type - Zero dependencies - only uses native
Error,Promise, andinstanceof - Tiny - under 1.5 KB, ships ESM + CJS with full TypeScript declarations
