Skip to content

@studnicky/errors

Standardized error handling for all modules.

Install

bash
pnpm add @studnicky/errors

Usage

ModuleError.create() resolves error code, retry behavior, and HTTP status from a named scenario.

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

ts
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);
ts
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.

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

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

Loading example…

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

MemberSourceWhy
typeproblemType() — the problem namespace joined with code§3.1.1: the URI reference identifying the problem type. This is the discriminant.
titlethe error's class name§3.1.2: a short summary that must NOT change between occurrences.
detailmessage§3.1.4: explicitly specific to THIS occurrence.
statusstatus, when the error carries one§3.1.3.
instanceinstance, 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.

json
{
  "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.

typescript
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.

typescript
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

SymbolPurposeImport path
BaseErrorBase class for structured application errors.@studnicky/errors
CliExitErrorRepresents a command-line exit failure.@studnicky/errors
DomainErrorArgumentListBuilds typed constructor arguments for domain errors.@studnicky/errors
HookInvocationErrorRepresents a lifecycle-hook failure.@studnicky/errors
HookInvokerInvokes lifecycle hooks with diagnostic handling.@studnicky/errors
HookTimeoutErrorRepresents a timed-out asynchronous hook.@studnicky/errors
ModuleErrorCreates structured errors from named scenario defaults.@studnicky/errors
ReentrantHookInvocationErrorRepresents synchronous hook reentrancy.@studnicky/errors
RuntimeErrorRepresents a generic package-owned runtime failure.@studnicky/errors
ValidationErrorRepresents a single validation failure.@studnicky/errors
ValidationErrorsCollects and reports validation failures.@studnicky/errors
DefaultHttpErrorClassifierClassifies standard HTTP failures for retry behavior.@studnicky/errors
ErrorClassifierBase class for custom error classifiers.@studnicky/errors
matchersProvides runtime error-classification predicates.@studnicky/errors
EventRecorderRecords detached event projections for observers.@studnicky/errors
ErrorCodeProvides standard error-code values.@studnicky/errors
ErrorDefaultsProvides named default error scenarios.@studnicky/errors
HttpStatusProvides common HTTP status-code values.@studnicky/errors
HTTP_INFORMATIONAL_STARTMarks the lower bound of informational HTTP responses.@studnicky/errors
HTTP_INFORMATIONAL_ENDMarks the upper bound of informational HTTP responses.@studnicky/errors
HTTP_SUCCESS_STARTMarks the lower bound of successful HTTP responses.@studnicky/errors
HTTP_SUCCESS_ENDMarks the upper bound of successful HTTP responses.@studnicky/errors
HTTP_REDIRECT_STARTMarks the lower bound of redirect HTTP responses.@studnicky/errors
HTTP_REDIRECT_ENDMarks the upper bound of redirect HTTP responses.@studnicky/errors
HTTP_CLIENT_ERROR_STARTMarks the lower bound of client-error HTTP responses.@studnicky/errors
HTTP_CLIENT_ERROR_ENDMarks the upper bound of client-error HTTP responses.@studnicky/errors
HTTP_REQUEST_TIMEOUTProvides the HTTP request-timeout status code.@studnicky/errors
HTTP_SERVER_ERROR_STARTMarks the lower bound of server-error HTTP responses.@studnicky/errors
HTTP_SERVER_ERROR_ENDMarks the upper bound of server-error HTTP responses.@studnicky/errors
BaseErrorArgumentsInterfaceDefines arguments passed to BaseError subclasses.@studnicky/errors
DomainErrorOptionsInterfaceDefines options passed to DomainErrorArgumentList.build().@studnicky/errors
ErrorClassifierFunctionInterfaceDefines a callable custom error classifier.@studnicky/errors
ErrorClassifierInterfaceDefines a class-based custom error classifier.@studnicky/errors
ModuleErrorCreateOptionsInterfaceDefines options passed to ModuleError.create().@studnicky/errors
ModuleErrorOptionsInterfaceDefines options passed to ModuleError subclasses.@studnicky/errors

Source on GitHub