Core API
safe.wrapAsync
Wraps an asynchronous function to return Promise<SafeResult> instead of throwing. Automatically preserves function parameter types. Supports automatic retry with configurable backoff.
Signatures
safe.wrapAsync<TArgs, T>(fn: (...args: TArgs) => Promise<T>): (...args: TArgs) => Promise<SafeResult<T, Error>>
safe.wrapAsync<TArgs, T>(fn: (...args: TArgs) => Promise<T>, hooks: SafeAsyncHooks<T, Error, TArgs>): (...args: TArgs) => Promise<SafeResult<T, Error>>
safe.wrapAsync<TArgs, T, E>(fn: (...args: TArgs) => Promise<T>, parseError: (e: unknown) => NonFalsy<E>): (...args: TArgs) => Promise<SafeResult<T, E>>
safe.wrapAsync<TArgs, T, E>(fn: (...args: TArgs) => Promise<T>, parseError: (e: unknown) => NonFalsy<E>, hooks: SafeAsyncHooks<T, E, TArgs> & { defaultError: E }): (...args: TArgs) => Promise<SafeResult<T, E>>
Error normalization
When no parseError is provided, non-Error thrown values are automatically normalized to Error instances via new Error(String(e)). The original thrown value is preserved as error.cause.
Basic usage
// Setup: an async function that can throw
const fetchUser = async (id: number) => {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error(`User ${id} not found`)
return response.json() as Promise<User>
}
// Wrap it
const safeFetchUser = safe.wrapAsync(fetchUser)
const [user, error] = await safeFetchUser(42) // Types inferred automatically!
if (error) {
console.error('Fetch failed:', error.message)
return
}
console.log(user.name) // user is typed as User
With error mapping
type ApiError = {
statusCode: number
message: string
endpoint: string
}
const safeFetchUser = safe.wrapAsync(
fetchUser,
(e): ApiError => ({
statusCode:
e instanceof Error && e.message.includes('not found') ? 404 : 500,
message: e instanceof Error ? e.message : 'Unknown error',
endpoint: '/api/users',
})
)
const [user, error] = await safeFetchUser(42)
if (error) {
// error is typed as ApiError
if (error.statusCode === 404) {
showNotFound()
} else {
showServerError(error.message)
}
}
With hooks
For safe.wrapAsync, the hook context contains the original arguments:
const safeFetchUser = safe.wrapAsync(fetchUser, {
onSuccess: (user, [id]) => {
console.log(`Fetched user ${id}:`, user.name)
cache.set(`user:${id}`, user)
},
onError: (error, [id]) => {
console.error(`Failed to fetch user ${id}:`, error.message)
metrics.increment('user_fetch_error')
},
})
await safeFetchUser(42) // logs: "Fetched user 42: John"
await safeFetchUser(999) // logs: "Failed to fetch user 999: User 999 not found"
With error mapping and hooks
const safeFetchUser = safe.wrapAsync(
fetchUser,
(e): ApiError => ({
statusCode: 500,
message: e instanceof Error ? e.message : 'Unknown error',
endpoint: '/api/users',
}),
{
onSuccess: (user, [id]) => {
analytics.track('user_fetched', { userId: id })
logger.info(`User ${id} fetched successfully`)
},
onError: (error, [id]) => {
// error is typed as ApiError here
analytics.track('user_fetch_failed', {
userId: id,
statusCode: error.statusCode,
})
logger.error(`Failed to fetch user ${id}`, {
endpoint: error.endpoint,
message: error.message,
})
},
}
)
With retry
const safeFetchWithRetry = safe.wrapAsync(fetchUser, {
retry: { times: 3, waitBefore: (attempt) => attempt * 1000 },
onRetry: (error, attempt, [id]) => {
console.log(`Retry ${attempt} for user ${id}: ${error.message}`)
},
})
const [user, error] = await safeFetchWithRetry(42)
Each call gets its own independent retry attempts. See Retry support for full details.