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"
}
| Field | Purpose |
|---|---|
type | Stable URI or URN of the error category |
status | HTTP status (duplicates the response code) |
title | Short name (usually the name of the HTTP status) |
detail | Details for the user, can be in a human language |
instance | Unique URN of a specific incident |
traceId | Trace ID to find the error in the logs |
code | UPPER_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
| Code | When |
|---|---|
400 Bad Request | validation error, invalid body format |
401 Unauthorized | no token or the token expired |
403 Forbidden | authorization denied |
404 Not Found | request to a non-existent object |
409 Conflict | concurrent modification, duplicate, business conflict |
410 Gone | a deprecated endpoint after it was turned off |
429 Too Many Requests | request limit exceeded |
500 Internal Server Error | unexpected 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, notapplication/json. - A single global
HttpExceptionFilterintercepts all exceptions and builds the correct body. - The
sendProblemhelper simplifies creating typed errors in scenarios. DomainExceptionFilterturns domain exceptions into Problem Details without an HTTP dependency in the business logic.exceptionFactoryinValidationPipeproduces a full list of violations withfieldin dot-notation.- The client branches on
code, not onstatus— 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 —
traceparent→traceId, 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.