Core API
createSafe
The recommended way to use @cometloop/safe. Creates a pre-configured safe instance so your call sites stay clean and minimal — just a normal function call, no extra configuration.
When to use createSafe
createSafe is the best way to use this library. The goal is simple: move all error handling configuration out of your call sites so they read like normal function calls.
- You want call sites that are clean and minimal — no inline
parseError, no options objects - You want consistent error mapping across multiple operations without repeating yourself
- You need default logging/analytics hooks that run automatically
- You want to wrap functions once and call them everywhere like any other function
Signature
function createSafe<E, TResult = never>(
config: CreateSafeConfig<E, TResult>
): SafeInstance<E, TResult>
type CreateSafeConfig<E, TResult = never> = {
parseError: (e: unknown) => NonFalsy<E> // Required: transforms caught errors
defaultError: E // Required: fallback when parseError throws
parseResult?: (result: unknown) => TResult // Optional: transforms successful results
onSuccess?: (result: unknown) => void // Optional: called on every success
onError?: (error: E) => void // Optional: called on every error
onSettled?: (result: unknown, error: E | null) => void // Optional: called after success or error
onRetry?: (error: E, attempt: number) => void // Optional: called before each retry
retry?: RetryConfig // Optional: default retry config (async only)
abortAfter?: number // Optional: default timeout (async only)
onHookError?: (error: unknown, hookName: string) => void // Optional: called when a hook throws
}
Basic usage
Configure once, then every call site is just a function call:
import { createSafe } from '@cometloop/safe'
type AppError = {
code: string
message: string
}
// All the configuration lives here — once
const appSafe = createSafe({
parseError: (e): AppError => ({
code: 'UNKNOWN_ERROR',
message: e instanceof Error ? e.message : 'An unknown error occurred',
}),
defaultError: {
code: 'UNKNOWN_ERROR',
message: 'An unknown error occurred',
},
})
// Wrap your functions
const safeJsonParse = appSafe.wrap(JSON.parse)
const safeFetchUser = appSafe.wrapAsync(fetchUser)
// Call sites are clean — just like calling a normal function
const [data, error] = safeJsonParse(jsonString)
const [user, err] = await safeFetchUser(id)
// error and err are fully typed as AppError
With default hooks
Global logging for all operations:
const loggingSafe = createSafe({
parseError: (e): AppError => ({
code: 'ERROR',
message: e instanceof Error ? e.message : String(e),
}),
defaultError: {
code: 'ERROR',
message: 'An unknown error occurred',
},
onSuccess: (result) => {
console.log('Operation succeeded:', result)
analytics.track('operation_success')
},
onError: (error) => {
console.error('Operation failed:', error.code)
analytics.track('operation_failed', { code: error.code })
Sentry.captureException(error)
},
})
// Default hooks are called automatically
loggingSafe.sync(() => processData()) // logs success or error
Per-call hooks
Override or extend default behavior with per-call hooks. Per-call hooks run after default hooks:
const [result, error] = loggingSafe.sync(() => riskyOperation(), {
// Per-call hooks run AFTER default hooks
onSuccess: (result, []) => {
cache.set('result', result)
},
onError: (error, []) => {
showUserNotification(error.message)
},
})
Wrapping functions
const apiSafe = createSafe({
parseError: (e) => ({
type: 'API_ERROR' as const,
status: 500,
message: e instanceof Error ? e.message : 'Request failed',
}),
defaultError: { type: 'API_ERROR' as const, status: 500, message: 'Request failed' },
onError: (error) => metrics.increment('api_error', { type: error.type }),
})
// Wrap functions - they inherit the configured parseError
const safeFetch = apiSafe.wrapAsync(async (url: string) => {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
const [data, error] = await safeFetch('/api/users')
// error is typed as { type: 'API_ERROR'; status: number; message: string }
With parseResult
Set a factory-level parseResult to transform all successful results:
import { createSafe } from '@cometloop/safe'
import { z } from 'zod'
const schema = z.object({ id: z.number(), name: z.string() })
const validatedSafe = createSafe({
parseError: (e) => ({
code: 'ERROR',
message: e instanceof Error ? e.message : String(e),
}),
defaultError: { code: 'ERROR', message: 'Unknown error' },
parseResult: (result) => schema.parse(result),
})
// All methods validate results through the schema
const [user, error] = validatedSafe.sync(() => JSON.parse(data))
// user is typed as { id: number; name: string }
Per-call parseResult overrides the factory default:
const [raw, error] = validatedSafe.sync(() => fetchData(), {
parseResult: (result) => result, // bypass factory validation
})
Multiple instances
Create different instances for different contexts:
const dbSafe = createSafe({
parseError: (e) => ({
type: 'DB_ERROR' as const,
query: 'unknown',
message: e instanceof Error ? e.message : String(e),
}),
defaultError: { type: 'DB_ERROR' as const, query: 'unknown', message: 'Database error' },
})
const authSafe = createSafe({
parseError: (e) => ({
type: 'AUTH_ERROR' as const,
code: e instanceof TokenExpiredError ? 'EXPIRED' : 'INVALID',
message: e instanceof Error ? e.message : 'Authentication failed',
}),
defaultError: { type: 'AUTH_ERROR' as const, code: 'INVALID', message: 'Authentication failed' },
})
// Each instance has its own error type
const [user, dbError] = await dbSafe.async(() => db.user.findById(id))
const [token, authError] = authSafe.sync(() => verifyToken(jwt))
// dbError is { type: 'DB_ERROR'; ... }
// authError is { type: 'AUTH_ERROR'; ... }
Hook error visibility
Set a factory-level onHookError to catch hook errors across all operations:
const appSafe = createSafe({
parseError: (e) => String(e),
defaultError: 'unknown error',
onSuccess: (result) => {
externalLogger.log(result) // might throw
},
onHookError: (err, hookName) => {
// Called when onSuccess (or any hook) throws
monitoring.trackHookFailure(hookName, err)
},
})
// All operations get hook error visibility
appSafe.sync(() => computeValue())
await appSafe.async(() => fetchData())
Per-call onHookError overrides the factory-level callback:
appSafe.sync(() => riskyOperation(), {
onHookError: (err, hookName) => {
// Replaces the factory onHookError for this call only
customLogger.warn(`${hookName} failed`, err)
},
})
See Hooks — onHookError for more details.
Hook execution order
When both default hooks (from config) and per-call hooks are provided:
- Default hook (from
createSafeconfig) - Per-call hook (from method call)
const appSafe = createSafe({
parseError: (e) => String(e),
defaultError: 'unknown error',
onSuccess: () => console.log('1. Default hook'),
})
appSafe.sync(() => 'result', {
onSuccess: () => console.log('2. Per-call hook'),
})
// Output:
// 1. Default hook
// 2. Per-call hook
Type inference
The error type E is automatically inferred from the parseError return type:
// Error type is inferred as { code: string; message: string }
const appSafe = createSafe({
parseError: (e) => ({
code: 'ERR',
message: e instanceof Error ? e.message : 'Unknown',
}),
defaultError: { code: 'ERR', message: 'Unknown' },
})
// Per-call hooks receive the correctly typed error
appSafe.sync(
() => {
throw new Error('fail')
},
{
onError: (error) => {
// TypeScript knows: error.code is string, error.message is string
console.log(error.code, error.message)
},
}
)
SafeInstance type
The returned instance has sync, async, wrap, wrapAsync, all, and allSettled methods — all pre-configured with the error type. The all and allSettled methods accept raw async functions (not pre-wrapped Promise<SafeResult> entries). See Types for the full SafeInstance<E> definition.