---
title: "Catalogs"
description: "Scale typed error and audit catalogs from one file to a monorepo: conventions, an npm packaging recipe, and type augmentation."
canonical_url: "https://www.evlog.dev/learn/catalogs"
---
# Catalogs

> Scale typed error and audit catalogs from one file to a monorepo: conventions, an npm packaging recipe, and type augmentation.

The catalog primitives (`defineError`, `defineErrorCatalog`, `defineAuditAction`, `defineAuditCatalog`) are the same regardless of project size. What changes is how you organise them. This page is the deep-dive: conventions, scaling recipes from one file to a published npm package, composition patterns, and the opt-in type augmentation.

<prompt :actions="["copy","cursor","claude"]" description="Set up typed error and audit catalogs in my app" icon="i-lucide-book-open">

Group errors and audit actions in typed catalogs to eliminate magic strings, get autocomplete on `code` everywhere, and ship them as npm packages in a monorepo.

- Use `defineErrorCatalog(prefix, map)` for error bundles, `defineAuditCatalog(prefix, map)` for audit bundles
- Use `defineError(code, options)` and `defineAuditAction(action, opts?)` for one-off factories that don't fit a catalog
- Convention: UPPER_SNAKE_CASE keys, lower.dot.case prefix, wire format is `${prefix}.${KEY}` (e.g. `billing.PAYMENT_DECLINED`, `billing.INVOICE_REFUND`)
- One catalog = one bounded context = one prefix = one file (e.g. `errors/billing.ts`, `audit/billing.ts`)
- Throw with `billingErrors.PAYMENT_DECLINED({ cause, internal })`, audit with `log.audit(billingAudit.INVOICE_REFUND({ actor, target }))`
- Use templated messages (`message: ({ id }) => \`User ${id} not found``) when params are dynamic and required
- Catalog defaults for `internal` are shallow-merged with call-site values (call-site wins)
- Add the opt-in `declare module 'evlog' { interface RegisteredErrorCatalogs { billing: typeof billingErrors } }` augmentation to surface autocomplete on `createError({ code })`, `parseError(err).code`, and `throwError(code)` everywhere
- Scale by sharding: single file → folder per domain → sub-prefixes (`billing.payment`) → one npm package per bounded context (each owns its prefix, no conflicts possible)
- Each shared package ships its own `declare module 'evlog'` block in `src/index.ts` so the type augmentation propagates to consumers via the published `.d.ts`
- Compare on `factory.code` in tests instead of string literals so renames are TS errors, not silent breaks: `expect(err.code).toBe(billingErrors.PAYMENT_DECLINED.code)`
- Never override `code` at the call site (the catalog defines the code identity); never put `declare module` blocks in test files (they leak into the main type-checker)

Docs: [https://www.evlog.dev/learn/catalogs](https://www.evlog.dev/learn/catalogs)

</prompt>

<tip>

If you haven't yet, start with [Structured Errors → Error Catalogs](https://www.evlog.dev/learn/structured-errors#error-catalogs) and [Audit → defineAuditCatalog](https://www.evlog.dev/use-cases/audit/recording#defineauditcatalog) for the basics. This page assumes you've used the primitives at least once.

</tip>

## Conventions

A single set of conventions covers both error and audit catalogs.

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      Convention
    </th>
    
    <th>
      Example
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <strong>
        Catalog key
      </strong>
    </td>
    
    <td>
      <code>
        UPPER_SNAKE_CASE
      </code>
      
       (enum-style, scales to hundreds of entries)
    </td>
    
    <td>
      <code>
        PAYMENT_DECLINED
      </code>
      
      , <code>
        INVOICE_REFUND
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Prefix
      </strong>
    </td>
    
    <td>
      <code>
        lower.dot.case
      </code>
      
      , can be hierarchical
    </td>
    
    <td>
      <code>
        'billing'
      </code>
      
      , <code>
        'billing.payment'
      </code>
      
      , <code>
        'auth.session'
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Wire format
      </strong>
    </td>
    
    <td>
      <code>
        ${prefix}.${KEY}
      </code>
      
       (preserved casing)
    </td>
    
    <td>
      <code>
        billing.PAYMENT_DECLINED
      </code>
      
      , <code>
        auth.INVALID_TOKEN
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        One catalog =
      </strong>
    </td>
    
    <td>
      One bounded context, one prefix, one file
    </td>
    
    <td>
      <code>
        errors/billing.ts
      </code>
      
      , <code>
        audit/billing.ts
      </code>
    </td>
  </tr>
</tbody>
</table>

The wire format ends up in HTTP responses, wide events, drains, and dashboards. Stick to it across services so a `code` from one service is recognisable in another.

## Scaling story

The same primitives cover four scales without API change.

### 1 file: small repo

One `errors.ts`, one `audit.ts`. Done.

<code-group>

```typescript [src/errors.ts]
import { defineErrorCatalog } from 'evlog'

export const errors = defineErrorCatalog('app', {
  USER_NOT_FOUND: { status: 404, message: 'User not found' },
  FORBIDDEN: { status: 403, message: 'Forbidden' },
  VALIDATION_FAILED: {
    status: 400,
    message: ({ field }: { field: string }) => `Invalid ${field}`,
  },
})
```

```typescript [src/audit.ts]
import { defineAuditCatalog } from 'evlog'

export const audit = defineAuditCatalog('app', {
  USER_LOGIN: { target: 'user', severity: 'medium' },
  USER_DELETE: { target: 'user', severity: 'high', requiresChanges: true, requiresReason: true },
})
```

</code-group>

### 1 folder, 1 file per domain: medium repo

Group by bounded context. One file per domain in `src/errors/` and `src/audit/`. An `index.ts` re-exports for ergonomic imports and centralises the type augmentation.

```text
src/
├── errors/
│   ├── billing.ts        → billingErrors (prefix: 'billing')
│   ├── auth.ts           → authErrors    (prefix: 'auth')
│   ├── user.ts           → userErrors    (prefix: 'user')
│   └── index.ts          → re-export + declare module
├── audit/
│   ├── billing.ts        → billingAudit
│   ├── auth.ts           → authAudit
│   └── index.ts
```

```typescript [src/errors/index.ts]
import type { authErrors } from './auth'
import type { billingErrors } from './billing'
import type { userErrors } from './user'

export { authErrors } from './auth'
export { billingErrors } from './billing'
export { userErrors } from './user'

declare module 'evlog' {
  interface RegisteredErrorCatalogs {
    auth: typeof authErrors
    billing: typeof billingErrors
    user: typeof userErrors
  }
}
```

The augmentation is purely type-level: there is no `init` step, no runtime registration. Importing `~/errors` once anywhere in your app is enough for TypeScript to pick up the merged type.

### Sub-prefixes: very large repo

Hierarchical prefixes (`billing.payment`, `billing.subscription`, `auth.session`) keep keys short while preserving namespace clarity. One catalog per sub-domain.

```text
src/features/
├── billing/
│   └── errors/
│       ├── payment.ts        → billingPaymentErrors (prefix: 'billing.payment')
│       ├── subscription.ts   → billingSubscriptionErrors
│       └── invoice.ts        → billingInvoiceErrors
├── auth/
│   └── errors/
│       ├── session.ts        → authSessionErrors (prefix: 'auth.session')
│       ├── oauth.ts          → authOAuthErrors
│       └── mfa.ts            → authMfaErrors
```

```typescript [src/features/billing/errors/payment.ts]
import { defineErrorCatalog } from 'evlog'

export const billingPaymentErrors = defineErrorCatalog('billing.payment', {
  DECLINED: { status: 402, message: 'Card declined' },
  INSUFFICIENT_FUNDS: { status: 402, message: 'Insufficient funds' },
  EXPIRED_CARD: { status: 402, message: 'Card expired' },
  CVV_MISMATCH: { status: 402, message: 'CVV mismatch' },
})
```

Wire codes become `billing.payment.DECLINED`, `billing.payment.INSUFFICIENT_FUNDS`, etc. The convention scales to hundreds of entries without collisions.

### npm packages: monorepo

In a monorepo, each bounded context can ship as its own npm package. Type augmentation propagates through the published `.d.ts`, so consumers get autocomplete just by `pnpm add @acme/errors-billing`.

```text
acme-monorepo/
├── packages/
│   ├── errors-billing/         → @acme/errors-billing
│   │   └── src/index.ts
│   ├── errors-auth/            → @acme/errors-auth
│   │   └── src/index.ts
│   └── audit-billing/          → @acme/audit-billing
│       └── src/index.ts
└── apps/
    ├── api/                    → imports + re-exports the catalogs
    └── worker/
```

## Publishing a catalog as an npm package

A catalog is just regular TypeScript that depends on `evlog` as a peer dep. Here is the minimal recipe.

### `package.json`

```json [packages/errors-billing/package.json]
{
  "name": "@acme/errors-billing",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "types": "./dist/index.d.ts"
    }
  },
  "peerDependencies": {
    "evlog": "^3.0.0"
  },
  "files": ["dist"]
}
```

### Source: catalog + augmentation in the same file

```typescript [packages/errors-billing/src/index.ts]
import { defineErrorCatalog } from 'evlog'

