Catalogs
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.
Conventions
A single set of conventions covers both error and audit catalogs.
| Convention | Example | |
|---|---|---|
| Catalog key | UPPER_SNAKE_CASE (enum-style, scales to hundreds of entries) | PAYMENT_DECLINED, INVOICE_REFUND |
| Prefix | lower.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 file | errors/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}`,
},
})
import { defineAuditCatalog } from 'evlog'
export const audit = defineAuditCatalog('app', {
USER_LOGIN: { target: 'user' },
USER_DELETE: { target: 'user' },
})
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
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
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
{
"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
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
// 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 }
import { billingErrors } from '~/init'
throw billingErrors.PAYMENT_DECLINED({ cause: stripeErr })
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
@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).
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.
export { billingErrors } from './errors/billing'
export { billingAudit } from './audit/billing'
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 shape | Recommended 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 package | At the bottom of the package's main src/index.ts so it ships in the published .d.ts |
| Monorepo | One 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:
declare module 'evlog' {
interface RegisteredErrorCatalogs {
billing: typeof billingErrors
}
}
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
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
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.@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.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.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
| Symbol | Kind | Purpose |
|---|---|---|
defineError(code, options) | factory | Standalone single-error factory. No prefix derivation. |
defineErrorCatalog(prefix, map) | factory | Bundle of typed errors sharing a prefix. |
defineAuditAction(action, opts?) | factory | Standalone single-action audit factory. |
defineAuditCatalog(prefix, map) | factory | Bundle of typed audit actions sharing a prefix. |
RegisteredErrorCatalogs | interface | Augmentable registry of error catalogs. |
RegisteredAuditCatalogs | interface | Augmentable registry of audit catalogs. |
ErrorCode | type | Union of all registered error codes. |
AuditAction | type | Union of all registered audit actions. |
Everything ships from the main evlog entrypoint.
Next Steps
- Structured Errors: The full
createErrorAPI andparseErrorreference. - Audit → Recording: All audit-emission APIs (
log.audit,withAudit, etc.). - Frameworks: Auto-managed per-request loggers and HTTP error serialization.
Structured Errors
Create errors that explain why they occurred and how to fix them. Add actionable context with why, fix, and link fields for humans and AI agents.
Client Logging
Capture browser events with structured logging. Same API as the server, with automatic console styling, user identity context, and optional server transport.