@setu-ts/exceptions

Exception hierarchy, factory functions, error formatters, and the global error-handler middleware.

This is a plain package, not a plugin — it depends on @setu-ts/common only and registers no capability. You wire the middleware into the application pipeline yourself.

Installation

import { errorHandler, notFound } from '@setu-ts/exceptions';

Usage

import { badRequest, errorHandler, HttpError, notFound } from '@setu-ts/exceptions';

app.middleware.add(errorHandler({ format: 'rfc9457' }), {
  priority: 0,
  name: 'error-handler',
});

app.router.get('/users/:id', async (ctx) => {
  const user = await users.find(ctx.params.id);
  if (!user) throw notFound('User not found');
  return ctx.response.json(user);
});

Options

Caught errors produce one diagnostic: warn for a served status below 500, error otherwise. The request logger records completion rather than a second failure diagnostic. With no registered logger, handled 5xx goes to IRuntimeServices.reportError, the runtime’s last-resort JSON stderr sink (Workers uses its platform log channel); handled 4xx stays silent. logErrors: false disables both paths. The responder’s optional, pure statusFor(error) lets request logging predict this middleware’s status without reporting a clamp twice. A custom respond hook’s different status is not reflected in request completion logs.

errorHandler(options?) takes:

OptionTypeDefaultDescription
formatErrorFormat | ErrorHandlerFormatter'default'Body shape, or a formatter function.
includeStackTracebooleanfalseAdd the stack to the body. Development only.
maskInternalErrorsbooleantrueReplace unhandled non-HttpError 5xx diagnostics with their status title before serving the response.
logErrorsbooleantrueReport handled errors to the resolved ILogger.
respond(error: HttpError, ctx: IRequestContext) => HandlerResult | undefined | Promise<HandlerResult | undefined>—Write an application-owned caught-error response; return or resolve undefined to use the configured formatter instead.

format accepts 'default', 'rfc9457', the deprecated 'rfc7807', or a function. Passing a formatter directly is equivalent to naming it — both paths select the same application/problem+json content type for Problem Details bodies.

respond is called after error normalization, masking, and status resolution. It can return or resolve an HTML (or another application-owned) result through ctx.response; the callback owns that result’s status, headers, and body. The log retains the unmasked diagnostic and records the status that result serves. It applies only to errors caught by errorHandler, not responder-based terminals such as an unmatched-path 404.

The natural spelling of the hook omits the one statement that matters and serves 200 for every branded error: renderView(ctx, Component, props) takes no status, and nothing else sets it. The callback owns the status, so the example carries that line on its own:

import { errorHandler, statusTitle } from '@setu-ts/exceptions';
import type { HandlerResult, IRequestContext } from '@setu-ts/common';
import { renderView } from '@setu-ts/view-plugin';

interface ErrorPageProps {
  readonly status: number;
  readonly title: string;
}

const ErrorPage = (props: ErrorPageProps) => (
  <main>
    <h1>{props.status}</h1>
    <p>{props.title}</p>
  </main>
);

function wantsHtml(ctx: IRequestContext): boolean {
  // Deliberately simple — the point here is that the callback owns the status,
  // not content negotiation. This does NOT honour `q` values, so a client
  // sending `text/html;q=0` (a refusal) still gets HTML; parse the media
  // ranges if your callers send them.
  return (ctx.request.headers.get('accept') ?? '').includes('text/html');
}

app.middleware.add(
  errorHandler({
    format: 'rfc9457',
    async respond(error, ctx): Promise<HandlerResult | undefined> {
      if (!wantsHtml(ctx)) return undefined; // API clients keep Problem Details
      // The callback owns the result's status, and renderView takes none —
      // without this line every branded error answers 200.
      ctx.response.status(error.statusCode);
      return await renderView(ctx, ErrorPage, {
        status: error.statusCode,
        title: statusTitle(error.statusCode),
      });
    },
  }),
  { priority: 0, name: 'error-handler' },
);

What the hook does NOT cover is stated by the rule in docs/mvc.md: every responder terminal — an unmatched-path 404, a guard’s 401, the rate limiter’s 429 — is emitted outside errorHandler, so each keeps its formatter’s JSON no matter what this callback renders.

What it exports

  • HttpError — the error type, carrying a status, a title, and optional validation details.
  • Factories — badRequest, unauthorized, forbidden, notFound, conflict, tooManyRequests, internalServerError, notImplemented, serviceUnavailable, validationError, plus statusTitle / STATUS_TITLES.
  • Formatters — defaultFormatter and rfc9457Formatter (RFC 9457 Problem Details), plus a deprecated rfc7807Formatter, selected by selectFormatter. A Problem Details body carries detail, never message.
  • Middleware — errorHandler(options).

