Skip to main content

wrapping unknown errors

Use asError and rethrowError at I/O boundaries to give every catch site one error type to handle, with the original failure preserved on .cause.

The boundary problem

A catch block receives unknown: a driver throw, a TypeError from a bug, a string someone threw, or an instance of your own kit rethrown from lower in the call stack. Handling all four shapes at every catch site does not scale. asError/rethrowError collapse them to one shape: either the value already was this kit's error (passed through unchanged), or it becomes one, wrapping the original.

kit.asError<C>(error: unknown, code: C, ...args: ErrorKit.Args<R, C>): KitError<R>
kit.rethrowError<C>(error: unknown, code: C, ...args: ErrorKit.Args<R, C>): never
try {
  await driver.query(sql)
} catch (err) {
  kit.rethrowError(err, 'STORAGE_FAILED', { cause: String(err) })
}

If driver.query threw a plain Error (a Postgres driver error, say), this wraps it as an AppError('STORAGE_FAILED', ...) with the original on .cause. If it threw (or rethrew) an AppError already (from a nested call that itself used rethrowError), that instance passes through unchanged, code and all. This is what makes it safe to call at every layer of a call stack without stacking wrappers: only the innermost, most specific rethrowError call actually sets the code.


Reading the original failure back out

try {
  kit.rethrowError(driverErr, 'STORAGE_FAILED', { cause: String(driverErr) })
} catch (err) {
  if (err instanceof AppError) {
    logger.error('storage failure', { code: err.code, original: err.cause })
  }
}

.cause is a standard Error property (super(code) inside the kit does not set it; asError assigns typed.cause = error itself). It is not included in toJSON()'s output. See Secret redaction for why that split exists: cause is the full-fidelity channel for your own logs, toJSON() is the scrubbed channel for a client.

Putting a string summary of the cause into meta (as { cause: String(err) } above) is a judgment call, not a requirement. Do it when you want that summary to reach a client's response body (subject to the same secret-stripping every other meta field gets), and skip it when .cause alone is enough for your logs.


A top-level catch-all

The same pattern closes the loop at the outermost boundary of a process: an HTTP framework's final error handler, or an unhandledRejection/uncaughtException listener, where you want a single normalized shape no matter what reached that point:

process.on('unhandledRejection', (reason) => {
  const err = kit.asError(reason, 'STORAGE_FAILED')
  logger.fatal('unhandled rejection', { code: err.code, cause: err.cause })
})

Next