@studnicky/errors
Standardized error handling for all modules.
Install
pnpm add @studnicky/errorsBaseError subclass
Extend BaseError to add domain codes, toJSON() serialization, user-facing messages, and cause-chain traversal:
import { BaseError } from '../src/index.js';
class AppError extends BaseError {
static of(message: string, code: string, cause?: Error): AppError {
return new AppError({ 'cause': cause, 'code': code, 'message': message, 'retryable': false });
}
protected override serializeExtra(): Record<string, unknown> {
const extra: Record<string, unknown> = { 'domain': 'app' };
return extra;
}
protected override formatUserMessage(): string {
const result = `Application error: ${this.message}`;
return result;
}
}
const err = AppError.of('Something failed', 'app.failure');
console.log('AppError.code:', err.code);
console.log('AppError.timestamp:', err.timestamp);
console.log('AppError.retryable:', err.retryable);
console.log('AppError.toUserMessage():', err.toUserMessage());
const json = err.toJSON();
console.log('AppError.toJSON().code:', json.code);
console.log('AppError.toJSON().domain:', json.domain);
const cause = new Error('DB connection refused');
const wrapped = AppError.of('Query failed', 'app.queryFailed', cause);
const chain = BaseError.getCauseChain(wrapped);
console.log('Cause chain length:', chain.length);
console.log('Cause chain[0]:', (chain[0] as Error).message);
console.log('Cause chain[1]:', (chain[1] as Error).message);Try it
/** 01-base-error — BaseError subclass with code, timestamp, retryable, toJSON(), toUserMessage(). Run: npx tsx packages/errors/examples/01-base-error.ts */
import assert from 'node:assert/strict';
// #region usage
import { BaseError } from '../src/index.js';
class AppError extends BaseError {
static of(message: string, code: string, cause?: Error): AppError {
return new AppError({ 'cause': cause, 'code': code, 'message': message, 'retryable': false });
}
protected override serializeExtra(): Record<string, unknown> {
const extra: Record<string, unknown> = { 'domain': 'app' };
return extra;
}
protected override formatUserMessage(): string {
const result = `Application error: ${this.message}`;
return result;
}
}
const err = AppError.of('Something failed', 'app.failure');
console.log('AppError.code:', err.code);
console.log('AppError.timestamp:', err.timestamp);
console.log('AppError.retryable:', err.retryable);
console.log('AppError.toUserMessage():', err.toUserMessage());
const json = err.toJSON();
console.log('AppError.toJSON().code:', json.code);
console.log('AppError.toJSON().domain:', json.domain);
const cause = new Error('DB connection refused');
const wrapped = AppError.of('Query failed', 'app.queryFailed', cause);
const chain = BaseError.getCauseChain(wrapped);
console.log('Cause chain length:', chain.length);
console.log('Cause chain[0]:', (chain[0] as Error).message);
console.log('Cause chain[1]:', (chain[1] as Error).message);
// #endregion usage
assert.ok(err instanceof AppError, 'instanceof AppError');
assert.ok(err instanceof BaseError, 'instanceof BaseError');
assert.ok(err instanceof Error, 'instanceof Error');
assert.strictEqual(err.code, 'app.failure');
assert.strictEqual(err.retryable, false);
assert.ok(typeof err.timestamp === 'number' && err.timestamp > 0, 'timestamp is a positive number');
assert.strictEqual(err.toUserMessage(), 'Application error: Something failed');
assert.strictEqual(json.code, 'app.failure');
assert.strictEqual(json.domain, 'app', 'serializeExtra() merged into toJSON()');
assert.strictEqual(chain.length, 2, 'Cause chain has 2 nodes');
assert.strictEqual((chain[0] as Error).message, 'Query failed');
assert.strictEqual((chain[1] as Error).message, 'DB connection refused');
console.log('01-base-error: all assertions passed');
The output shows the error code, timestamp, retryable flag, user message, serialized domain field from serializeExtra(), and a two-node cause chain traversal.
ModuleError with scenario defaults
ModuleError.create() resolves HTTP status codes, retry flags, and error codes from a named scenario:
import { ErrorDefaults, ModuleError } from '../src/index.js';
// Create from scenario — defaults supply code, statusCode, retryable
const notFound = ModuleError.create('User not found', {
'context': { 'userId': 'u-456' },
'scenario': 'NOT_FOUND'
});
console.log('ModuleError NOT_FOUND: code=%s, statusCode=%d, retryable=%s', notFound.code, notFound.statusCode, notFound.retryable);
// Retryable connection error
const connErr = ModuleError.create('Service unreachable', {
'context': { 'host': 'db.internal', 'port': 5432 },
'scenario': 'CONNECTION'
});
console.log('ModuleError CONNECTION: retryable=%s, statusCode=%d', connErr.retryable, connErr.statusCode);
// Cause chain
const cause = new Error('ETIMEDOUT');
const wrapped = ModuleError.create('Request timed out', {
'cause': cause,
'scenario': 'TIMEOUT'
});
const chain = wrapped.getCauseChain();
console.log('Cause chain length:', chain.length);
// toJSON serialization
const json = notFound.toJSON();
console.log('toJSON().name:', json.name);
console.log('toJSON().code:', json.code);Domain subclass extending ModuleError
Add domain-specific create(), serializeExtra(), and formatUserMessage() by subclassing ModuleError:
import type { ModuleErrorOptionsType } from '../src/index.js';
import { BaseError, ModuleError } from '../src/index.js';
class StorageError extends ModuleError {
static override create(
message: string,
options?: { 'cause'?: Error; 'context'?: Record<string, unknown> }
): StorageError {
const opts: ModuleErrorOptionsType = {
'cause': options?.cause,
'code': 'STORAGE_ERROR',
'context': options?.context,
'retryable': false,
'statusCode': 500
};
return new StorageError(message, opts);
}
protected override serializeExtra(): Record<string, unknown> {
const extra: Record<string, unknown> = { 'domain': 'storage' };
return extra;
}
protected override formatUserMessage(): string {
const result = 'Storage unavailable. Please try again later.';
return result;
}
}
const err = StorageError.create('Write failed', { 'context': { 'bucket': 'uploads', 'key': 'img.png' } });
console.log('StorageError.name:', err.name);
console.log('StorageError.code:', err.code);
console.log('StorageError.statusCode:', err.statusCode);
console.log('StorageError.toUserMessage():', err.toUserMessage());
const json = err.toJSON();
console.log('toJSON().domain:', json.domain);
console.log('toJSON().name:', json.name);
// Wrapping: findCauseOfType finds StorageError in a chain
const outer = ModuleError.create('Operation failed', {
'cause': err,
'scenario': 'INTERNAL'
});
const found = outer.findCauseOfType(StorageError);
console.log('findCauseOfType(StorageError):', found?.code);Domain errors with DomainErrorArgs
DomainErrorArgs.build() collapses the repeated "compute code/message/retryable, call super()" ceremony that small leaf error classes duplicate. It works with any error base whose constructor takes BaseErrorArgumentsType-shaped args, not only BaseError or ModuleError directly:
import { BaseError, DomainErrorArgs } from '../src/index.js';
abstract class RateLimitError extends BaseError {
protected constructor(args: Readonly<BaseErrorArgumentsType>) {
super(args);
}
}
// json-schema-uninexpressible: minimal demo-only fields type for a runnable doc example, not a shipped domain shape
type RateLimitExceededFieldsType = {
'limit': number;
'route': string;
};
class RateLimitExceededError extends RateLimitError {
readonly limit!: number;
readonly route!: string;
constructor(route: string, limit: number) {
const fields: RateLimitExceededFieldsType = { 'limit': limit, 'route': route };
super(DomainErrorArgs.build(fields, {
'code': 'rateLimit.exceeded',
'message': (f) => { const result = `Rate limit of ${String(f.limit)} exceeded for "${f.route}"`; return result; },
'retryable': true
}));
Object.assign(this, fields);
}
}
const err = new RateLimitExceededError('/api/orders', 100);
console.log('RateLimitExceededError.code:', err.code);
console.log('RateLimitExceededError.route:', err.route);
console.log('RateLimitExceededError.limit:', err.limit);
console.log('RateLimitExceededError.retryable:', err.retryable);
console.log('RateLimitExceededError.message:', err.message);EventRecorder
EventRecorder collapses the repeated "push to an array, then console.log a trace line" ceremony that lifecycle-hook overrides duplicate into a single record() call. It's reached via the @studnicky/errors/observers subpath rather than the package root, and stays intentionally minimal — no config, no pluggable sinks — since it's meant for demo and observability glue, not a general event bus:
// Published as a subpath export: import { EventRecorder } from '@studnicky/errors/observers';
import { EventRecorder } from '../src/observers/index.js';
// json-schema-uninexpressible: minimal demo-only event type for a runnable doc example, not a shipped domain shape
type CacheEventType = { 'event': 'hit' | 'miss'; 'key': string };
class TracingCache {
readonly #store = new Map<string, number>();
readonly #recorder = new EventRecorder<CacheEventType>();
get events(): CacheEventType[] { return this.#recorder.events; }
protected onAccess(key: string, hit: boolean): void {
const event: CacheEventType = { '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)HookInvoker
HookInvoker safely invokes a consumer-supplied lifecycle hook — synchronous or asynchronous — without forcing async contagion on a synchronous caller and without letting a broken hook produce an unhandled rejection or corrupt caller state. A class composes it as a field (never extends it directly, so it never spends the class's one extends slot), and calls invoke(hookName, fn) from its own methods:
import { HookInvoker } from '../src/index.js';
// json-schema-uninexpressible: minimal demo-only entry type for a runnable doc example, not a shipped domain shape
type HookErrorEntryType = { 'cause': unknown; 'hookName': string };
/**
* Hoisted to module scope, per the delegate-class idiom: overrides
* `onHookError` to record a failure instead of letting it throw, so a
* broken observer hook can never abort a mutation that already committed.
*/
class CounterHookInvoker extends HookInvoker {
constructor(private readonly onError: (hookName: string, cause: unknown) => void) {
super();
}
protected override onHookError<T>(hookName: string, cause: unknown): T {
this.onError(hookName, cause);
return undefined as T;
}
}
class ObservedCounter {
#value = 0;
readonly #hookErrors: HookErrorEntryType[] = [];
readonly #hooks: HookInvoker;
constructor() {
this.#hooks = new CounterHookInvoker((hookName, cause) => {
this.#hookErrors.push({ 'cause': cause, 'hookName': hookName });
});
}
get value(): number { return this.#value; }
get hookErrorCount(): number { return this.#hookErrors.length; }
getHookErrors(): readonly HookErrorEntryType[] { return [...this.#hookErrors]; }
protected onIncrement(_next: number): void {}
increment(): void {
this.#value += 1;
this.#hooks.invoke('onIncrement', () => {
const result = this.onIncrement(this.#value);
return result;
});
}
}
class ThrowingObservedCounter extends ObservedCounter {
protected override onIncrement(next: number): void {
if (next === 2) {
throw new Error(`refusing to observe value ${String(next)}`);
}
}
}
const counter = new ThrowingObservedCounter();
counter.increment(); // onIncrement(1) succeeds
counter.increment(); // onIncrement(2) throws, recorded instead of propagating
counter.increment(); // onIncrement(3) succeedsThe base onHookError always throws a HookInvocationError. A caller that needs a different disposition — record-and-continue, as above, or swallow-and-default — defines a small subclass, hoisted to module scope, overriding onHookError. A caller that wants the base throwing behavior with a bound on hook duration passes { timeoutMs } to the constructor directly, with no subclass needed; an async hook that neither resolves nor rejects in time routes to onHookError with a HookTimeoutError cause instead of HookInvocationError. Passing { detectReentrancy: true } makes a synchronous, same-call-stack reentrant call to invoke throw ReentrantHookInvocationError immediately instead of recursing.
Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/errors | BaseError, ModuleError, DomainErrorArgs, ValidationError, CliExitError, ErrorCodeRegistry, ErrorCode, HttpStatus, ErrorDefaults, DomainErrorOptionsType, HookInvoker, HookInvocationError, HookTimeoutError, ReentrantHookInvocationError, HookInvokerOptionsType |
@studnicky/errors/constants | ErrorCode, HttpStatus, ErrorDefaults, CAUSE_CHAIN_DEPTH_LIMIT |
@studnicky/errors/errors | Error classes |
@studnicky/errors/interfaces | Interface types |
@studnicky/errors/observers | EventRecorder |
@studnicky/errors/types | ErrorScenarioType |
Extending
All errors extend BaseError, which itself extends the native Error. Add domain-specific errors by extending ModuleError and overriding serializeExtra() and formatUserMessage() as shown in the domain subclass example above.