Skip to content

Error Handling

Every error in IGNIS ends up as one shape: statusCode, message, normalized, requestId, and details. getError builds that shape at the throw site. A global handler catches whatever you don't.

typescript
import { getError, HTTP } from '@venizia/ignis-helpers';

throw getError({
  statusCode: HTTP.ResultCodes.RS_4.NotFound,
  message: 'User not found',
});

Find what you need

You want toGo to
Throw an error with debugging contextThrow a structured error
Add an i18n error a client can branch onCatalog a domain error
Pick the right HTTP status codeChoose the right status code
Handle errors in a service, controller, or repositoryHandle errors by layer
Customize the global error responseCustomize the global handler
Read the exact JSON a client receivesThe error response shape
Log an error without losing the stackLog errors
Handle a rejected promise or a fire-and-forget callHandle async errors
Roll back a transaction safelyRoll back a transaction on failure
Parse an error response on the clientParse errors on the client

Throw a structured error

getError is the one function every throw in IGNIS goes through. Give it a status code and a message, and it returns an ApplicationError ready to throw.

typescript
import { getError, HTTP } from '@venizia/ignis-helpers';

// Error with context - any key the framework does not model lands in `extra`
throw getError({
  statusCode: HTTP.ResultCodes.RS_4.BadRequest,
  message: 'Invalid request',
  details: { field: 'email', reason: 'Must be a valid email address' },
});
// -> error.extra.details

// The same thing, explicit. Prefer this when the context could be mistaken for a field.
throw getError({
  statusCode: HTTP.ResultCodes.RS_4.BadRequest,
  message: 'Invalid request',
  extra: { details: { field: 'email' } },
});

// Wrapping a lower-level failure - `cause` reaches the native Error.cause
throw getError({
  statusCode: HTTP.ResultCodes.RS_5.InternalServerError,
  message: '[UserService][create] Database connection failed',
  cause: error,
  extra: { userId: requestedId },
});

Any key getError does not model rides into extra. That's how a throw site attaches context the framework knows nothing about.

NOTE

The trade-off: a misspelling rides the same path. getError({ message, statuscode: 503 }) compiles. statusCode stays 400, and 503 sits in extra.statuscode. The framework can't tell your context from your typo - spell option keys carefully.

Catalog a domain error

A free-form getError call like that is right for a failure with no i18n code: an invariant, a misconfiguration, a seed guard. Nobody translates '[UserService][create] Database connection failed'.

But a domain failure - one a client localizes and branches on - belongs in a catalog instead. Retyping its code and status at each throw is how two call sites end up raising category.create.duplicate_name and category.duplicate_name for the same thing.

typescript
import { ErrorScopes, getError, HTTP } from '@venizia/ignis-helpers';
import type { TErrorDefinition, TRegisterErrors } from '@venizia/ignis-helpers';

export const UserErrors = {
  CREATE_DUPLICATE_EMAIL: {
    message: {
      text: 'An account with %{email} already exists.',
      code: 'server.core.user.create.duplicate_email',
    },
    statusCode: HTTP.ResultCodes.RS_4.Conflict,
    category: ErrorScopes.VALIDATION,
    description: 'Sign-up rejected because the email is already registered.',
  },
} as const satisfies Record<string, TErrorDefinition>;

declare module '@venizia/ignis-helpers' {
  interface IErrorKeyRegistry extends TRegisterErrors<typeof UserErrors> {}
}

// At every throw site:
throw getError({ error: UserErrors.CREATE_DUPLICATE_EMAIL, messageArgs: { email } });

Pass the definition as error - never spread it. getError({ ...UserErrors.CREATE_DUPLICATE_EMAIL, messageArgs: { email } }) looks equivalent. It isn't.

Spreading skips the error: field entirely. category and description are fields only a definition models, so they fall into extra instead of staying structured. statusCode and message still resolve correctly, but only because their shapes happen to match what getError expects standalone - nothing catches the rest for you.

