Skip to content

Error Handling

Exception Hierarchy

The @modularityjs/exception package provides a typed exception hierarchy. Every exception extends FrameworkException:

typescript
class FrameworkException extends Error {
  readonly code: string;
  readonly context: Record<string, unknown>;
}

Built-in Exceptions

ExceptionCodeHTTP StatusUse case
AuthenticationExceptionAUTHENTICATION_REQUIRED401Missing or invalid credentials
AuthorizationExceptionACCESS_DENIED403Insufficient permissions
NotFoundExceptionNOT_FOUND404Entity or resource not found
ConflictExceptionCONFLICT409Duplicate entity or state conflict
StateExceptionINVALID_STATE409Invalid operation for current state
RateLimitExceededExceptionRATE_LIMIT_EXCEEDED429Too many requests
ValidationExceptionVALIDATION_FAILED422Input validation failure
ServiceUnavailableExceptionSERVICE_UNAVAILABLE503External service down

Usage

typescript
import {
  ConflictException,
  NotFoundException,
  ValidationException,
} from '@modularityjs/exception';

// Simple throw
throw new NotFoundException('User not found', { userId: '123' });

// Factory helpers
throw NotFoundException.forEntity('User', { id: '123' });
throw ConflictException.alreadyExists('User', { email: 'alice@example.com' });

// Validation errors (multiple)
throw new ValidationException([
  { field: 'email', message: 'Email is required', code: 'REQUIRED' },
  { field: 'age', message: 'Must be at least 18', code: 'MIN_VALUE' },
]);

// Validation helpers
throw ValidationException.requiredField('email');
throw ValidationException.invalidField('age', 'Must be at least 18');

HTTP Error Handling

FrameworkExceptionFilter

The Fastify driver ships with FrameworkExceptionFilter and applies it implicitly: any FrameworkException that escapes a handler (and isn't caught by a user-registered filter) is converted to a structured JSON response. No registration required — user-declared @UseErrorFilter filters take precedence when their @CatchError matches.

A controller throwing a FrameworkException produces a JSON response:

json
{
  "code": "NOT_FOUND",
  "message": "User not found",
  "context": { "userId": "123" }
}

For ValidationException, the response includes the errors array:

json
{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed: 2 error(s)",
  "errors": [
    { "field": "email", "message": "Email is required", "code": "REQUIRED" },
    { "field": "age", "message": "Must be at least 18", "code": "MIN_VALUE" }
  ]
}

Custom Error Filters

Create custom error filters with @CatchError(). A filter receives the error plus the inbound request and returns an ErrorResponse value (status, body, headers, cookies); the framework adapter applies it to the reply. Filters are pure — they decide what the response is, not how to write it.

typescript
import {
  CatchError,
  type ErrorResponse,
  type HttpRequest,
} from '@modularityjs/http';

@CatchError({ error: PaymentError })
class PaymentErrorFilter {
  catch(error: PaymentError, _request: HttpRequest): ErrorResponse {
    return {
      status: 402,
      body: { code: 'PAYMENT_REQUIRED', message: error.message },
    };
  }
}

ErrorResponse lets you set headers and cookies too — useful for redirects:

typescript
@CatchError({ error: AuthenticationException })
class RedirectToLoginFilter {
  catch(): ErrorResponse {
    return { status: 303, headers: { Location: '/login' } };
  }
}

Async filters return Promise<ErrorResponse> — the adapter awaits before finalizing.

Apply filters at the controller or method level with @UseErrorFilter():

typescript
@Injectable()
@Controller('/payments')
@UseErrorFilter(PaymentErrorFilter)
class PaymentController {
  @Post('/charge')
  @UseErrorFilter(StripeErrorFilter) // method-level filter runs first
  async charge(@Body() body: ChargeRequest) {
    // ...
  }
}

Filter resolution order: method-level filters are checked first, then controller-level filters. The first filter whose @CatchError({ error }) type matches the thrown error (via instanceof) handles it.

An error filter is one of several wrap points on the HTTP request pipeline. Interceptors can also observe errors (a thrown handler propagates out through await next()), but transforming a thrown error into a response is the filter's job — interceptors are for the success path. Middleware runs before guards/auth/handler and can't see handler exceptions. For the full comparison of when to reach for each, see HTTP — Middleware vs. Interceptor vs. Plugin.