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

  1. Function executes and returns raw result (T)
  2. parseResult transforms the result (TTOut)
  3. onSuccess receives the transformed result (TOut)
  4. onSettled receives 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.

Previous
Types