See the Error helper reference for the full surface.

Choose the right status code

CodeConstantUse when
400RS_4.BadRequestInvalid input format, missing required fields, database constraint violations (auto-handled)
401RS_4.UnauthorizedMissing or invalid authentication
403RS_4.ForbiddenAuthenticated but insufficient permissions
404RS_4.NotFoundResource does not exist
409RS_4.ConflictResource already exists (custom duplicate handling); transient DB conflicts (deadlock / serialization failure, auto-handled)
422RS_4.UnprocessableEntityValidation failed (Zod errors)
429RS_4.TooManyRequestsRate limit exceeded
500RS_5.InternalServerErrorUnexpected server error
502RS_5.BadGatewayExternal service failed
503RS_5.ServiceUnavailableService temporarily down

Automatic database error handling

The global error middleware converts constraint violations to HTTP 400 on its own, no try/catch needed - SQLSTATE class 22 (data exception), 23 (integrity constraint: unique, foreign key, not null, check, exclusion), and 44 (WITH CHECK OPTION violation). Transient conflicts (40001 serialization failure, 40P01 deadlock) become HTTP 409 with a retryable message instead. Other classes (for example, class 42 undefined column) stay 500, and production responses are sanitized - see Repository layer.

Handle errors by layer

Service layer

A service decides what's wrong and throws with getError. It doesn't catch what it can't handle.

typescript
import { BaseService } from '@venizia/ignis';
import { getError, HTTP } from '@venizia/ignis-helpers';

export class UserService extends BaseService {
  async createUser(data: TCreateUserRequest): Promise<TUser> {
    // Validate business rules (findOne returns the record or null)
    const existingUser = await this.userRepository.findOne({
      filter: { where: { email: data.email } },
    });

    if (existingUser) {
      throw getError({
        statusCode: HTTP.ResultCodes.RS_4.Conflict,
        message: 'Email already registered',
        details: { email: data.email },
      });
    }

    // Handle external service errors
    try {
      await this.emailService.sendWelcome(data.email);
    } catch (error) {
      // Log but don't fail user creation - never a silent catch
      this.logger.error('[createUser] Failed to send welcome email | email: %s | error: %s',
        data.email, error);
    }

    // create returns { count, data }
    const created = await this.userRepository.create({ data });
    return created.data;
  }

  async getUserOrFail(id: string): Promise<TUser> {
    // findById returns the record or null (no wrapper object)
    const user = await this.userRepository.findById({ id });

    if (!user) {
      throw getError({
        statusCode: HTTP.ResultCodes.RS_4.NotFound,
        message: 'User not found',
        details: { id },
      });
    }

    return user;
  }
}

Notice findOne and findById return the record or null - there's no separate "not found" exception to catch. You check for null and throw.

Controller layer

Controllers stay thin. Call the service, return the response, and let the global handler catch what the service throws.

typescript
import { BaseRestController, controller, get, post } from '@venizia/ignis';

@controller({ path: '/users' })
export class UserController extends BaseRestController {

  @post({ configs: RouteConfigs.CREATE_USER })
  async createUser(c: TRouteContext) {
    const data = c.req.valid<{ name: string; email: string }>('json');

    // Service throws appropriate errors
    const user = await this.userService.createUser(data);

    return c.json(user, HTTP.ResultCodes.RS_2.Created);
  }

  @get({ configs: RouteConfigs.GET_USER })
  async getUser(c: TRouteContext) {
    const { id } = c.req.valid<{ id: string }>('param');

    // Service throws 404 if not found
    const user = await this.userService.getUserOrFail(id);

    return c.json(user, HTTP.ResultCodes.RS_2.Ok);
  }
}

Repository layer

The global middleware already converts constraint violations for you (see Choose the right status code), so most repository methods need no try/catch at all. Skip it unless you want a message tailored to one specific constraint.

