← Back to the section

When an API returns an error, the client needs to understand what happened, why, and what to do about it. Default NestJS returns { statusCode, message, error } — that gives the client nothing but a number and a string. The RFC 9457 standard describes a single format for errors from any REST service. Let's see how to implement it.

What Problem Details is

RFC 9457 is simply an agreement about the shape of the error body. Instead of a custom format for each command — the same JSON fields across all services:

{
  "type": "urn:problem:order-service:order-not-found",
  "status": 404,
  "title": "Not Found",
  "detail": "Order #7a3f not found",
  "instance": "urn:uuid:9f2d6c22-8e6d-4c2a-9b41-6b9a5e2f6c10",
  "traceId": "00-1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c-7a8b9c0d1e2f3a4b-01",
  "code": "ORDER_NOT_FOUND"
}
FieldPurpose
typeStable URI or URN of the error category
statusHTTP status (duplicates the response code)
titleShort name (usually the name of the HTTP status)
detailDetails for the user, can be in a human language
instanceUnique URN of a specific incident
traceIdTrace ID to find the error in the logs
codeUPPER_SNAKE_CASE code for programmatic logic on the client

The Content-Type for an error must be application/problem+json, not the usual application/json.

Why default NestJS doesn't fit

Out of the box, NestJS returns:

{ "statusCode": 404, "message": "Not found", "error": "Not Found" }

The client has neither type, nor code, nor instance. The format is non-standard, and it's impossible to understand what exactly wasn't found. The solution: a single global Exception Filter that intercepts all exceptions and builds the correct body.

Global Exception Filter

// src/common/filters/http-exception.filter.ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { randomUUID } from 'crypto';

export interface ProblemDetails {
  type: string;
  status: number;
  title: string;
  detail: string;
  instance: string;
  traceId?: string;
  code: string;
  violations?: Violation[];
}

export interface Violation {
  field?: string;
  message: string;
}

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse<Response>();
    const req = ctx.getRequest();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const body = buildProblem(exception, status, req);

    res
      .status(status)
      .header('Content-Type', 'application/problem+json')
      .json(body);
  }
}

function buildProblem(
  exception: unknown,
  status: number,
  req: { headers: Record<string, string> },
): ProblemDetails {
  const traceId = extractTraceId(req.headers['traceparent']);

  if (exception instanceof HttpException) {
    const response = exception.getResponse();
    if (typeof response === 'object' && 'code' in response) {
      return response as ProblemDetails;
    }
  }

  return {
    type: 'urn:problem:internal:internal-server-error',
    status,
    title: 'Internal Server Error',
    detail: 'An unexpected error occurred',
    instance: `urn:uuid:${randomUUID()}`,
    traceId,
    code: 'INTERNAL_SERVER_ERROR',
  };
}

function extractTraceId(traceparent?: string): string | undefined {
  if (!traceparent) return undefined;
  const parts = traceparent.split('-');
  return parts.length >= 2 ? parts[1] : undefined;
}

The filter is registered in main.ts:

app.useGlobalFilters(new DomainExceptionFilter(), new HttpExceptionFilter());

Order matters: the domain filter is checked first (more on it below).

The sendProblem helper

To avoid assembling an HttpException by hand every time, you write a small helper:

