Skip to content

@studnicky/errors

Standardized error handling for all modules.

Install

bash
pnpm add @studnicky/errors

BaseError subclass

Extend BaseError to add domain codes, toJSON() serialization, user-facing messages, and cause-chain traversal:

ts
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

BaseError subclass with code, toJSON, and cause chain
/** 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');
Output
Press Execute to run this example against the real library.

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:

ts
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:

ts
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:

ts
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:

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

ts
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) succeeds

The 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

SubpathContents
@studnicky/errorsBaseError, ModuleError, DomainErrorArgs, ValidationError, CliExitError, ErrorCodeRegistry, ErrorCode, HttpStatus, ErrorDefaults, DomainErrorOptionsType, HookInvoker, HookInvocationError, HookTimeoutError, ReentrantHookInvocationError, HookInvokerOptionsType
@studnicky/errors/constantsErrorCode, HttpStatus, ErrorDefaults, CAUSE_CHAIN_DEPTH_LIMIT
@studnicky/errors/errorsError classes
@studnicky/errors/interfacesInterface types
@studnicky/errors/observersEventRecorder
@studnicky/errors/typesErrorScenarioType

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.

Source on GitHub