Reference
Hooks
All safe functions support optional hooks for executing side effects on success or error without affecting the return value.
Overview
Hooks are callbacks that run after the operation completes. They receive the result (or error) and the context (function arguments for wrap/wrapAsync, empty tuple for sync/async).
// Hooks for sync/async - context is always empty tuple []
safe.sync(() => operation(), {
onSuccess: (result, []) => {
/* result is the return value */
},
onError: (error, []) => {
/* error is the caught/mapped error */
},
})
// Hooks for wrap/wrapAsync - context is the arguments tuple
const safeOp = safe.wrap(
(a: number, b: string, c: boolean) => operation(a, b, c),
{
onSuccess: (result, [a, b, c]) => {
// a: number, b: string, c: boolean - fully typed!
},
onError: (error, [a, b, c]) => {
// Same context available for error logging
},
}
)
Context differences
sync and async
The context is always an empty tuple []:
safe.sync(() => doWork(), {
onSuccess: (result, []) => console.log('Done:', result),
onError: (error, []) => console.error('Failed:', error),
})
await safe.async(() => fetchData(), {
onSuccess: (data, []) => cache.set('data', data),
onError: (error, []) => reportToSentry(error),
})
wrap and wrapAsync
The context contains the original arguments passed to the wrapped function:
const safeDivide = safe.wrap(
(a: number, b: number) => a / b,
{
onSuccess: (result, [a, b]) => {
console.log(`${a} / ${b} = ${result}`)
},
onError: (error, [a, b]) => {
console.error(`Failed: ${a} / ${b}`)
},
}
)
const safeFetch = safe.wrapAsync(
async (url: string, options?: RequestInit) => {
const res = await fetch(url, options)
return res.json()
},
{
onSuccess: (data, [url, options]) => {
console.log(`Fetched ${url}`)
},
onError: (error, [url, options]) => {
console.error(`Failed to fetch ${url}`)
},
}
)
Async-specific hooks
The onRetry hook is available for safe.async and safe.wrapAsync:
await safe.async(() => unstableApi(), {
retry: { times: 3 },
onRetry: (error, attempt, []) => {
console.log(`Attempt ${attempt} failed: ${error.message}`)
},
onSuccess: (data, []) => console.log('Eventually succeeded'),
onError: (error, []) => console.error('All retries exhausted'),
})
The onRetry hook is called before each retry, not after the final failure. onError is only called after all retries are exhausted.
Common use cases
- Logging and analytics — Track success/failure rates
- Error reporting — Send errors to Sentry, DataDog, etc.
- Metrics collection — Increment counters, record timings
- Audit trails — Log who did what and when
- Caching — Store successful results in a cache
const safeGetUser = safe.wrapAsync(fetchUser, {
onSuccess: (user, [id]) => {
metrics.increment('user.fetch.success')
cache.set(`user:${id}`, user)
},
onError: (error, [id]) => {
metrics.increment('user.fetch.error')
Sentry.captureException(error, { extra: { userId: id } })
},
})
parseResult
The parseResult option transforms the successful result before it reaches hooks or the return value. It is part of the hooks object but behaves differently — it changes the result type.
const [length, error] = safe.sync(
() => 'hello world',
{
parseResult: (raw) => raw.length,
onSuccess: (result, []) => {
// result is number (the transformed type)
console.log('Length:', result)
},
}
)
// length is typed as number
Execution order
- Function executes and returns raw result (
T) parseResulttransforms the result (T→TOut)onSuccessreceives the transformed result (TOut)onSettledreceives the transformed result (TOut)
Error handling
If parseResult throws, the error is routed through the standard error path — just like any error thrown by the wrapped function. It goes through parseError (if provided), triggers onError and onSettled, and returns [null, error].
const [data, error] = safe.sync(
() => 42,
(e) => ({ code: 'TRANSFORM_FAILED', message: String(e) }),
{
parseResult: (n) => {
throw new Error('transform failed')
},
onError: (err) => {
// err is { code: 'TRANSFORM_FAILED', message: 'Error: transform failed' }
},
}
)
// data is null, error is { code: 'TRANSFORM_FAILED', message: '...' }
parseResult vs hooks
Unlike hooks, parseResult does affect the return value — it transforms the result type. Hooks (onSuccess, onError, onSettled) are fire-and-forget side effects that cannot modify the SafeResult tuple.
onHookError
By default, hook errors are silently swallowed — a throwing hook never crashes the application or alters the SafeResult. The optional onHookError callback lets you observe these failures for debugging.
const [data, error] = safe.sync(() => fetchData(), {
onSuccess: (result) => {
logger.lgo(result) // typo — throws TypeError
},
onHookError: (err, hookName) => {
// err = TypeError, hookName = 'onSuccess'
console.warn(`Hook "${hookName}" failed:`, err)
},
})
// data is still returned correctly
The callback receives:
error— The value thrown by the hook (unknown)hookName— Which hook threw:'onSuccess','onError','onSettled','onRetry', or'parseError'
parseError safety
The parseError function is also wrapped in try/catch. If it throws, the error is reported via onHookError with hookName 'parseError', and the defaultError value is returned as the error result. If no defaultError is provided, the original caught error is normalized to an Error instance.
Safety guarantee
If onHookError itself throws, that error is also silently swallowed. The SafeResult is never affected by hook failures.
With createSafe
Set a factory-level onHookError to catch hook errors across all operations:
const appSafe = createSafe({
parseError: (e) => String(e),
defaultError: 'unknown error',
onSuccess: (result) => {
logger.log(result) // might throw
},
onHookError: (err, hookName) => {
monitoring.trackHookFailure(hookName, err)
},
})
Per-call onHookError overrides the factory-level callback (it does not merge):
appSafe.sync(() => riskyOperation(), {
onHookError: (err, hookName) => {
// This replaces the factory onHookError for this call
customLogger.warn(`${hookName} failed`, err)
},
})
Not a hook chain
Unlike onSuccess/onError/onSettled which merge (default runs first, then per-call), onHookError uses simple override semantics — per-call replaces factory-level. This is because onHookError is an error handler, not a side-effect hook.