Throw an exception anywhere — a handler, middleware, or a service deep in the container — and Keel's HTTP kernel turns it into the right response. No try/catch in every controller.
HTTP exceptions
HttpException carries a status code and message. Throw it (or one of its
subclasses) to short-circuit a request with a specific status:
import {
HttpException,
NotFoundException,
UnauthorizedException,
ForbiddenException,
} from "@shaferllc/keel/core";
throw new NotFoundException("Widget not found"); // 404
throw new UnauthorizedException(); // 401
throw new ForbiddenException(); // 403
throw new HttpException(429, "Slow down"); // any status
throw new HttpException(503, "Down for maintenance", { "Retry-After": "120" });
The full HTTP error family
Every common HTTP status has a named subclass with a fixed status and a stable
machine code. Each takes an optional message and an optional data bag (see
below):
| Class | Status | code |
|---|---|---|
BadRequestException |
400 | E_BAD_REQUEST |
UnauthorizedException |
401 | E_UNAUTHORIZED |
PaymentRequiredException |
402 | E_PAYMENT_REQUIRED |
ForbiddenException |
403 | E_FORBIDDEN |
NotFoundException |
404 | E_NOT_FOUND |
MethodNotAllowedException |
405 | E_METHOD_NOT_ALLOWED |
NotAcceptableException |
406 | E_NOT_ACCEPTABLE |
RequestTimeoutException |
408 | E_REQUEST_TIMEOUT |
ConflictException |
409 | E_CONFLICT |
LengthRequiredException |
411 | E_LENGTH_REQUIRED |
ValidationException |
422 | E_VALIDATION |
TooManyRequestsException |
429 | E_TOO_MANY_REQUESTS |
ServerErrorException |
500 | E_SERVER_ERROR |
NotImplementedException |
501 | E_NOT_IMPLEMENTED |
BadGatewayException |
502 | E_BAD_GATEWAY |
ServiceUnavailableException |
503 | E_SERVICE_UNAVAILABLE |
ValidationException is special — it takes a per-field error map first (see
Validation errors); the rest take (message?, data?).
Attaching structured data
Any exception can carry a data bag that lands in the JSON body under data:
throw new ConflictException("Email already registered", { email: "a@b.com" });
// -> 409 { "error": "Email already registered", "status": 409,
// "code": "E_CONFLICT", "data": { "email": "a@b.com" } }
Every exception also has a toJSON() that returns exactly this body shape
({ error, status, code?, data? }, plus errors for ValidationException), so
you can serialize one yourself outside the HTTP kernel — logging, a queue payload,
a websocket frame.
A controller that always throws can be typed : never:
show(c: Ctx): never {
throw new NotFoundException();
}
The third headers argument is emitted on the response — handy for a 503 with
Retry-After, or a 429 with rate-limit headers:
throw new HttpException(429, "Slow down", { "Retry-After": "30" });
For terse, inline guards you don't need to construct an exception at all — the
request object's abort, abortIf, and abortUnless
helpers throw a plain HttpException for you:
request.abortUnless(user.isAdmin, "Forbidden", 403);
How responses are rendered
The kernel negotiates the response by Accept and by your app.debug config:
| Situation | Response |
|---|---|
| Client accepts JSON | { "error": "...", "status": 404 } |
| Client accepts HTML | A rendered error page |
app.debug = true, unexpected error |
Full message + stack trace (page + JSON) |
app.debug = false, unexpected 500 |
Generic Internal Server Error, internals hidden |
Thrown HttpException |
Its status + message (shown even in production) |
Unexpected errors (anything that isn't an HttpException) become 500. In
production their message and stack are hidden so you never leak internals; the
intentional message on an HttpException is always shown. A subclass code and
any data bag are added to the JSON body ({ error, status, code, data }), and
any headers you passed are set on the response.
The title on both the JSON and HTML paths comes from
STATUS_TEXT — the kernel looks the status up there (STATUS_TEXT[status] ?? "Error"),
so a custom status still gets a sensible label as long as it's in the map.
Unmatched routes
Any request that doesn't match a route is turned into a 404 automatically —
same rendering as a thrown NotFoundException:
GET /does-not-exist → 404 { "error": "No route for GET /does-not-exist", "status": 404, "code": "E_NOT_FOUND" }
The debug error page
When app.debug is on and the client is a browser, the kernel renders a
readable error page with the status, message, request line, and a formatted
stack trace — so you see what broke without digging through logs. Turn debug off
(via APP_DEBUG=false) in production.
Validation errors
ValidationException is a 422 that carries per-field messages, which appear in
the JSON body under errors:
import { ValidationException } from "@shaferllc/keel/core";
throw new ValidationException({ email: ["The email is invalid."] });
// -> 422 { "error": "The given data was invalid.", "status": 422,
// "code": "E_VALIDATION", "errors": { "email": ["The email is invalid."] } }
Custom exceptions
Extend HttpException to model your domain errors. Add a code (surfaced in the
JSON body), and optionally make the exception render or report itself:
import { HttpException } from "@shaferllc/keel/core";
import type { Context } from "hono";
export class PaymentRequiredException extends HttpException {
code = "E_PAYMENT_REQUIRED";
constructor() {
super(402, "Payment is required to continue.");
}
// Optional: render this exception itself.
handle(c: Context) {
return c.json({ error: this.message, code: this.code, upgrade: "/billing" }, this.status);
}
// Optional: called before rendering — log/report it.
report() {
metrics.increment("payment_required");
}
}
throw new PaymentRequiredException();
code→ added to the JSON error body ({ error, status, code }).handle(c)→ if it returns aResponse, the kernel uses it verbatim. If it returns anything else, the kernel falls back to the default rendering.report()→ always called (and awaited) first; failures there never mask the original error.
Both hooks are duck-typed, not tied to a base class: the kernel calls any thrown
value that happens to have a report and/or handle method. The built-in
subclasses don't define either — they render through the default path.
Coded errors with createError
When all you want is a coded error class — a stable code, a message, a status —
skip the boilerplate and mint one with createError. It's the ergonomic shortcut
for the common case:
import { createError } from "@shaferllc/keel/core";
const InsufficientFunds = createError("E_FUNDS", "Balance too low: need %s", 402);
throw new InsufficientFunds("$40");
// -> 402 { "error": "Balance too low: need $40", "status": 402, "code": "E_FUNDS" }
%s placeholders in the message are filled, in order, from the constructor
arguments. The result is a real HttpException subclass, so it renders through
the same path (the code lands in the JSON body) and passes
instanceof HttpException. Define your app's errors once and throw them anywhere:
export const TenantSuspended = createError("E_TENANT_SUSPENDED", "Tenant %s is suspended.", 403);
export const RateExceeded = createError("E_RATE", "Slow down.", 429);
The built-in exceptions carry stable codes too, so code shows up without
any work: NotFoundException → E_NOT_FOUND, UnauthorizedException →
E_UNAUTHORIZED, ForbiddenException → E_FORBIDDEN, ValidationException →
E_VALIDATION.
Reach for a hand-written subclass (above) only when you need behavior — a
handle(c) renderer or a report() hook. For a plain coded error, createError
is all you need.
Customizing the handler
Override the whole thing from your app's HTTP kernel with onError():
// app/Http/Kernel.ts
export class Kernel extends HttpKernel {
constructor(app: Application) {
super(app);
this.onError((err, c) => {
// report to your logging service, then render however you like
return c.json({ oops: true }, 500);
});
}
}
Or override the protected renderException(err, c) method to keep the default
routing but change the presentation.
A registered
onErrorhandler takes precedence over an exception's ownhandle(c)— the custom handler wins, and self-handling is skipped.report()still runs first, either way.
API reference
Every exception below is a subclass of HttpException, which extends the native
Error. You throw them; you never catch them yourself — the kernel does. All are
exported from @shaferllc/keel/core.
HttpException
The base semantic HTTP error: a status code, a message, and optional response headers. Throw it directly for any status that doesn't have a dedicated subclass.
new HttpException(status, message?, headers?)
new HttpException(status: number, message?: string, headers?: Record<string, string>): HttpException
Constructs an error carrying status and message. Omit message to fall back
to the status text.
throw new HttpException(409, "That email is taken");
throw new HttpException(503, "Down for maintenance", { "Retry-After": "120" });
throw new HttpException(429); // message defaults to "Too Many Requests"
Notes: the message defaults to STATUS_TEXT[status], then "Error" if the
status isn't in the map. Exposes three readonly-ish fields the kernel reads:
status (number), headers (optional, emitted on the response), and code
(optional string, added to the JSON body when set). It also sets name to
"HttpException". There's no built-in handle/report — add those on a subclass
to self-render or self-report (see Custom exceptions).
NotFoundException
A 404. Thrown automatically for unmatched routes, and by Model.findOrFail.
new NotFoundException(message?)
new NotFoundException(message?: string): NotFoundException
Constructs a 404. Message defaults to "Not Found".
throw new NotFoundException(); // "Not Found"
throw new NotFoundException("Widget 42 not found");
Notes: status is fixed at 404; name is "NotFoundException". The kernel
also throws this for any request that matches no route.
UnauthorizedException
A 401 — the request isn't authenticated. Reach for it when there's no valid
session or credentials; use ForbiddenException when the user is known but not
allowed.
new UnauthorizedException(message?)
new UnauthorizedException(message?: string): UnauthorizedException
Constructs a 401. Message defaults to "Unauthorized".
throw new UnauthorizedException();
throw new UnauthorizedException("Session expired");
Notes: status is fixed at 401; name is "UnauthorizedException".
ForbiddenException
A 403 — the request is authenticated but not permitted.
new ForbiddenException(message?)
new ForbiddenException(message?: string): ForbiddenException
Constructs a 403. Message defaults to "Forbidden".
throw new ForbiddenException();
throw new ForbiddenException("You can't edit this post");
Notes: status is fixed at 403; name is "ForbiddenException".
ValidationException
A 422 carrying per-field error messages. The kernel adds them to the JSON body
under errors.
new ValidationException(errors, message?)
new ValidationException(errors: Record<string, string[]>, message?: string): ValidationException
Constructs a 422 from a map of field name → messages.
throw new ValidationException({
email: ["The email is invalid."],
password: ["Too short.", "Must contain a number."],
});
// -> 422 { "error": "The given data was invalid.", "status": 422,
// "code": "E_VALIDATION", "errors": { "email": [...], "password": [...] } }
Notes: status is fixed at 422; name is "ValidationException". Message
defaults to "The given data was invalid.". The field map is exposed as the
readonly errors property, which the kernel serializes into the response body.
Keel's validate() helper throws this for you on a failed
parse. code is "E_VALIDATION".
createError(code, message, status?)
createError(code: string, message: string, status?: number): new (...args: (string | number)[]) => HttpException
Mints a reusable, coded HttpException subclass. message may contain %s
placeholders, filled in order from the constructor arguments; status defaults
to 500.
const InsufficientFunds = createError("E_FUNDS", "Balance too low: need %s", 402);
throw new InsufficientFunds("$40");
// -> 402 { "error": "Balance too low: need $40", "status": 402, "code": "E_FUNDS" }
Notes: the returned class extends HttpException, so it renders through the
default path (with code in the JSON body) and passes instanceof HttpException;
its name is the code. A missing argument leaves its %s in place. For an
error that needs a custom handle(c)/report(), subclass HttpException
directly instead.
Constants
STATUS_TEXT
const STATUS_TEXT: Record<number, string>
Maps HTTP status codes to their reason phrases. Used to title error pages/bodies
and to supply the default message for HttpException.
import { STATUS_TEXT } from "@shaferllc/keel/core";
STATUS_TEXT[404]; // "Not Found"
STATUS_TEXT[419]; // "Page Expired"
STATUS_TEXT[418] ?? "Error"; // not in the map
Notes: covers the statuses Keel uses (400, 401, 403, 404, 405,
409, 419, 422, 429, 500, 503). Lookups for anything else are
undefined, which the kernel falls back to "Error" for. It's a plain mutable
object — you can add entries for custom statuses so they render with a label.