@studnicky/errors
Standardized error handling for all modules.
Install
pnpm add @studnicky/errorsUsage
ModuleError.create() resolves error code, retry behavior, and HTTP status from a named scenario.
import { BaseError, ErrorDefaults, ModuleError } from '../src/index.js';
// Create from scenario — defaults supply code, status, retryable
const notFound = ModuleError.create('User not found', {
'context': { 'userId': 'u-456' },
'scenario': 'NOT_FOUND'
});
console.log('ModuleError NOT_FOUND: code=%s, status=%d, retryable=%s', notFound.code, notFound.status, notFound.retryable);
// Retryable connection error
const connectionError = ModuleError.create('Service unreachable', {
'context': { 'host': 'db.internal', 'port': 5432 },
'scenario': 'CONNECTION'
});
console.log('ModuleError CONNECTION: retryable=%s, status=%d', connectionError.retryable, connectionError.status);
// Cause chain
const cause = RuntimeError.create('ETIMEDOUT');
const wrapped = ModuleError.create('Request timed out', {
'cause': cause,
'scenario': 'TIMEOUT'
});
const chain = BaseError.getCauseChain(wrapped);
console.log('Cause chain length:', chain.length);
// toJSON serialization
const json = notFound.toJSON();
console.log('toJSON().title:', json.title);
console.log('toJSON().code:', json.code);Extend BaseError or ModuleError for a domain-specific error, and use DomainErrorArgumentList when a leaf error carries typed fields.
import { BaseError } from '../src/index.js';
class AppError extends BaseError {
public constructor(argumentList: { 'cause'?: Error; 'code': string; 'message': string; 'retryable': boolean }) {
super(argumentList);
}
protected override serializeExtra(): Record<string, unknown> {
return { 'domain': 'app' };
}
protected override formatUserMessage(): string {
const result = String.raw`Application error: ${this.message}`;
return result;
}
}
const error = new AppError({ 'code': 'app.failure', 'message': 'Something failed', 'retryable': false });
console.log('AppError.code:', error.code);
console.log('AppError.timestamp:', error.timestamp);
console.log('AppError.retryable:', error.retryable);
console.log('AppError.toUserMessage():', error.toUserMessage());
const json = error.toJSON();
console.log('AppError.toJSON().code:', json.code);
console.log('AppError.toJSON().domain:', json.domain);
const cause = RuntimeError.create('DB connection refused');
const wrapped = new AppError({ 'cause': cause, 'code': 'app.queryFailed', 'message': 'Query failed', 'retryable': false });
const chain = BaseError.getCauseChain(wrapped);
const firstCause = chain[0];
const secondCause = chain[1];
if (!(firstCause instanceof Error) || !(secondCause instanceof Error)) {
throw RuntimeError.create('cause chain contains a non-error value');
}
console.log('Cause chain length:', chain.length);
console.log('Cause chain[0]:', firstCause.message);
console.log('Cause chain[1]:', secondCause.message);import { BaseError, DomainErrorArgumentList } from '../src/index.js';
abstract class RateLimitError extends BaseError {
protected constructor(argumentList: Readonly<BaseErrorArgumentsInterface>) {
super(argumentList);
}
}
class RateLimitExceededError extends RateLimitError {
readonly limit!: number;
readonly route!: string;
constructor(route: string, limit: number) {
const fields = { 'limit': limit, 'route': route };
super(DomainErrorArgumentList.build(fields, {
'code': 'rateLimit.exceeded',
'message': (messageFields): string => {
const result = `Rate limit of ${String(messageFields.limit)} exceeded for "${messageFields.route}"`;
return result;
},
'retryable': true
}));
this.limit = limit;
this.route = route;
}
}
const error = new RateLimitExceededError('/api/orders', 100);
console.log('RateLimitExceededError.code:', error.code);
console.log('RateLimitExceededError.route:', error.route);
console.log('RateLimitExceededError.limit:', error.limit);
console.log('RateLimitExceededError.retryable:', error.retryable);
console.log('RateLimitExceededError.message:', error.message);HookInvoker runs synchronous or asynchronous lifecycle hooks, preserving diagnostics in HookInvocationError and reporting timeouts as HookTimeoutError.
import { type HookInvocationError, HookInvoker } from '../src/index.js';
class ObservedCounter {
static readonly #OwnedHookInvoker = class CounterHookInvoker extends HookInvoker {
// Disposition only: HookInvoker already owns and snapshots diagnostics.
protected override onHookError(_hookName: string, _cause: Error): void {}
};
#value = 0;
readonly #hooks: HookInvoker = new ObservedCounter.#OwnedHookInvoker();
get value(): number { return this.#value; }
get hookErrorCount(): number { return this.#hooks.hookErrorCount; }
getHookErrors(): readonly HookInvocationError[] {
const diagnostics = this.#hooks.getHookErrors();
if (diagnostics.length !== this.#hooks.hookErrorCount) {
throw RuntimeError.create('Hook diagnostic projection count mismatch');
}
return diagnostics;
}
protected onIncrement(_next: number): void {}
increment(): void {
this.#value += 1;
this.#hooks.invoke('onIncrement', () => {
const result = this.onIncrement(this.#value);
return result;
});
}
async incrementAndWait(): Promise<void> {
this.#value += 1;
await this.#hooks.invokeAsync('onIncrement', () => {
const result = this.onIncrement(this.#value);
return result;
});
}
}
class ThrowingObservedCounter extends ObservedCounter {
protected override onIncrement(next: number): void {
if (next === 2) {
throw RuntimeError.create(`refusing to observe value ${String(next)}`);
}
}
}
const counter = new ThrowingObservedCounter();
counter.increment(); // Fire-and-forget when subsequent work does not depend on hook completion.
await counter.incrementAndWait(); // Await when subsequent work requires hook completion.
counter.increment();EventRecorder stores detached event projections for small observability integrations.
// Published at the package root: import { EventRecorder } from '@studnicky/errors';
import { EventRecorder } from '../src/index.js';
import { CacheEventEntity } from './entities/CacheEventEntity.js';
class TracingCache {
readonly #store = new Map<string, number>();
readonly #recorder = new EventRecorder<CacheEventEntity.Type>();
get events(): readonly CacheEventEntity.Type[] { return this.#recorder.events; }
protected onAccess(key: string, hit: boolean): void {
const event = CacheEventEntity.create({ 'event': hit ? 'hit' : 'miss', 'key': key });
this.#recorder.record(event, `[cache] ${event.event} key=${key}`);
}
set(key: string, value: number): void {
this.#store.set(key, value);
}
get(key: string): number | undefined {
const value = this.#store.get(key);
this.onAccess(key, value !== undefined);
return value;
}
}
const cache = new TracingCache();
cache.set('a', 1);
cache.get('a'); // onAccess(a, true)
cache.get('b'); // onAccess(b, false)Try it
The output shows ModuleError.create() resolving code/status/retryable from the NOT_FOUND and CONNECTION scenario defaults, BaseError.getCauseChain() walking a wrapped TIMEOUT error's cause chain, and toJSON() serializing the error's title and code.
RFC 9457 Problem Details
Every error serializes to one form: an RFC 9457 Problem Details object. toJSON() returns it, so JSON.stringify(error) produces it too. There is no second serialized shape.
Member mapping
| Member | Source | Why |
|---|---|---|
type | problemType() — the problem namespace joined with code | §3.1.1: the URI reference identifying the problem type. This is the discriminant. |
title | the error's class name | §3.1.2: a short summary that must NOT change between occurrences. |
detail | message | §3.1.4: explicitly specific to THIS occurrence. |
status | status, when the error carries one | §3.1.3. |
instance | instance, when the error carries one | §3.1.5. |
Everything else is an extension member (§3.2): code, correlationId, timestamp, retryable, context, stack, and the flattened causes chain.
{
"type": "https://problems.studnicky.dev/fetch.httpError",
"title": "HTTPError",
"detail": "HTTP 503 Service Unavailable: https://api.example.com/orders",
"status": 503,
"instance": "https://api.example.com/orders",
"code": "fetch.httpError",
"retryable": true,
"timestamp": 1756461600000
}Two rules that are easy to get wrong
Every member is optional. §3.1 defines no required member, and an absent type means about:blank (§4.2.1). ProblemDetailsEntity therefore requires nothing — including type, whose schema default is deliberately omitted, because a member with a default is no longer optional.
Extension members must survive. §3.2 lets a problem type extend the object, and consumers must ignore members they do not recognise. The schema is open, and ProblemDetailsEntity.intake copies its candidate through rather than rebuilding it from the declared members — rebuilding would silently drop exactly the data an extension carries.
Cause chains
The chain is flattened into the causes extension, nearest first, bounded at 32 hops and cycle-safe. Each node carries type/title/detail, plus code/context/correlationId/timestamp when that node was itself a BaseError. Only the head carries stack; a cause node is a summary.
A caught value that is not an Error still projects: a thrown string resolves to .../thrown-string, a thrown primitive to .../thrown-primitive, null to .../thrown-nullish. The problem type URI carries that classification, so no separate discriminant member exists.
Subclass extensions
Override serializeExtra() to add extension members. Registered members always win — extras are merged first — so an extension named context or status cannot silently displace the contract. Override problemType() to point a specific problem type at published documentation.
Entities
@studnicky/errors/entities exports every schema namespace in src/entities, including error classifications, validation arguments and reports, error diagnostics, and native-error field projections. Each namespace exposes its Schema, inferred Type, and runtime validate predicate.
import type { ErrorClassificationEntity } from '@studnicky/errors/entities';Interfaces
@studnicky/errors/interfaces exports every TypeScript interface in src/interfaces, including ModuleErrorInterface plus construction and classifier contracts.
import type { ModuleErrorInterface } from '@studnicky/errors/interfaces';BaseErrorArgumentsInterface, DomainErrorOptionsInterface, ErrorClassifierFunctionInterface, ErrorClassifierInterface, ModuleErrorCreateOptionsInterface, and ModuleErrorOptionsInterface also remain at the root because callers pass or implement them when using the public API.
Exports
| Symbol | Purpose | Import path |
|---|---|---|
BaseError | Base class for structured application errors. | @studnicky/errors |
CliExitError | Represents a command-line exit failure. | @studnicky/errors |
DomainErrorArgumentList | Builds typed constructor arguments for domain errors. | @studnicky/errors |
HookInvocationError | Represents a lifecycle-hook failure. | @studnicky/errors |
HookInvoker | Invokes lifecycle hooks with diagnostic handling. | @studnicky/errors |
HookTimeoutError | Represents a timed-out asynchronous hook. | @studnicky/errors |
ModuleError | Creates structured errors from named scenario defaults. | @studnicky/errors |
ReentrantHookInvocationError | Represents synchronous hook reentrancy. | @studnicky/errors |
RuntimeError | Represents a generic package-owned runtime failure. | @studnicky/errors |
ValidationError | Represents a single validation failure. | @studnicky/errors |
ValidationErrors | Collects and reports validation failures. | @studnicky/errors |
DefaultHttpErrorClassifier | Classifies standard HTTP failures for retry behavior. | @studnicky/errors |
ErrorClassifier | Base class for custom error classifiers. | @studnicky/errors |
matchers | Provides runtime error-classification predicates. | @studnicky/errors |
EventRecorder | Records detached event projections for observers. | @studnicky/errors |
ErrorCode | Provides standard error-code values. | @studnicky/errors |
ErrorDefaults | Provides named default error scenarios. | @studnicky/errors |
HttpStatus | Provides common HTTP status-code values. | @studnicky/errors |
HTTP_INFORMATIONAL_START | Marks the lower bound of informational HTTP responses. | @studnicky/errors |
HTTP_INFORMATIONAL_END | Marks the upper bound of informational HTTP responses. | @studnicky/errors |
HTTP_SUCCESS_START | Marks the lower bound of successful HTTP responses. | @studnicky/errors |
HTTP_SUCCESS_END | Marks the upper bound of successful HTTP responses. | @studnicky/errors |
HTTP_REDIRECT_START | Marks the lower bound of redirect HTTP responses. | @studnicky/errors |
HTTP_REDIRECT_END | Marks the upper bound of redirect HTTP responses. | @studnicky/errors |
HTTP_CLIENT_ERROR_START | Marks the lower bound of client-error HTTP responses. | @studnicky/errors |
HTTP_CLIENT_ERROR_END | Marks the upper bound of client-error HTTP responses. | @studnicky/errors |
HTTP_REQUEST_TIMEOUT | Provides the HTTP request-timeout status code. | @studnicky/errors |
HTTP_SERVER_ERROR_START | Marks the lower bound of server-error HTTP responses. | @studnicky/errors |
HTTP_SERVER_ERROR_END | Marks the upper bound of server-error HTTP responses. | @studnicky/errors |
BaseErrorArgumentsInterface | Defines arguments passed to BaseError subclasses. | @studnicky/errors |
DomainErrorOptionsInterface | Defines options passed to DomainErrorArgumentList.build(). | @studnicky/errors |
ErrorClassifierFunctionInterface | Defines a callable custom error classifier. | @studnicky/errors |
ErrorClassifierInterface | Defines a class-based custom error classifier. | @studnicky/errors |
ModuleErrorCreateOptionsInterface | Defines options passed to ModuleError.create(). | @studnicky/errors |
ModuleErrorOptionsInterface | Defines options passed to ModuleError subclasses. | @studnicky/errors |