Skip to content
NestJS ns exceptions 5 min read

Custom Exceptions

The built-in exceptions cover generic HTTP failures, but real applications fail in domain-specific ways: an order is already paid, a coupon has expired, an account is locked. Encoding those failures as named subclasses of HttpException lets you throw a single, expressive line — throw new CouponExpiredException(code) — instead of hand-building a body at every call site. The exception carries its own status, message, and a machine-readable error code, so business semantics live in one place and your controllers stay thin. This page shows how to extend HttpException, attach structured payloads, and design a small hierarchy of domain errors.

Extending HttpException

A custom exception is just a class that calls super(response, status) in its constructor. The response becomes the serialized body, and status sets the HTTP status code. Because the named class encapsulates both, every caller produces an identical, well-formed error.

import { HttpException, HttpStatus } from '@nestjs/common';

export class CouponExpiredException extends HttpException {
  constructor(code: string) {
    super(
      {
        statusCode: HttpStatus.GONE,
        error: 'CouponExpired',
        message: `Coupon "${code}" has expired`,
        code: 'COUPON_EXPIRED',
      },
      HttpStatus.GONE,
    );
  }
}

Throwing it from a service is a single, readable line:

import { Injectable } from '@nestjs/common';
import { CouponExpiredException } from './exceptions/coupon-expired.exception';

@Injectable()
export class CheckoutService {
  applyCoupon(code: string, expiresAt: Date) {
    if (expiresAt.getTime() < Date.now()) {
      throw new CouponExpiredException(code);
    }
    // ...apply discount
  }
}

The default exception filter catches it and serializes the structured object verbatim.

Output:

POST /checkout/coupon
410 {
  "statusCode": 410,
  "error": "CouponExpired",
  "message": "Coupon \"SUMMER24\" has expired",
  "code": "COUPON_EXPIRED"
}

The code field is the contract clients should branch on, not the human-readable message. Status codes are coarse (many failures share 400), so a stable string code lets the frontend react precisely without parsing prose.

Designing a structured payload

A consistent body shape across every custom error makes client handling trivial. Define a TypeScript interface for the payload and reuse it, so the structure is enforced at compile time rather than copy-pasted.

export interface ErrorPayload {
  statusCode: number;
  error: string;
  message: string;
  code: string;
  details?: Record<string, unknown>;
}
import { HttpException } from '@nestjs/common';
import { ErrorPayload } from './error-payload.interface';

export class DomainException extends HttpException {
  constructor(payload: ErrorPayload) {
    super(payload, payload.statusCode);
  }

  get code(): string {
    return (this.getResponse() as ErrorPayload).code;
  }
}

DomainException becomes the base for every business error. The code getter reads back the machine code from the response, which is handy inside logging interceptors or exception filters that want to branch on it.

A small domain hierarchy

Subclass the base for each concrete failure. Each constructor fixes the status and code and accepts only the dynamic data it needs, keeping the throw site clean.

import { HttpStatus } from '@nestjs/common';
import { DomainException } from './domain.exception';

export class InsufficientFundsException extends DomainException {
  constructor(required: number, available: number) {
    super({
      statusCode: HttpStatus.PAYMENT_REQUIRED,
      error: 'InsufficientFunds',
      message: 'Account balance is too low for this transaction',
      code: 'INSUFFICIENT_FUNDS',
      details: { required, available },
    });
  }
}

export class AccountLockedException extends DomainException {
  constructor(accountId: string) {
    super({
      statusCode: HttpStatus.FORBIDDEN,
      error: 'AccountLocked',
      message: 'This account is temporarily locked',
      code: 'ACCOUNT_LOCKED',
      details: { accountId },
    });
  }
}
import { Injectable } from '@nestjs/common';
import { InsufficientFundsException } from './exceptions/insufficient-funds.exception';

@Injectable()
export class WalletService {
  withdraw(balance: number, amount: number) {
    if (amount > balance) {
      throw new InsufficientFundsException(amount, balance);
    }
    return balance - amount;
  }
}

Output:

POST /wallet/withdraw
402 {
  "statusCode": 402,
  "error": "InsufficientFunds",
  "message": "Account balance is too low for this transaction",
  "code": "INSUFFICIENT_FUNDS",
  "details": { "required": 150, "available": 90 }
}

Preserving the original cause

When a domain error wraps a lower-level failure, pass the original through the options.cause argument. It is not serialized into the response — keeping infrastructure details private — but stays on the error object for logging and tracing.

import { HttpStatus } from '@nestjs/common';
import { DomainException } from './domain.exception';

export class PaymentGatewayException extends DomainException {
  constructor(cause: unknown) {
    super(
      {
        statusCode: HttpStatus.BAD_GATEWAY,
        error: 'PaymentGateway',
        message: 'Payment provider is currently unavailable',
        code: 'PAYMENT_GATEWAY_ERROR',
      },
      // HttpException's second arg can be a status OR options; pass options here
    );
    this.cause = cause;
  }
}

The client receives a safe 502 with a stable code, while error.cause carries the real stack trace for your observability stack.

Custom vs built-in exceptions

AspectBuilt-in (NotFoundException)Custom (DomainException)
Status codeFixed per classChosen per domain error
Machine code fieldNone by defaultFirst-class code property
Reuse of messageRepeated at each call siteCentralized in the class
Structured detailsManual object each timeEnforced by a shared interface
Best forGeneric HTTP failuresRecurring business rules

Avoid leaking internal exceptions (database, ORM, third-party SDK) directly to the client. Catch them at the service boundary, wrap them in a domain exception with a safe message, and attach the original via cause.

Best Practices

  • Extend HttpException (or a shared base like DomainException) so the default filter serializes your errors automatically — no extra filter required for the happy path.
  • Give every domain error a stable, uppercase code string and treat it as the client contract instead of the status code or message text.
  • Define one ErrorPayload interface and reuse it across all custom exceptions so every error response has an identical shape.
  • Keep dynamic data in a typed details object rather than interpolating it into the message, so clients can read structured fields.
  • Wrap infrastructure failures at the service boundary and pass the original through cause — return a safe, generic message in the body.
  • Throw domain exceptions from services, keeping controllers free of error-construction logic and business semantics.
Last updated June 14, 2026
Was this helpful?