Non-production returns the full driver context for debugging:

json
{
  "message": "Unique constraint violation\nDetail: Key (email)=(test@example.com) already exists.\nTable: User\nConstraint: UQ_User_email",
  "statusCode": 400,
  "normalized": {
    "text": "Unique constraint violation\nDetail: Key (email)=(test@example.com) already exists.\nTable: User\nConstraint: UQ_User_email",
    "code": "core.system_error",
    "args": {}
  },
  "requestId": "abc123",
  "details": {
    "url": "http://localhost:3000/users",
    "path": "/users",
    "stack": "Error: Unique constraint violation\n    at ..."
  }
}

Production sanitizes database internals

In production the message is the base message only. Detail: (which echoes row values like emails), Table:, and Constraint: are stripped, and details.stack/details.cause are omitted. Unexpected (non-client) database errors and connection failures return a generic "Internal Server Error", so SQL, schema names, and connection host/port never leak. Use requestId and server logs to diagnose.

json
{
  "message": "Unique constraint violation",
  "statusCode": 400,
  "normalized": { "text": "Unique constraint violation", "code": "core.system_error", "args": {} },
  "requestId": "abc123",
  "details": { "url": "http://localhost:3000/users", "path": "/users" }
}

If you need a custom message for one constraint, catch it explicitly and re-throw everything else:

typescript
import { DefaultCRUDRepository, type TCount } from '@venizia/ignis';
import { getError, HTTP } from '@venizia/ignis-helpers';

export class UserRepository extends DefaultCRUDRepository<typeof User.schema> {
  async createWithCustomError(data: TCreateUser): Promise<TCount & { data: TUser }> {
    try {
      return await this.create({ data });
    } catch (error) {
      // Custom message for specific constraint
      if (error.cause?.code === '23505' && error.cause?.constraint === 'UQ_User_email') {
        throw getError({
          statusCode: HTTP.ResultCodes.RS_4.Conflict,
          message: 'This email is already registered. Please use a different email or login.',
        });
      }
      throw error; // Re-throw for automatic handling
    }
  }
}

Customize the global handler

IGNIS wires a built-in handler by default. AppErrorMiddleware from @venizia/ignis is a class, registered as new AppErrorMiddleware({ logger, rootKey }).value(), and value() returns the Hono ErrorHandler. You rarely need to replace it. When you do, keep the same response contract:

typescript
import { BaseApplication } from '@venizia/ignis';
import { isApplicationError, MessageCode } from '@venizia/ignis-helpers';

export class Application extends BaseApplication {
  override setupMiddlewares(): void {
    super.setupMiddlewares();

    // Custom error handler (optional) - the default AppErrorMiddleware already does this.
    // `this.server` is `{ hono, runtime, instance }` - reach the Hono app via getServer().
    this.getServer().onError((error, c) => {
      const requestId = c.get('requestId') ?? 'unknown';

      // Log all errors
      this.logger.error('[%s] Error | %s', requestId, error);

      // Handle known application errors - isApplicationError(), never `instanceof
      // ApplicationError` (unreliable across package boundaries)
      if (isApplicationError(error)) {
        return c.json({
          statusCode: error.statusCode,
          message: error.message,
          normalized: error.normalized, // { text, code, args } - never undefined
          extra: error.extra,
          requestId,
        }, error.statusCode as Parameters<typeof c.json>[1]);
      }

      // Handle Zod validation errors - Zod v4 exposes `issues`, not `errors`
      if (error.name === 'ZodError') {
        return c.json({
          statusCode: 422,
          message: 'Validation failed',
          normalized: { text: 'Validation failed', code: MessageCode.DEFAULT, args: {} },
          details: { cause: error.issues },
          requestId,
        }, 422);
      }

      // Unknown errors - don't expose details
      return c.json({
        statusCode: 500,
        message: 'Internal server error',
        normalized: { text: 'Internal server error', code: MessageCode.DEFAULT, args: {} },
        requestId,
      }, 500);
    });
  }
}

