Skip to main content

getting started

Install @gentleduck/error and build your first error kit, from a one-code registry to a typed, secret-safe error class.

Install

bun add @gentleduck/error

npm install @gentleduck/error

npm install @gentleduck/error

typescript is an optional peer dependency (>=5.0.0). The package has no runtime dependencies at all. It works in plain JavaScript too, you simply lose the type inference described below.


Step 1: a registry with one code

Every kit starts from a plain object literal mapping string codes to HTTP status numbers.

// errors.ts
import { createErrorKit } from '@gentleduck/error'

const REGISTRY = {
  NOT_FOUND: 404,
} as const satisfies Record<string, number>

const kit = createErrorKit('AppError', REGISTRY)

export const AppError = kit.ErrorClass
export const throwAppError = kit.throwError
throwAppError('NOT_FOUND')
// Uncaught AppError: NOT_FOUND

satisfies Record<string, number> is the load-bearing half of that line: every code already resolves to a number-compatible type (detail/fault return a branded number; a bare code is a plain number), and satisfies checks that without erasing those specific types the way an : Record<string, number> annotation would. as const on top is good practice for a bare code's status: it keeps 404 a literal 404 instead of widening to number, but the compile-time argument checking later in this walkthrough comes from detail/fault's own branded return types, not from as const.

Step 2: a code that carries metadata

NOT_FOUND above takes no arguments. Calling throwAppError('NOT_FOUND', { anything: true }) is a compile error. Most real codes carry something. Wrap the status in detail and give it a type parameter:

import { createErrorKit, detail } from '@gentleduck/error'

const REGISTRY = {
  NOT_FOUND: 404,
  RATE_LIMITED: detail<{ retryAfter: number }>(429),
} as const satisfies Record<string, number>

const kit = createErrorKit('AppError', REGISTRY)
export const AppError = kit.ErrorClass
export const throwAppError = kit.throwError
throwAppError('RATE_LIMITED', { retryAfter: 60 })
// the second argument is now required, and its shape is checked:

throwAppError('RATE_LIMITED')
// Error: Expected 2 arguments, but got 1.

throwAppError('RATE_LIMITED', { retryAfter: 'soon' })
// Error: Type 'string' is not assignable to type 'number'.

Step 3: a code an adapter can raise

fault is the same as detail, for a code that represents your own storage/adapter layer failing rather than a caller mistake or a business rule. It behaves identically at runtime, and the distinction exists so you can select on it later (see Branded types).

import { createErrorKit, detail, fault } from '@gentleduck/error'

const REGISTRY = {
  NOT_FOUND: 404,
  RATE_LIMITED: detail<{ retryAfter: number }>(429),
  STORAGE_FAILED: fault<{ cause?: string }>(500),
} as const satisfies Record<string, number>

const kit = createErrorKit('AppError', REGISTRY)

export const AppError = kit.ErrorClass
export const throwAppError = kit.throwError
export const asAppError = kit.asError
export const hasAppErrorCode = kit.hasErrorCode

This exports AppError as a value only, enough for everything on this page. Once you want to write a function signature like err: AppError or code: AppError.Code, see the declaration merge every framework guide uses, in API reference under ErrorClass, to make AppError work as a type too.

Step 4: catching and responding

try {
  await handler(req)
} catch (err) {
  if (err instanceof AppError) {
    res.status(err.status).json(err.toJSON())
    return
  }
  // Not one of ours: wrap it so callers downstream have one shape to handle.
  const wrapped = asAppError(err, 'STORAGE_FAILED', { cause: String(err) })
  res.status(wrapped.status).json(wrapped.toJSON())
}
{
  "ok": false,
  "error": {
    "code": "RATE_LIMITED",
    "status": 429,
    "retryAfter": 60
  }
}

Any key in meta that looks like a secret (token, password, hash, and more) is stripped before this JSON is produced. See Secret redaction for the exact rule.


Where to go next