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
codefield is the contract clients should branch on, not the human-readablemessage. Status codes are coarse (many failures share400), 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
| Aspect | Built-in (NotFoundException) | Custom (DomainException) |
|---|---|---|
| Status code | Fixed per class | Chosen per domain error |
| Machine code field | None by default | First-class code property |
| Reuse of message | Repeated at each call site | Centralized in the class |
| Structured details | Manual object each time | Enforced by a shared interface |
| Best for | Generic HTTP failures | Recurring 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 likeDomainException) so the default filter serializes your errors automatically — no extra filter required for the happy path. - Give every domain error a stable, uppercase
codestring and treat it as the client contract instead of the status code or message text. - Define one
ErrorPayloadinterface and reuse it across all custom exceptions so every error response has an identical shape. - Keep dynamic data in a typed
detailsobject 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.