export const billingErrors = defineErrorCatalog('billing', {
  PAYMENT_DECLINED: {
    status: 402,
    message: 'Card declined',
    why: 'Issuer declined the charge',
    fix: 'Try a different payment method',
    link: 'https://docs.example.com/errors/billing.payment_declined',
  },
  INSUFFICIENT_FUNDS: {
    status: 402,
    message: ({ available, required }: { available: number, required: number }) =>
      `Insufficient funds: $${available}/$${required}`,
  },
  // ...
})

declare module 'evlog' {
  interface RegisteredErrorCatalogs {
    billing: typeof billingErrors
  }
}
```

The `declare module` block lives inside the source file so the bundler emits it into the `dist/index.d.ts`. Any consumer that imports from `@acme/errors-billing` gets the augmentation transitively, with no extra setup required on their side.

### Consumption

```typescript [apps/api/src/init.ts]
// Importing the package activates both the runtime catalog and the type augmentation.
import { billingErrors } from '@acme/errors-billing'
import { authErrors } from '@acme/errors-auth'

// Re-export from a central place so the rest of the app has one import path.
export { billingErrors, authErrors }
```

```typescript [apps/api/src/routes/checkout.post.ts]
import { billingErrors } from '~/init'

throw billingErrors.PAYMENT_DECLINED({ cause: stripeErr })
```

```typescript [Anywhere in the app — autocomplete works]
import { createError, parseError } from 'evlog'

