@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:
| Option | Type | Default | Description |
|---|---|---|---|
format | ErrorFormat | ErrorHandlerFormatter | 'default' | Body shape, or a formatter function. |
includeStackTrace | boolean | false | Add the stack to the body. Development only. |
maskInternalErrors | boolean | true | Replace unhandled non-HttpError 5xx diagnostics with their status title before serving the response. |
logErrors | boolean | true | Report 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, plusstatusTitle/STATUS_TITLES. - Formatters —
defaultFormatterandrfc9457Formatter(RFC 9457 Problem Details), plus a deprecatedrfc7807Formatter, selected byselectFormatter. A Problem Details body carriesdetail, nevermessage. - 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 andrfc7807Formatterstill emit the status-derivedtype(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
| Export | Kind |
|---|---|
badRequest | function |
conflict | function |
defaultFormatter | function |
errorHandler | function |
forbidden | function |
internalServerError | function |
notFound | function |
notImplemented | function |
rfc7807Formatter | function |
rfc9457Formatter | function |
selectFormatter | function |
serviceUnavailable | function |
statusTitle | function |
tooManyRequests | function |
unauthorized | function |
validationError | function |
HttpError | class |
ERROR_TYPE_BASE | const |
STATUS_TITLES | const |
DefaultErrorBody | interface |
ErrorHandlerOptions | interface |
HttpErrorInit | interface |
ProblemDetails | interface |
ValidationError | interface |
ErrorFormat | type |
ErrorHandlerFormatter | type |
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)
| Export | Kind | Purpose |
|---|---|---|
HttpError | class | The single HTTP error type (extends Error, carries statusCode) |
badRequest | function | Factory → 400 HttpError |
unauthorized | function | Factory → 401 HttpError |
forbidden | function | Factory → 403 HttpError |
notFound | function | Factory → 404 HttpError |
conflict | function | Factory → 409 HttpError |
validationError | function | Factory → 422 HttpError wrapping ValidationError[] |
tooManyRequests | function | Factory → 429 HttpError |
internalServerError | function | Factory → 500 HttpError (accepts cause for error chaining) |
notImplemented | function | Factory → 501 HttpError |
serviceUnavailable | function | Factory → 503 HttpError |
statusTitle | function | Resolves a status code to a human-readable title |
STATUS_TITLES | const | Readonly record of well-known status-code → title mappings |
errorHandler | function | Creates the global error-handler MiddlewareFunction |
defaultFormatter | const | Framework-standard error body formatter ({ statusCode, message }) |
rfc9457Formatter | const | RFC 9457 Problem Details formatter |
rfc7807Formatter | const | Deprecated. RFC 7807 Problem Details formatter |
selectFormatter | function | Resolves an ErrorFormat or custom function to a formatter |
ERROR_TYPE_BASE | const | Base URI for framework problem types (https://setu-ts.dev/errors) |
Types
| Export | Kind | Purpose |
|---|---|---|
ValidationError | type | A single validation failure ({ field, message, code? }) |
HttpErrorInit | type | Options object for HttpError.from() |
ErrorHandlerOptions | type | Options for errorHandler() ({ format?, includeStackTrace?, logErrors?, maskInternalErrors?, respond? }); respond(error: HttpError, ctx: IRequestContext) returns or resolves a HandlerResult or undefined fallback. |
ErrorHandlerFormatter | type | (error: Error, ctx?) => Record<string, unknown> |
ErrorFormat | type | 'default' | 'rfc9457' | 'rfc7807' (this package’s union, no 'nestjs') |
DefaultErrorBody | type | Framework-standard error body shape |
ProblemDetails | type | RFC 9457 Problem Details body shape |
Contract notes:
-
Composition over inheritance: there is exactly one
HttpErrorclass. Every factory function returns anHttpErrorwith a pre-setstatusCode— noBadRequestError extends HttpErrorhierarchy. -
causechaining:internalServerError(message, cause)forwardscauseto the ES2022Errorcause chain. The error handler logs it when a logger is registered (the loggedcauseis serialized throughserializeError, so a nestedErrornever renders as{}). -
Error format is the framework’s error-body contract:
errorHandleris the single place an application configures how every error body is written. It publishes a request-scopedIErrorResponder(viarespondWithErrorin@setu-ts/common) beforenext(), 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 witherrorHandler({ format: 'rfc9457' })registered, every error those sites produce is RFC 9457.validation-pluginis the deliberate exception: it formats validation failures itself and takes its ownerrorFormatoption, so an application configures the two to agree — the CLI templates andrest-starterpairValidationPlugin({ errorFormat: 'rfc9457' })with the handler for exactly that reason. With noerrorHandlerregistered, 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 thedefaultformatter’s{ statusCode, message, details? }: it is the framework’s pre-formatter shape, written directly byrespondWithError, and it is what an application withouterrorHandlerkeeps receiving byte-for-byte. -
Application-owned caught-error response:
ErrorHandlerOptions.respondruns after a caught value has been normalized, status-hinted or masked, and resolved to a serveable status. It receives thatHttpErrorand the liveIRequestContext; returning or resolving aHandlerResultbuilt throughctx.responsebypasses framework formatting, so the application owns the returned status, headers, and body (for example, an HTML error page). Returning or resolvingundefinedis 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 toerrorHandler’s caught-error path: the M70f responder still formats unmatched-path404, malformed-request400, shutdown-drain503, and other responder-based terminals, including when no full request context exists. The status ownership is the part easiest to lose —renderViewtakes no status, so the natural spelling serves200for 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 carriestype,title,status,detail(andinstancefrom the request path) withContent-Type: application/problem+json. Themessagefield is absent in this mode (Problem Details usesdetail). The media type follows the RESOLVED formatter, so passing the exportedrfc9457Formatterfunction asformatproduces the same body and the same content type as the'rfc9457'alias. -
typeisabout:blankfor status-only problems. RFC 9457 §4.2 registersabout:blankfor a problem carrying “no semantics beyond the HTTP status code”, which is every error this package produces except the one fromvalidationError(). That one defines anerrorsextension member, so it is a distinct problem type identified byhttps://setu-ts.dev/errors/validation— the same URI@setu-ts/validation-pluginemits for it. Clients that need to distinguish errors should readstatus, 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 therfc7807Formatterexport are retained through a deprecation period (AI_GUIDELINES §9.2) and still emit the status-derivedtype(https://setu-ts.dev/errors/404) they always did — a deprecated symbol must not silently change behavior (§9.4). Both spellings carryapplication/problem+json. Removal is scheduled for v1.0.0. -
Logger is optional:
errorHandlerlogs viaILoggerresolved fromctx.services.get(CAPABILITIES.LOGGER)only when a logger is registered; otherwise logging is silently skipped. -
maskInternalErrors(defaulttrue): an unhandled error that is not anHttpErrorand resolves to status ≥ 500 is masked to a genericdetailbefore 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. AnHttpError(a message the developer chose for the caller) and every 4xx pass through unmasked, andmaskInternalErrors: falserestores the previous body verbatim. This is a behaviour change from the previous release — see the CHANGELOG. -
includeStackTraceis config-supplied: passconfig.get('NODE_ENV') === 'development'— never readprocess.envdirectly. The stack trace is secondary to the message, which is what carries the failing statement and its bound parameters;maskInternalErrors(defaulttrue) is what guards that message, notincludeStackTrace. Masking wins over this option: a masked error carries nostackin the body even when this istrue, because a stack begins with the very message that was masked. SetmaskInternalErrors: falseto see an internal error’s stack. -
Short-circuit: when
next()throws,errorHandlerproduces a response (HandlerResult) without re-invokingnext().