Kata logoKata

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:

FieldAudienceExample
super(message) (internal)Logs, stack traces, console.error"Post abc-123 not found"
publicMessageClient (JSON message)"Post not found" (no internal IDs)
codeClient (JSON error); machine-readable"post_not_found" (snake_case, stable)
statusHTTP status code404
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.

FieldConventionExample
codesnake_case, namespace by domain'post_not_found', 'comment_forbidden'
statusStandard HTTP codes400, 403, 404, 409, 422
publicMessageSentence case, user-facing'Post not found' (not 'POST_NOT_FOUND')
Class name<Domain><Condition>ErrorPostNotFoundError, 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 BadRequestError or ConflictError for this.
  • For "this should never happen" cases, throw a regular Error. It maps to 500 and your onUnhandled hook 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. Provide status, code, publicMessage. Override publicPayload if you have structured data.
  • Throw from anywhere — service, repository, middleware, inside withTransaction. errorMapper catches it.
  • Response is JSON with error, message, payload, and requestId. Status is your error's status.
  • Errors that aren't AppError get masked to 500 and routed through onUnhandled for your reporting integration.

On this page