The error response shape

typescript
interface ErrorResponse {
  statusCode: number;
  message: string;
  normalized: {
    text: string;
    code: string; // stable, localizable code (validation: from params.code or the raw Zod code)
    args: Record<string, unknown>;
  };
  requestId: string;
  extra?: Record<string, unknown>; // structured context attached via getError(...)
  details: {
    url: string;
    path: string;
    stack?: string; // non-production only
    cause?: unknown; // non-production only, or the Zod issue list for 422s
    [key: string]: unknown;
  };
}

There's no top-level messageCode - read normalized.code. extra never mirrors messageArgs; the resolved interpolation values live at normalized.args.

400 Bad Request:

json
{
  "message": "Invalid request body",
  "statusCode": 400,
  "normalized": { "text": "Invalid request body", "code": "core.system_error", "args": {} },
  "requestId": "abc123",
  "details": { "url": "http://localhost:3000/users", "path": "/users" }
}

404 Not Found: extra keys passed to getError(...) (for example details) surface under the top-level extra. The top-level details object is reserved for middleware context (url, path, stack, cause) - the two never share a key.

json
{
  "message": "User not found",
  "statusCode": 404,
  "normalized": { "text": "User not found", "code": "core.system_error", "args": {} },
  "requestId": "abc123",
  "extra": { "details": { "id": "user-uuid" } },
  "details": { "url": "http://localhost:3000/users/user-uuid", "path": "/users/:id" }
}

422 Validation Error: message and normalized.code come from the first failing issue - its params.code if the schema set one, otherwise the raw Zod code (for example invalid_type, too_small). normalized.args is always empty for a Zod issue; the full list of issues stays in details.cause.

json
{
  "message": "Invalid email format",
  "statusCode": 422,
  "normalized": { "text": "Invalid email format", "code": "user.email.invalid", "args": {} },
  "requestId": "abc123",
  "details": {
    "url": "http://localhost:3000/users",
    "path": "/users",
    "cause": [
      {
        "path": "email",
        "message": "Invalid email format",
        "code": "custom"
      }
    ]
  }
}

Log errors

Use %s, never %j, for an Error

message and stack are non-enumerable properties on a native Error (and on ApplicationError, which extends it). %j serializes via JSON.stringify, which only visits enumerable own properties - so logger.error('... | error: %j', error) silently drops both. Always use %s to log an Error instance; reserve %j/%o for plain data objects.

typescript
// Good - %s prints message + stack
this.logger.error('[createOrder] Failed | error: %s', error);

// Bad - %j drops the stack
this.logger.error('[createOrder] Failed | error: %j', error);

NOTE

An ApplicationError logged with %j does show its message text, because the text rides inside the enumerable normalized.text. That's incidental, not a reprieve - the stack is still gone, which is the reason the rule exists. A plain Error under %j still logs little more than {}.

What to log

typescript
// Good - context for debugging. `%s` on the error itself carries message + stack
this.logger.error('[createOrder] Failed | userId: %s | orderId: %s | error: %s',
  userId, orderId, error);

// Bad - no context, and `.message` throws the stack away
this.logger.error(error.message);

// Bad - sensitive data
this.logger.error('Login failed for user | password: %s', password);

Log levels

ILogger has exactly five levels, each a direct method. alert, http, verbose, and silly don't exist.

LevelUse for
emergThe process cannot continue - unrecoverable
errorExceptions that need attention
warnRecoverable issues, deprecation warnings
infoImportant business events
debugDetailed debugging information (gated on DEBUG)
typescript
// Error - requires attention
this.logger.error('[payment] Transaction failed | orderId: %s', orderId);

// Warn - recovered but should investigate
this.logger.warn('[cache] Redis unavailable, falling back to memory');

// Info - business event
this.logger.info('[order] Created | orderId: %s | userId: %s', orderId, userId);

