Errors
AppError subclasses, errorMapper, domain error to HTTP response mapping.
katajs has one error pattern: throw an AppError subclass from anywhere, and the framework's errorMapper turns it into a structured JSON response. Domain errors carry their own status code, a stable error code, a public-facing message, and optional structured payload. The user sees a clean JSON body; you write throw new PostNotFoundError(id) and you're done.
The shape
AppError is an abstract class:
export abstract class AppError extends Error {
abstract readonly status: number;
abstract readonly code: string;
abstract readonly publicMessage: string;
get publicPayload(): Record<string, unknown> | undefined {
return undefined;
}
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}A concrete domain error is one subclass:
import { AppError } from '@katajs/core';
export class PostNotFoundError extends AppError {
override readonly status = 404;
override readonly code = 'post_not_found';
override readonly publicMessage = 'Post not found';
constructor(public readonly postId: string) {
super(`Post ${postId} not found`);
}
override get publicPayload() {
return { postId: this.postId };
}
}Four fields, each doing different work:
| Field | Audience | Example |
|---|---|---|
super(message) (internal) | Logs, stack traces, console.error | "Post abc-123 not found" |
publicMessage | Client (JSON message) | "Post not found" (no internal IDs) |
code | Client (JSON error); machine-readable | "post_not_found" (snake_case, stable) |
status | HTTP status code | 404 |
publicPayload (optional) | Client (merged into JSON) | { postId: "abc-123" } |
The internal message and the publicMessage are intentionally separate. Internal messages can include sensitive details (database IDs, internal state) without leaking them to the client.
The response shape
When an AppError is thrown anywhere in a route handler — directly, from a service, from a repository, from inside withTransaction — errorMapper produces this JSON:
{
"error": "post_not_found",
"message": "Post not found",
"postId": "abc-123",
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}error and message always present. The contents of publicPayload are merged into the body. requestId is the per-request UUID generated by the container middleware (also present in the X-Request-Id response header). The HTTP status is whatever the error class set.
Wiring: errorMapper in createApp
createApp installs errorMapper automatically as the Hono onError handler. You can pass options:
const { app } = createApp({
// ...
errorMapper: {
onUnhandled: (err, ctx) => {
// Send to Sentry, Logflare, etc.
console.error('[unhandled]', ctx.requestId, err);
},
},
});If you don't pass errorMapper, the framework uses sensible defaults — AppErrors map correctly, unhandled errors return a 500 with a generic message.
Why onError, not middleware
Hono v4's recommended pattern for global error handling is app.onError(handler), not a try/catch middleware. We use that:
// (simplified, packages/core/src/app.ts)
app.onError(errorMapper(config.errorMapper));The difference matters because app.onError catches errors thrown anywhere — including inside route handlers, middleware, validation, transactions — and gets the right Context to render a response. A try/catch middleware can't catch errors thrown after await next() returns.
Unhandled errors
Anything thrown that isn't an AppError is treated as a bug, not a domain condition. The mapper returns a generic 500:
{
"error": "internal_error",
"message": "Something went wrong. Please try again.",
"requestId": "..."
}The original error is not leaked to the client. But it is passed to your onUnhandled hook so you can log it / report to Sentry / page someone:
errorMapper: {
onUnhandled: (err, ctx) => {
Sentry.captureException(err, { tags: { requestId: ctx.requestId } });
// err is the original thrown value (Error, string, anything)
// ctx.c is the Hono context, ctx.requestId is the UUID
},
}This split — masked output, full inner detail to your hook — is the right default for production. In development you might want richer error output; that's a v0.2/v0.3 enhancement (onUnhandled is the extension point).
ValidationError: a built-in subclass
The framework ships one AppError subclass beyond AppError itself: ValidationError. It's thrown by validate({...}) when a Zod schema rejects input.
export class ValidationError extends AppError {
override readonly status = 400;
override readonly code = 'validation_failed';
override readonly publicMessage = 'Request validation failed';
constructor(public readonly issues: ValidationIssue[]) { ... }
override get publicPayload() {
return { issues: this.issues };
}
}Because it extends AppError, the same errorMapper handles it. The response body includes the validation issues:
{
"error": "validation_failed",
"message": "Request validation failed",
"issues": [
{ "path": ["body", "title"], "message": "Required" }
],
"requestId": "..."
}This is the entire validation story — Zod produces the issues, validate() throws ValidationError, errorMapper renders it. No separate validation pipeline.
Where to put error classes
By convention, in each module's <module>.errors.ts file:
src/modules/posts/
posts.errors.ts ← PostNotFoundError, ForbiddenPostError
posts.service.ts
posts.routes.ts
...Throw them from services and repositories; they propagate up through Hono's call stack and land in errorMapper. The katajs add module template generates a starter <module>.errors.ts with one <Pascal>NotFoundError class.
Naming conventions
These are conventions, not enforced — but worth being consistent.
| Field | Convention | Example |
|---|---|---|
code | snake_case, namespace by domain | 'post_not_found', 'comment_forbidden' |
status | Standard HTTP codes | 400, 403, 404, 409, 422 |
publicMessage | Sentence case, user-facing | 'Post not found' (not 'POST_NOT_FOUND') |
| Class name | <Domain><Condition>Error | PostNotFoundError, EmailTakenError |
The code is the most important — it's what client code conditionals on (e.g., a UI distinguishing "post deleted" from "you're not allowed"). Keep them stable across versions.
When to make a new error class vs throw inline
Make a class when:
- The condition is one a UI or client might want to handle distinctly.
- The condition occurs in more than one place.
- You need a structured payload (resource IDs, validation paths).
Inline throw new SomeAppError(...) (well, you can't really, since AppError is abstract) — alternatives:
- For one-off conditions, use a generic class. Many codebases have a
BadRequestErrororConflictErrorfor this. - For "this should never happen" cases, throw a regular
Error. It maps to 500 and youronUnhandledhook fires.
Common error patterns
Not found
async getById(id: string) {
const post = await c.resolve('postRepository').findById(id);
if (!post) throw new PostNotFoundError(id);
return post;
}Forbidden / authorization
async deleteOwned({ id, actorId }) {
const post = await c.resolve('postRepository').findById(id);
if (!post) throw new PostNotFoundError(id);
if (post.authorId !== actorId) throw new ForbiddenPostError(id, actorId);
// ...
}Conflict (resource already exists)
class EmailTakenError extends AppError {
override readonly status = 409;
override readonly code = 'email_taken';
override readonly publicMessage = 'That email is already in use';
constructor(email: string) {
super(`Email already taken: ${email}`);
}
}Wrapping another error
If you catch a third-party error (Stripe API, JWT verification, etc.), re-throw as an AppError:
async verifyToken(token: string) {
try {
return await jwt.verify(token, secret);
} catch (err) {
throw new InvalidTokenError({ cause: err }); // your AppError subclass
}
}The original error is still in the cause; errorMapper will use your subclass's status/code/message for the response, and your onUnhandled hook only fires for errors not caught and rethrown.
Summary
- Subclass
AppError. Providestatus,code,publicMessage. OverridepublicPayloadif you have structured data. - Throw from anywhere — service, repository, middleware, inside
withTransaction.errorMappercatches it. - Response is JSON with
error,message, payload, andrequestId. Status is your error's status. - Errors that aren't
AppErrorget masked to 500 and routed throughonUnhandledfor your reporting integration.