throw createError({
  code: 'billing.PAYMENT_DECLINED', // ← autocomplete from the registered catalog
  message: 'Card declined',
  status: 402,
})

const err = parseError(caught)
if (err.code === 'billing.PAYMENT_DECLINED') retry()
//                ↑ TypeScript knows the union of all registered codes
```

<callout color="neutral" icon="i-lucide-package">

**Each shared package owns its prefix.** `@acme/errors-billing` owns `billing.*`, `@acme/errors-auth` owns `auth.*`. Conflicts are impossible by construction. Bumping a catalog to a new minor (adding entries) propagates to consumers via the regular semver upgrade path: no codegen, no migration step.

</callout>

## Composition patterns

### Mix catalogs and standalone factories

`defineError` and `defineErrorCatalog` produce identical call-site shapes. Use catalogs for grouped errors, `defineError` for one-offs (e.g. cross-cutting concerns like rate-limiting that don't belong to a specific domain).

```typescript [src/errors/index.ts]
import { defineError, defineErrorCatalog } from 'evlog'

export const billingErrors = defineErrorCatalog('billing', {
  PAYMENT_DECLINED: { status: 402, message: 'Card declined' },
})

export const rateLimited = defineError('app.RATE_LIMITED', {
  status: 429,
  message: ({ retryAfter }: { retryAfter: number }) =>
    `Rate limited: retry in ${retryAfter}s`,
})