Problem Details and about:blank

The 'rfc9457' format emits type: "about:blank" for any error whose only semantics are its status code — which is every factory in this package except validationError(). RFC 9457 §4.2 registers about:blank for exactly that case, because a URI minted from the status code identifies nothing the status member does not already carry. Read status to distinguish errors.

validationError() is the exception: it defines an errors extension member, so it is a distinct problem type identified by https://setu-ts.dev/errors/validation — the same URI @setu-ts/validation-plugin uses.

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "User 42 does not exist",
  "instance": "/users/42"
}

'rfc7807' is deprecated. RFC 7807 was obsoleted by RFC 9457 in July 2023. The 'rfc7807' alias and rfc7807Formatter still emit the status-derived type (https://setu-ts.dev/errors/404) they always did — a deprecated symbol must not silently change behavior — so upgrading is a deliberate one-word edit. Removal is scheduled for v1.0.0.

Exports

ExportKind
badRequestfunction
conflictfunction
defaultFormatterfunction
errorHandlerfunction
forbiddenfunction
internalServerErrorfunction
notFoundfunction
notImplementedfunction
rfc7807Formatterfunction
rfc9457Formatterfunction
selectFormatterfunction
serviceUnavailablefunction
statusTitlefunction
tooManyRequestsfunction
unauthorizedfunction
validationErrorfunction
HttpErrorclass
ERROR_TYPE_BASEconst
STATUS_TITLESconst
DefaultErrorBodyinterface
ErrorHandlerOptionsinterface
HttpErrorInitinterface
ProblemDetailsinterface
ValidationErrorinterface
ErrorFormattype
ErrorHandlerFormattertype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export, its signature, and the exact response shapes are documented in PUBLIC_API.md.

API reference

API Reference: @setu-ts/exceptions

Exception factory functions, HttpError, error formatters, and the global error handler middleware. This is a plain package (not a plugin) — it depends on @setu-ts/common only. Register the middleware via the application’s pipeline.

Values (exceptions exports)

ExportKindPurpose
HttpErrorclassThe single HTTP error type (extends Error, carries statusCode)
badRequestfunctionFactory → 400 HttpError
unauthorizedfunctionFactory → 401 HttpError
forbiddenfunctionFactory → 403 HttpError
notFoundfunctionFactory → 404 HttpError
conflictfunctionFactory → 409 HttpError
validationErrorfunctionFactory → 422 HttpError wrapping ValidationError[]
tooManyRequestsfunctionFactory → 429 HttpError
internalServerErrorfunctionFactory → 500 HttpError (accepts cause for error chaining)
notImplementedfunctionFactory → 501 HttpError
serviceUnavailablefunctionFactory → 503 HttpError
statusTitlefunctionResolves a status code to a human-readable title
STATUS_TITLESconstReadonly record of well-known status-code → title mappings
errorHandlerfunctionCreates the global error-handler MiddlewareFunction
defaultFormatterconstFramework-standard error body formatter ({ statusCode, message })
rfc9457FormatterconstRFC 9457 Problem Details formatter
rfc7807FormatterconstDeprecated. RFC 7807 Problem Details formatter
selectFormatterfunctionResolves an ErrorFormat or custom function to a formatter
ERROR_TYPE_BASEconstBase URI for framework problem types (https://setu-ts.dev/errors)

Types

ExportKindPurpose
ValidationErrortypeA single validation failure ({ field, message, code? })
HttpErrorInittypeOptions object for HttpError.from()
ErrorHandlerOptionstypeOptions for errorHandler() ({ format?, includeStackTrace?, logErrors?, maskInternalErrors?, respond? }); respond(error: HttpError, ctx: IRequestContext) returns or resolves a HandlerResult or undefined fallback.
ErrorHandlerFormattertype(error: Error, ctx?) => Record<string, unknown>
ErrorFormattype'default' | 'rfc9457' | 'rfc7807' (this package’s union, no 'nestjs')
DefaultErrorBodytypeFramework-standard error body shape
ProblemDetailstypeRFC 9457 Problem Details body shape

Contract notes:

  • Composition over inheritance: there is exactly one HttpError class. Every factory function returns an HttpError with a pre-set statusCode — no BadRequestError extends HttpError hierarchy.

  • cause chaining: internalServerError(message, cause) forwards cause to the ES2022 Error cause chain. The error handler logs it when a logger is registered (the logged cause is serialized through serializeError, so a nested Error never renders as {}).

  • Error format is the framework’s error-body contract: errorHandler is the single place an application configures how every error body is written. It publishes a request-scoped IErrorResponder (via respondWithError in @setu-ts/common) before next(), and every short-circuiting site — the kernel’s own 404/400/500/503 terminals, the storage, multi-tenancy, session, auth, http-security, and feature-flags middleware — answers through it. So with errorHandler({ format: 'rfc9457' }) registered, every error those sites produce is RFC 9457. validation-plugin is the deliberate exception: it formats validation failures itself and takes its own errorFormat option, so an application configures the two to agree — the CLI templates and rest-starter pair ValidationPlugin({ errorFormat: 'rfc9457' }) with the handler for exactly that reason. With no errorHandler registered, every responder site falls back to the no-handler shape { error, detail? } — a site cannot answer in its own ad-hoc shape. That fallback is a different body from the default formatter’s { statusCode, message, details? }: it is the framework’s pre-formatter shape, written directly by respondWithError, and it is what an application without errorHandler keeps receiving byte-for-byte.

  • Application-owned caught-error response: ErrorHandlerOptions.respond runs after a caught value has been normalized, status-hinted or masked, and resolved to a serveable status. It receives that HttpError and the live IRequestContext; returning or resolving a HandlerResult built through ctx.response bypasses framework formatting, so the application owns the returned status, headers, and body (for example, an HTML error page). Returning or resolving undefined is the only fallback signal and preserves the configured formatter exactly. Logging retains the unmasked diagnostic and records the selected response status. This hook is deliberately limited to errorHandler’s caught-error path: the M70f responder still formats unmatched-path 404, malformed-request 400, shutdown-drain 503, and other responder-based terminals, including when no full request context exists. The status ownership is the part easiest to lose — renderView takes no status, so the natural spelling serves 200 for every branded error, and the one-line fix belongs in the hook:

    async respond(error, ctx): Promise<HandlerResult | undefined> {
      if (!wantsHtml(ctx)) return undefined; // API clients keep Problem Details
      ctx.response.status(error.statusCode); // the callback owns the status — without it, 200
      return await renderView(ctx, ErrorPage, { status: error.statusCode });
    }
  • RFC 9457 compliance: when format: 'rfc9457', the response body carries type, title, status, detail (and instance from the request path) with Content-Type: application/problem+json. The message field is absent in this mode (Problem Details uses detail). The media type follows the RESOLVED formatter, so passing the exported rfc9457Formatter function as format produces the same body and the same content type as the 'rfc9457' alias.

  • type is about:blank for status-only problems. RFC 9457 §4.2 registers about:blank for a problem carrying “no semantics beyond the HTTP status code”, which is every error this package produces except the one from validationError(). That one defines an errors extension member, so it is a distinct problem type identified by https://setu-ts.dev/errors/validation — the same URI @setu-ts/validation-plugin emits for it. Clients that need to distinguish errors should read status, which is what it is for.

    {
      "type": "about:blank",
      "title": "Not Found",
      "status": 404,
      "detail": "User 42 does not exist",
      "instance": "/users/42"
    }
  • 'rfc7807' is deprecated but unchanged. RFC 7807 was obsoleted by RFC 9457 in July 2023. The 'rfc7807' alias and the rfc7807Formatter export are retained through a deprecation period (AI_GUIDELINES §9.2) and still emit the status-derived type (https://setu-ts.dev/errors/404) they always did — a deprecated symbol must not silently change behavior (§9.4). Both spellings carry application/problem+json. Removal is scheduled for v1.0.0.

  • Logger is optional: errorHandler logs via ILogger resolved from ctx.services.get(CAPABILITIES.LOGGER) only when a logger is registered; otherwise logging is silently skipped.

  • maskInternalErrors (default true): an unhandled error that is not an HttpError and resolves to status ≥ 500 is masked to a generic detail before the response is built, so the failing SQL and its bound parameter values never reach the client. The real error is still written to the logger first. An HttpError (a message the developer chose for the caller) and every 4xx pass through unmasked, and maskInternalErrors: false restores the previous body verbatim. This is a behaviour change from the previous release — see the CHANGELOG.

  • includeStackTrace is config-supplied: pass config.get('NODE_ENV') === 'development' — never read process.env directly. The stack trace is secondary to the message, which is what carries the failing statement and its bound parameters; maskInternalErrors (default true) is what guards that message, not includeStackTrace. Masking wins over this option: a masked error carries no stack in the body even when this is true, because a stack begins with the very message that was masked. Set maskInternalErrors: false to see an internal error’s stack.

  • Short-circuit: when next() throws, errorHandler produces a response (HandlerResult) without re-invoking next().


Agent card · TypeDoc API reference