core concepts
The registry as the single source of truth, one kit per subsystem, why the message always equals the code, and construct-vs-throw.
The registry is the whole contract
A kit has no separate type declaration to keep in sync with its runtime codes. The registry
literal you pass to createErrorKit is the type source: Object.keys(REGISTRY) at runtime
equals ErrorKit.Code<R> at compile time, and REGISTRY[code]'s branded type is where each
code's required/optional metadata shape comes from. There is exactly one place to add, remove,
or reshape a code.
This is why the registry is written as const satisfies Record<string, number> rather than
declared against a separate interface. An interface would be a second place for a code to
go stale against.
One kit per subsystem, not one kit per app
createErrorKit is cheap to call more than once. Prefer a kit per subsystem or bounded context,
such as AuthError, BillingError, StorageError, over one giant registry for a whole
application:
const AuthError = createErrorKit('AuthError', AUTH_CODES).ErrorClass
const BillingError = createErrorKit('BillingError', BILLING_CODES).ErrorClass
Two reasons:
- Isolation. Each call to
createErrorKitdeclares its own class internally.err instanceof AuthErrorisfalsefor everyBillingErrorinstance, even if a code name collided between the two registries. A catch block written for one subsystem cannot accidentally swallow an error from an unrelated one. - A registry that stays readable. A 200-entry flat registry is a worse map of "what can this subsystem raise" than five 40-entry ones, each scoped to the part of the app that actually raises those codes.
Nothing stops you from using one kit for a small app. The isolation guarantee simply has nothing to isolate from in that case.
The message is always the code
new AppError('RATE_LIMITED', { retryAfter: 60 }).message === 'RATE_LIMITED'
There is no free-text message parameter. This is deliberate: a message is often where a
developer writes exactly the kind of interpolated, unreviewed string that ends up leaking a
value it shouldn't (`user ${email} not found`). Making message equal the code removes
that entire class of mistake. Anything you want to say beyond the code goes in meta, which
passes through the same secret-scrubbing toJSON runs on every other field, or you look the
code up in a table you control (see Type narrowing for a pattern
that maps codes to user-facing copy).
fail vs throwError: construct, or construct-and-throw
kit.fail(code, meta?) // returns the instance. Never throws.
kit.throwError(code, meta?) // constructs, then throws. Returns `never`.
Reach for fail when you want the error value itself without unwinding the stack: attaching
it to a Result-shaped return, collecting several validation failures before deciding what to
do with them, or logging one without stopping execution. Reach for throwError for the
ordinary imperative case: a precondition fails, and this function has nothing more useful to do
than stop.
// fail: collect, don't stop
const failures = fields
.map((f) => (isValid(f) ? null : kit.fail('INVALID_FIELD', { field: f.name })))
.filter((e): e is InstanceType<typeof AppError> => e !== null)
// throwError: stop now
if (!widget) throwAppError('NOT_FOUND', { resource: 'widget', id })
Metadata has no runtime schema
ErrorKit.Args<R, C> constrains what you can pass at the call site, at compile time. Once
inside the constructor, nothing re-validates it. this.meta = { ...meta } is a shallow copy,
not a schema check. A value that reaches the constructor through an as never cast, a JS
caller with no type checker, or a deserialized/reconstructed object bypasses the compile-time
guarantee entirely.
meta, with
whatever schema tool your project already uses.Next
- Branded types: exactly how
detail/faultmake the above type-checking happen - Error codes and status design: naming and organizing a registry in practice
- Wrapping unknown errors:
asError/rethrowErrorat I/O boundaries