// Both look identical at the call site:
throw billingErrors.PAYMENT_DECLINED()
throw rateLimited({ retryAfter: 30 })
```

### Re-export from one entry per domain

If a feature ships errors and audits together, give it a single re-export module so call sites only import once.

```typescript [src/features/billing/index.ts]
export { billingErrors } from './errors/billing'
export { billingAudit } from './audit/billing'
```

```typescript [services/refund.ts]
import { billingErrors, billingAudit } from '~/features/billing'

if (!cart.items.length) throw billingErrors.CART_EMPTY()

log.audit(billingAudit.INVOICE_REFUND({ actor, target: { id: 'inv_889' } }))
```

### Override catalog defaults at the call site

Every entry's defaults (`message`, `status`, `why`, `fix`, `link`, `internal`) are overridable per call. `internal` is shallow-merged (call-site wins on conflict).

```typescript
// Catalog default:
// message: 'Card declined'
// internal: { category: 'gateway' }

throw billingErrors.PAYMENT_DECLINED({
  message: 'Custom message for this specific call',
  internal: { stripeRef: 'ch_x', category: 'gateway-overridden' },
  cause: stripeErr,
})

// Resulting EvlogError:
// - message: 'Custom message for this specific call' (override)
// - status: 402 (catalog default)
// - why: 'Issuer declined the charge' (catalog default)
// - internal: { category: 'gateway-overridden', stripeRef: 'ch_x' }
```

## Type augmentation: deep dive

The opt-in `declare module 'evlog'` block is what surfaces autocomplete on `createError({ code })`, `parseError(err).code`, and the typed `ErrorCode` / `AuditAction` exports.

### Where to put the augmentation

<table>
<thead>
  <tr>
    <th>
      Repo shape
    </th>
    
    <th>
      Recommended location
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Single file (<code>
        src/errors.ts
      </code>
      
      )
    </td>
    
    <td>
      At the bottom of the same file
    </td>
  </tr>
  
  <tr>
    <td>
      Folder (<code>
        src/errors/*.ts
      </code>
      
      )
    </td>
    
    <td>
      In <code>
        src/errors/index.ts
      </code>
      
       (centralised) or each catalog file (decentralised)
    </td>
  </tr>
  
  <tr>
    <td>
      npm package
    </td>
    
    <td>
      At the bottom of the package's main <code>
        src/index.ts
      </code>
      
       so it ships in the published <code>
        .d.ts
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Monorepo
    </td>
    
    <td>
      One augmentation per package, no central registry needed
    </td>
  </tr>
</tbody>
</table>

Both centralised and decentralised work, because TypeScript merges multiple `declare module 'evlog'` blocks across files automatically.

### How to add custom domains

Each augmentation key is the namespace name. Multiple catalogs sharing a prefix can either be merged into one key or split:

```typescript [Centralised — one key per package]
declare module 'evlog' {
  interface RegisteredErrorCatalogs {
    billing: typeof billingErrors
  }
}
```

```typescript [Decentralised — one key per sub-domain]
declare module 'evlog' {
  interface RegisteredErrorCatalogs {
    'billing.payment': typeof billingPaymentErrors
    'billing.subscription': typeof billingSubscriptionErrors
    'billing.invoice': typeof billingInvoiceErrors
  }
}
```

The `_codes` literal union is what produces the actual `ErrorCode` type. The keys themselves are arbitrary, choose what feels right for your structure.

### Verifying the augmentation

```typescript [Anywhere in the codebase]
import type { ErrorCode, AuditAction } from 'evlog'

// Hover the type in your IDE — should show the union of all registered codes.
type AllErrorCodes = ErrorCode
type AllAuditActions = AuditAction

