errorPage(cb)
Register dynamic, per-status HTML rendering in register().
Stratal routes every error through a single exception pipeline. You throw a typed error, and the framework reports it (logging, external services), renders it (JSON, HTML, or a custom response), and negotiates the response format based on what the client accepts. You configure the pipeline by extending ExceptionHandler.
Three classes from stratal/errors form the hierarchy. ApplicationError is the base, HttpException extends it to carry a status code, and InternalError is what the framework wraps unknown throwables in.
The base class for all errors. It is not abstract, so you can instantiate it directly or extend it. Every instance carries a timestamp and supports the standard cause chain.
import { ApplicationError } from 'stratal/errors'
throw new ApplicationError('Something went wrong')
// Chain an underlying causethrow new ApplicationError('Failed to load profile', originalError)An ApplicationError resolves to HTTP 500 unless a subclass specifies otherwise. Use plain, human-readable messages.
Extends ApplicationError with a specific HTTP status code. This is the base for any non-500 error.
import { HttpException, abort } from 'stratal/errors'
throw new HttpException(404, 'Resource not found')throw new HttpException(422, 'Invalid input')
// abort() throws an HttpException and is typed as `never`abort(403, 'Access denied')When you omit the message, HttpException fills in a standard status text (for example Not Found for 404).
A 500 error the framework creates automatically when something that is not an ApplicationError is thrown (for example a TypeError). The original throwable is preserved as the cause, so it still appears in your logs.
import { InternalError } from 'stratal/errors'
throw new InternalError(originalError)You rarely construct this yourself: it exists so the pipeline can normalize any throwable into an ApplicationError.
Define your own errors by extending the class that matches the status you want.
HttpExceptionBake the status into the subclass so call sites stay clean:
import { HttpException } from 'stratal/errors'
export class NoteNotFoundError extends HttpException { constructor() { super(404, 'Note not found') }}
export class DuplicateEmailError extends HttpException { constructor(email: string) { super(409, `A user with email ${email} already exists`) }}Throw them anywhere: services, controllers, guards, or middleware. The pipeline catches them at every level.
import { Transient } from 'stratal/di'
@Transient()export class NotesService { async findById(id: string): Promise<Note> { const note = await this.repository.findById(id)
if (!note) { throw new NoteNotFoundError() }
return note }}ApplicationErrorFor unexpected server-side failures, extend ApplicationError and chain the underlying cause:
import { ApplicationError } from 'stratal/errors'
export class PaymentProcessingError extends ApplicationError { constructor(reason: string, cause?: unknown) { super(`Payment processing failed: ${reason}`, cause) }}For non-HTML requests, the handler returns a JSON ErrorResponse:
{ "message": "Note not found", "timestamp": "2026-06-11T12:00:00.000Z", "stack": "..."}| Field | Description |
|---|---|
message | Human-readable message (translated when i18n is configured) |
timestamp | ISO 8601 timestamp captured when the error was constructed |
stack | Stack trace, included only when ENVIRONMENT is development |
For server errors (status >= 500) outside development, the handler replaces the message with a generic status text (for example Internal Server Error) before sending the response. The real message is still logged, just not exposed to the client. Errors with a 4xx status keep their actual message in every environment.
Every error flows through three phases in order:
Normalize: if the throwable is already an ApplicationError, it passes through. Anything else is wrapped in an InternalError.
Report: the error is logged (and optionally sent to external services). This runs in the background via waitUntil, so reporting never blocks the response.
Render: the error is turned into a Response. Content negotiation decides between JSON, a custom response, or an HTML error page. Registered respond callbacks then post-process the final Response.
When you do not override the level, the handler picks log severity from the resolved status:
| Status | Severity |
|---|---|
| >= 500 | error |
| >= 400 | warn |
Create an exception handler by extending ExceptionHandler and implementing register(). Register your reporting, rendering, and logging customizations there.
import { ExceptionHandler } from 'stratal/errors'import { Transient } from 'stratal/di'
@Transient()export class AppExceptionHandler extends ExceptionHandler { register(): void { // Report specific errors to an external service this.reportable(PaymentError, (error, context) => { sentry.captureException(error) })
// Custom rendering for a specific error this.renderable(MaintenanceError, (error, context) => { return new Response('Service temporarily unavailable', { status: 503 }) })
// Suppress logging for expected errors this.dontReport([NoteNotFoundError])
// Override log severity for a class this.level(RecordNotFoundError, 'warn')
// Add global context to every error log entry this.context(() => ({ region: this.env.CF_REGION, deployId: this.env.DEPLOY_ID, }))
// Post-process every error response this.respond((response, error, context) => { response.headers.set('X-Request-Id', crypto.randomUUID()) return response })
// Render a custom HTML page for a given status (browser / Inertia first loads) this.errorPage((errorResponse, status, context, error) => { if (status === 503) { return new Response(maintenanceHtml(), { status, headers: { 'content-type': 'text/html; charset=utf-8' }, }) } }) }}Register it on the Stratal constructor:
export default new Stratal({ module: AppModule, exceptionHandler: AppExceptionHandler,})| Method | Purpose |
|---|---|
reportable(ErrorClass, callback) | Custom reporting for a class. Returns a Reportable: chain .stop() to skip default logging. |
renderable(ErrorClass, callback) | Custom rendering. Return a Response, an ErrorResponse, or undefined to fall through to the default renderer. |
errorPage(callback) | Render the HTML error page for requests that accept text/html. Callbacks run in registration order, first non-undefined wins. |
dontReport([...classes]) | Suppress logging for the listed error classes. |
level(ErrorClass, severity) | Override log severity ('debug' | 'info' | 'warn' | 'error'). |
context(callback) | Merge key-value pairs into every error log entry. |
respond(callback) | Transform the final Response before it is sent. |
resolve(token) | Resolve a dependency from the DI container inside callbacks. |
When several reportable or renderable entries match the same error, the most specific subclass wins.
reportable() returns a handle whose .stop() prevents the default logger from also reporting the error:
this.reportable(ExternalApiError, (error) => { externalLogger.log(error)}).stop()Every reportable, renderable, and respond callback receives an ExceptionContext, a discriminated union you narrow on context.type to learn where the error came from.
this.renderable(PaymentError, (error, context) => { if (context.type === 'http') { return context.ctx.json({ message: 'Payment failed' }, 500) } // Non-HTTP contexts: return undefined to use default rendering})context.type | Available properties |
|---|---|
'http' | ctx (the RouterContext) |
'queue' | queueName |
'cron' | (none) |
'cli' | commandName |
This is what lets one handler serve HTTP requests, queue consumers, cron jobs, and CLI commands with the right behavior for each.
The renderer inspects the request’s Accept header:
text/html accepted: the handler walks the registered errorPage callbacks (first non-undefined wins) and falls back to a built-in minimal HTML page.ErrorResponse.You can shape HTML rendering at three levels:
errorPage(cb)
Register dynamic, per-status HTML rendering in register().
renderDefaultHtml()
Override this method for a branded static fallback page.
wantsHtml()
Override to change how content negotiation decides on HTML.
Error messages are plain strings. When you want them translated, resolve them through the i18n service. Use withI18n() to translate inside an error class or anywhere in request-scoped code:
import { HttpException } from 'stratal/errors'import { withI18n } from 'stratal/i18n'
export class NoteNotFoundError extends HttpException { constructor(noteId: string) { super(404, withI18n('notes.errors.notFound', { noteId })) }}withI18n(key, params?) returns the translated string for the current request’s locale, or the key itself when called outside a request context. Validation errors are translated automatically through the i18n Zod error map.
Use the isApplicationError type guard to tell framework errors apart from unexpected throwables:
import { isApplicationError } from 'stratal/errors'
try { await someOperation()} catch (error) { if (isApplicationError(error)) { console.log('Known error:', error.message, error.timestamp) } else { console.error('Unexpected:', error) }}