// Debug - detailed trace
this.logger.debug('[query] Executing | sql: %s | params: %j', sql, params);

Handle async errors

Promises

async/await propagates errors on its own - most of the time, you write no error handling at all.

typescript
// Good - errors propagate naturally with async/await
const processOrder = async (orderId: string) => {
  const order = await orderRepository.findById({ id: orderId }); // Throws if fails
  return paymentService.charge(order); // Throws if fails
};

// Good - explicit catch when you need to handle
const processOrderWithFallback = async (order: TOrder) => {
  try {
    return await paymentService.charge(order);
  } catch (error) {
    this.logger.warn('[processOrder] Primary payment failed, trying backup | error: %s', error);
    return backupPaymentService.charge(order);
  }
};

// Bad - swallowing errors
const processOrderSilently = async () => {
  try {
    await dangerousOperation();
  } catch (error) {
    // Error is swallowed - no one knows it happened!
  }
};

Fire-and-forget

But a call you don't await needs its own .catch(), or a rejection crashes the process as an unhandled rejection.

typescript
// Good - log errors from fire-and-forget operations
this.sendNotification(userId).catch(error => {
  this.logger.error('[notify] Failed | userId: %s | error: %s', userId, error);
});

// Good - use void to indicate intentional fire-and-forget
void this.analytics.track('order_created', { orderId });

// Bad - unhandled promise rejection
this.sendNotification(userId); // If this rejects, crash!

Roll back a transaction on failure

typescript
import { getError, HTTP } from '@venizia/ignis-helpers';

const transferFunds = async (opts: { from: string; to: string; amount: number }) => {
  const { from, to, amount } = opts;
  const transaction = await accountRepository.beginTransaction();

  try {
    await accountRepository.debit({ id: from, amount, options: { transaction } });
    await accountRepository.credit({ id: to, amount, options: { transaction } });

    await transaction.commit();
    return { success: true };
  } catch (error) {
    await transaction.rollback();

    // Re-throw with context
    throw getError({
      statusCode: HTTP.ResultCodes.RS_5.InternalServerError,
      message: '[transferFunds] Transaction failed',
      cause: error,
      extra: { from, to, amount },
    });
  }
};

NOTE

rollback() throws on failure, so it belongs in the catch block exactly as shown here - never after a commit() you already awaited outside one. A rollback that follows a failed commit is a deliberate no-op: the transaction is already torn down, so this canonical shape stays safe.

Parse errors on the client

Branch on normalized.code - it's always present. There's no top-level messageCode.

typescript
// TypeScript client example
const createUser = async (data: TCreateUserRequest): Promise<TUser> => {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    const error = await response.json();

    switch (response.status) {
      case 400: {
        // Context passed to getError(...) arrives under `extra`, not `details`
        throw new ValidationError(error.message, error.extra);
      }
      case 401: {
        window.location.href = '/login';
        throw new AuthError('Please log in');
      }
      case 404: {
        throw new NotFoundError(error.message);
      }
      case 422: {
        // Field-level issues live in details.cause
        const fieldErrors = error.details?.cause?.reduce((accumulator, issue) => {
          accumulator[issue.path] = issue.message;
          return accumulator;
        }, {});
        throw new ValidationError(error.normalized.code, fieldErrors);
      }
      case 429: {
        throw new RateLimitError('Too many requests. Try again later.');
      }
      default: {
        throw new ApiError(error.message || 'Something went wrong');
      }
    }
  }

  return response.json();
};

Checklist

CategoryCheck
ServicesBusiness rule violations throw appropriate errors
RepositoriesConstraint errors left to the global handler; only custom messages wrapped
ControllersErrors propagate to global handler
AsyncAll promises have error handling
TransactionsAlways rollback on error
LoggingErrors logged with context
ResponsesConsistent error format returned
SecurityNo sensitive data in error messages

See also