// src/common/filters/send-problem.ts
import { HttpException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { ProblemDetails, Violation } from './http-exception.filter';

interface ProblemOptions {
  type: string;
  status: number;
  title: string;
  detail: string;
  code: string;
  traceId?: string;
  violations?: Violation[];
}

export function sendProblem(options: ProblemOptions): never {
  const body: ProblemDetails = {
    ...options,
    instance: `urn:uuid:${randomUUID()}`,
  };
  throw new HttpException(body, options.status);
}

Usage in code:

const order = await this.orderRepository.findById(orderId);

if (!order) {
  sendProblem({
    type: 'urn:problem:order-service:order-not-found',
    status: 404,
    title: 'Not Found',
    detail: `Order #${orderId} not found`,
    code: 'ORDER_NOT_FOUND',
  });
}

The global filter sees that the body has a code field and passes it through unchanged.

Mapping domain exceptions

If the application throws its own exceptions (OrderNotFoundException, ProductArchivedError, and so on), you don't need to wrap them in sendProblem inside every scenario — a separate DomainExceptionFilter does that:

// src/common/filters/domain-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { randomUUID } from 'crypto';
import { OrderNotFoundException } from '../../order/domain/exceptions/order-not-found.exception';
import { ProductArchivedError } from '../../product/domain/exceptions/product-archived.error';

@Catch(OrderNotFoundException, ProductArchivedError)
export class DomainExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse<Response>();
    const { status, body } = resolve(exception);

    res
      .status(status)
      .header('Content-Type', 'application/problem+json')
      .json(body);
  }
}

function resolve(exception: unknown): { status: number; body: object } {
  const instance = `urn:uuid:${randomUUID()}`;

  if (exception instanceof OrderNotFoundException) {
    return {
      status: HttpStatus.NOT_FOUND,
      body: {
        type: 'urn:problem:order-service:order-not-found',
        status: 404,
        title: 'Not Found',
        detail: `Order #${exception.orderId} not found`,
        instance,
        code: 'ORDER_NOT_FOUND',
      },
    };
  }

  if (exception instanceof ProductArchivedError) {
    return {
      status: HttpStatus.CONFLICT,
      body: {
        type: 'urn:problem:catalog:product-archived',
        status: 409,
        title: 'Conflict',
        detail: `Product ${exception.productId} has been discontinued`,
        instance,
        code: 'PRODUCT_ARCHIVED',
      },
    };
  }

  return {
    status: HttpStatus.INTERNAL_SERVER_ERROR,
    body: {
      type: 'urn:problem:internal:internal-server-error',
      status: 500,
      title: 'Internal Server Error',
      detail: 'An unexpected error occurred',
      instance,
      code: 'INTERNAL_SERVER_ERROR',
    },
  };
}

The domain exception is thrown in the business logic, and the filter turns it into Problem Details. The business logic knows nothing about HTTP.

Validation errors with violations

By default, ValidationPipe throws a BadRequestException with an array of strings. The client doesn't know which field each error belongs to. The solution is an exceptionFactory that builds violations:

// src/main.ts (fragment)
import { ValidationPipe, HttpStatus, HttpException } from '@nestjs/common';
import { ValidationError } from 'class-validator';
import { randomUUID } from 'crypto';

app.useGlobalPipes(
  new ValidationPipe({
    transform: true,
    whitelist: true,
    exceptionFactory: (errors: ValidationError[]) => {
      const violations = flattenViolations(errors);
      return new HttpException(
        {
          type: 'urn:problem:order-service:validation-error',
          status: HttpStatus.BAD_REQUEST,
          title: 'Bad Request',
          detail: 'Input data validation error',
          instance: `urn:uuid:${randomUUID()}`,
          code: 'VALIDATION_ERROR',
          violations,
        },
        HttpStatus.BAD_REQUEST,
      );
    },
  }),
);

function flattenViolations(
  errors: ValidationError[],
  prefix = '',
): { field: string; message: string }[] {
  return errors.flatMap((error) => {
    const field = prefix ? `${prefix}.${error.property}` : error.property;

    const messages = Object.values(error.constraints ?? {}).map((message) => ({
      field,
      message,
    }));

    const nested = error.children?.length
      ? flattenViolations(error.children, field)
      : [];

    return [...messages, ...nested];
  });
}

The result for an invalid request:

{
  "type": "urn:problem:order-service:validation-error",
  "status": 400,
  "title": "Bad Request",
  "detail": "Input data validation error",
  "instance": "urn:uuid:2c4d6e22-1a3b-4f5c-9d8e-7b0a1c2d3e4f",
  "code": "VALIDATION_ERROR",
  "violations": [
    { "field": "customerId", "message": "customerId must be a UUID" },
    { "field": "deliveryAddress.zipCode", "message": "zipCode should not be empty" },
    { "field": "items[0].quantity", "message": "quantity must not be less than 1" }
  ]
}

The field field uses dot-notation with indices for arrays. All errors are returned in a single request, not one at a time.

The type field — URI or URN

type must be stable — it changes only if the meaning of the error changes. Two approaches:

A URL to a documentation page — if you have a developer portal:

"type": "https://errors.example.com/order/not-found"

A URN without a portal — a simple and reliable option:

"type": "urn:problem:order-service:order-not-found"
"type": "urn:problem:catalog:product-archived"

The value "about:blank" cannot be used: the client loses the error category and can't branch on type.

The code field and client-side logic

The client should branch on code, not on status. The HTTP status is too coarse: 409 can mean both a version conflict and exceeding a limit. code is precise:

switch (error.code) {
  case 'ORDER_NOT_FOUND':
    router.push('/orders');
    break;
  case 'VALIDATION_ERROR':
    highlightFields(error.violations);
    break;
  case 'EXT_SYSTEM_UNAVAILABLE':
    showRetryButton();
    break;
}

All codes are listed in OpenAPI as an enum:

export enum ErrorCode {
  INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
  VALIDATION_ERROR = 'VALIDATION_ERROR',
  ORDER_NOT_FOUND = 'ORDER_NOT_FOUND',
  PRODUCT_ARCHIVED = 'PRODUCT_ARCHIVED',
  INSUFFICIENT_STOCK = 'INSUFFICIENT_STOCK',
  CUSTOMER_LIMIT_EXCEEDED = 'CUSTOMER_LIMIT_EXCEEDED',
}

Which HTTP status to use

CodeWhen
400 Bad Requestvalidation error, invalid body format
401 Unauthorizedno token or the token expired
403 Forbiddenauthorization denied
404 Not Foundrequest to a non-existent object
409 Conflictconcurrent modification, duplicate, business conflict
410 Gonea deprecated endpoint after it was turned off
429 Too Many Requestsrequest limit exceeded
500 Internal Server Errorunexpected exceptions

It's better not to use non-standard codes (418, 422, 451) without an explicit need.

Common mistakes

Content-Type left as application/json — the client won't recognize it as Problem Details. You need application/problem+json.

The default NestJS format ({ statusCode, message, error }) — without a global filter, it slips through for some exceptions. Always register HttpExceptionFilter globally.

A stack trace or SQL query in the detail field — never include internal details in the response. For debugging, traceId is enough to find the logs.

A single validation error instead of all of them — if a form has three invalid fields, the client should receive all three at once. flattenViolations does exactly that.

PII in detail — email, phone, document number must not end up in the error body. Use code and a general description.

In short

  • RFC 9457 is a standard error format: type, status, title, detail, instance, code.
  • The Content-Type for errors is application/problem+json, not application/json.
  • A single global HttpExceptionFilter intercepts all exceptions and builds the correct body.
  • The sendProblem helper simplifies creating typed errors in scenarios.
  • DomainExceptionFilter turns domain exceptions into Problem Details without an HTTP dependency in the business logic.
  • exceptionFactory in ValidationPipe produces a full list of violations with field in dot-notation.
  • The client branches on code, not on status — the code is more precise than the HTTP status.
  • type: "about:blank" is not acceptable — the error category is lost.

Further reading

  • Headers and tracing in NestJS — traceparenttraceId, custom headers.
  • JSON and response format in NestJS — the 2xx format, pagination.
  • URLs and resources in NestJS — kebab-case, URI versioning.
  • OpenAPI and antipatterns in NestJS — operationId, @ApiTags, generating the specification.