Skip to content

@studnicky/errors ​

Standardized error handling for all modules.

What it is ​

A composable error-contract primitive: it gives application boundaries one structured hierarchy, named error scenarios, and RFC 9457 Problem Details serialization without prescribing an application or transport.

What it is for ​

Northstar Books uses it to make failures from catalogue reads, stock reservations, and fulfilment hooks machine-readable at the boundary. Consumers choose the error classes, classifiers, hook utilities, and contracts they need; the package does not model a bookstore workflow.

Install ​

bash
pnpm add @studnicky/errors

Usage ​

Not every error needs a bespoke subclass — sometimes a NOT_FOUND or a CONNECTION failure just needs the right code, status, and retry guidance already filled in. This example creates both from named scenarios, wraps a timeout around a lower-level RuntimeError to build a real cause chain, and calls toJSON() to see the RFC 9457 shape a client would actually receive.

ts
import { 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);

Two different needs call for two different tools. The first example below subclasses BaseError (exported by @studnicky/types) directly to build AppError — overriding serializeExtra() to add a domain field and formatUserMessage() for a user-facing string — and shows a real cause chain by wrapping a lower-level error. The second needs something BaseError alone can't give for free: typed constructor fields. DomainErrorArgumentList.build() takes the field values and a message-building function and hands back exactly the arguments BaseError's constructor expects, so RateLimitExceededError ends up with a .limit and a .route a caller can read directly, not just a formatted message.

ts

class AppError extends BaseError {
  public override readonly name: string = 'AppError';

  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 { DomainErrorArgumentList } from '../src/index.js';

abstract class RateLimitError extends BaseError {
  protected constructor(argumentList: Readonly<BaseErrorArgumentsInterface>) {
    super(argumentList);
  }
}

class RateLimitExceededError extends RateLimitError {
  public override readonly name: string = 'RateLimitExceededError';

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

A broken observability hook shouldn't be able to break the thing it's observing. This example wires a counter's increment() through HookInvoker, then deliberately makes the hook throw on the second increment — the counter still reaches 3, and the failure is captured as a single recorded HookInvocationError rather than propagating up and aborting the increment that triggered it.

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();

Logging a cache hit or miss shouldn't require a logger import and a console-formatting decision at every call site — EventRecorder collapses "push an event, print a trace line" into one call. This example's TracingCache records a hit for a key it has and a miss for one it doesn't, and the assertions read the recorded events straight back as plain structured data.

ts
// Published from the Node entrypoint: import { EventRecorder } from '@studnicky/errors/node';
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.

Northstar Books examples ​

The runnable ModuleError scenario example models Northstar Books translating a missing ISBN record and a temporarily unavailable inventory connection into stable problem codes and retry guidance. It proves that a checkout or catalogue boundary can give clients actionable, serializable failure data while retaining the causal timeout for operators.

RFC 9457 Problem Details ​

Every BaseError 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 declared 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";

DomainErrorOptionsInterface, ErrorClassifierFunctionInterface, ErrorClassifierInterface, ModuleErrorCreateOptionsInterface, and ModuleErrorOptionsInterface are imported from /errors/interfaces because callers pass or implement them when using the public API.

Public entrypoints ​

Import pathUse it when
@studnicky/errors/nodeNorthstar Books needs structured server-side catalogue, checkout, or hook failures and their runtime utilities.
@studnicky/errors/browserA browser client needs the same portable error contract when it handles a catalogue or basket response.
@studnicky/errors/entitiesAn adapter validates or describes the package's error and diagnostics data contracts.
@studnicky/errors/interfacesTypeScript code implements or accepts the public error construction and classification contracts.

Exports ​

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

Source on GitHub