// Compile-time check:
const validCode: ErrorCode = 'billing.PAYMENT_DECLINED' // OK
const invalidCode: ErrorCode = 'billing.NOPE' // ← TS error if catalog is registered
```

If autocomplete is empty, either no catalog is registered yet, or the augmentation file is not in the TypeScript program (check `tsconfig.json` includes).

## Common pitfalls

<warning>

**Don't put declare module blocks in test files.** Augmentations from test files leak into the type-checker for the rest of the codebase if the test files are included in the main `tsconfig.json`. Keep augmentations next to the catalog source, never inside `*.test.ts`.

</warning>

<warning>

**Avoid prefix collisions across packages.** If two packages augment the same `RegisteredErrorCatalogs` key (say both ship a `billing` catalog), TypeScript merges them silently and the runtime keeps the last-registered factory. Convention: one prefix per package, no overlap.

</warning>

<warning>

**Never override the code at the call site.** The catalog defines the code identity, and overriding it would break dashboards, alerts, and consumer code branching on `err.code`. The factory's call-site signature deliberately omits `code` from the overridable fields.

</warning>

<tip>

**Prefer factory.code over string comparisons in tests.** Both forms below are valid; the first survives renames (refactor-safe), the second doesn't.

```typescript
expect(err.code).toBe(billingErrors.PAYMENT_DECLINED.code) // ✓ refactor-safe
expect(err.code).toBe('billing.PAYMENT_DECLINED')          // ✗ string literal
```

</tip>

## API reference

<table>
<thead>
  <tr>
    <th>
      Symbol
    </th>
    
    <th>
      Kind
    </th>
    
    <th>
      Purpose
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        defineError(code, options)
      </code>
    </td>
    
    <td>
      factory
    </td>
    
    <td>
      Standalone single-error factory. No prefix derivation.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defineErrorCatalog(prefix, map)
      </code>
    </td>
    
    <td>
      factory
    </td>
    
    <td>
      Bundle of typed errors sharing a prefix.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defineAuditAction(action, opts?)
      </code>
    </td>
    
    <td>
      factory
    </td>
    
    <td>
      Standalone single-action audit factory.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defineAuditCatalog(prefix, map)
      </code>
    </td>
    
    <td>
      factory
    </td>
    
    <td>
      Bundle of typed audit actions sharing a prefix. Each entry accepts <code>
        target
      </code>
      
      , <code>
        description
      </code>
      
      , <code>
        severity
      </code>
      
      , <code>
        requiresChanges
      </code>
      
      , <code>
        requiresReason
      </code>
      
      , <code>
        redactPaths
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        AuditCatalogEntry
      </code>
    </td>
    
    <td>
      type
    </td>
    
    <td>
      Metadata shape for a single catalog entry (alias of <code>
        AuditActionDefinition
      </code>
      
      ).
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        AuditSeverity
      </code>
    </td>
    
    <td>
      type
    </td>
    
    <td>
      <code>
        'low' | 'medium' | 'high' | 'critical'
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        RegisteredErrorCatalogs
      </code>
    </td>
    
    <td>
      interface
    </td>
    
    <td>
      Augmentable registry of error catalogs.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        RegisteredAuditCatalogs
      </code>
    </td>
    
    <td>
      interface
    </td>
    
    <td>
      Augmentable registry of audit catalogs.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ErrorCode
      </code>
    </td>
    
    <td>
      type
    </td>
    
    <td>
      Union of all registered error codes.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        AuditAction
      </code>
    </td>
    
    <td>
      type
    </td>
    
    <td>
      Union of all registered audit actions.
    </td>
  </tr>
</tbody>
</table>

Everything ships from the main `evlog` entrypoint.

## Next Steps

- [Structured Errors](https://www.evlog.dev/learn/structured-errors): The full `createError` API and `parseError` reference.
- [Audit → Recording](https://www.evlog.dev/use-cases/audit/recording): All audit-emission APIs (`log.audit`, `withAudit`, etc.).
- [Frameworks](https://www.evlog.dev/integrate/frameworks/overview): Auto-managed per-request loggers and HTTP error serialization.

---

- [Structured Errors](https://www.evlog.dev/learn/structured-errors)
- [Audit Logs](https://www.evlog.dev/use-cases/audit/overview)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
