Logging
Scale typed error and audit catalogs from a single file to multi-package monorepos. Conventions, npm packaging recipe, composition patterns, and the type-augmentation deep dive.

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.

If you haven't yet, start with Structured Errors → Error Catalogs and Audit → defineAuditCatalog for the basics. This page assumes you've used the primitives at least once.

Conventions

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

ConventionExample
Catalog keyUPPER_SNAKE_CASE (enum-style, scales to hundreds of entries)PAYMENT_DECLINED, INVOICE_REFUND
Prefixlower.dot.case, can be hierarchical'billing', 'billing.payment', 'auth.session'
Wire format${prefix}.${KEY} (preserved casing)billing.PAYMENT_DECLINED, auth.INVALID_TOKEN
One catalog =One bounded context, one prefix, one fileerrors/billing.ts, audit/billing.ts

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.

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}`,
  },
})

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.

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
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.

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
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.

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

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

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 — no extra setup required on their side.

Consumption

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 }
apps/api/src/routes/checkout.post.ts
import { billingErrors } from '~/init'

throw billingErrors.PAYMENT_DECLINED({ cause: stripeErr })
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
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.

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).

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.

src/features/billing/index.ts
export { billingErrors } from './errors/billing'
export { billingAudit } from './audit/billing'
server/api/refund.post.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).

// 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

Repo shapeRecommended location
Single file (src/errors.ts)At the bottom of the same file
Folder (src/errors/*.ts)In src/errors/index.ts (centralised) or each catalog file (decentralised)
npm packageAt the bottom of the package's main src/index.ts so it ships in the published .d.ts
MonorepoOne augmentation per package, no central registry needed

Both centralised and decentralised work — 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:

Centralised — one key per package
declare module 'evlog' {
  interface RegisteredErrorCatalogs {
    billing: typeof billingErrors
  }
}
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

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

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.
Avoid prefix collisions across packages. If @acme/errors-billing and @acme/errors-billing-legacy both augment the billing key, TypeScript merges them silently and the runtime keeps the last-registered factory. Convention: one prefix per package, no overlap.
Never override the code at the call site. The catalog defines the code identity — 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.
Prefer factory.code over string comparisons in tests.expect(err.code).toBe(billingErrors.PAYMENT_DECLINED.code) survives renames; expect(err.code).toBe('billing.PAYMENT_DECLINED') doesn't. Both are valid, the first is refactor-safe.

API reference

SymbolKindPurpose
defineError(code, options)factoryStandalone single-error factory. No prefix derivation.
defineErrorCatalog(prefix, map)factoryBundle of typed errors sharing a prefix.
defineAuditAction(action, opts?)factoryStandalone single-action audit factory.
defineAuditCatalog(prefix, map)factoryBundle of typed audit actions sharing a prefix.
RegisteredErrorCatalogsinterfaceAugmentable registry of error catalogs.
RegisteredAuditCatalogsinterfaceAugmentable registry of audit catalogs.
ErrorCodetypeUnion of all registered error codes.
AuditActiontypeUnion of all registered audit actions.

Everything ships from the main evlog entrypoint.

Next Steps