@camcima/nestjs-rfc9457
v0.7.0
Published
NestJS library for RFC 9457 Problem Details responses
Maintainers
Readme
NestJS library for RFC 9457 Problem Details HTTP error responses.
Table of Contents
- What is RFC 9457?
- Features
- Installation
- Coding Agent Skill
- Quick Start
- Configuration
- Async Configuration
- Custom Exception Types
- Validation Integration
- Swagger / OpenAPI Integration
- Advanced Usage
- API Reference
- Example Responses
- Examples
- Security
- Contributing
- License
What is RFC 9457?
RFC 9457 (July 2023) defines a standard JSON format for HTTP API error responses, using the application/problem+json media type. It supersedes RFC 7807 and gives APIs a consistent, machine-readable way to communicate errors.
A Problem Details response looks like this:
{
"type": "https://api.example.com/problems/not-found",
"title": "Not Found",
"status": 404,
"detail": "User 42 not found",
"instance": "/api/users/42"
}The five standard members are:
| Member | Description |
| ---------- | ------------------------------------------------------ |
| type | URI identifying the problem type |
| title | Short human-readable summary of the problem type |
| status | HTTP status code (advisory) |
| detail | Human-readable explanation of this specific occurrence |
| instance | URI identifying this specific occurrence |
Extension members (arbitrary key-value pairs) are allowed for problem-type-specific data.
Features
- Zero-config drop-in: import the module once in
AppModuleand all HTTP exceptions become RFC 9457 responses - Automatic
ValidationPipeintegration — flat string-array errors work out of the box (Tier 1) - Enhanced structured validation errors with
property,constraints, and nestedchildren(Tier 2) @ProblemType()class decorator for custom exception types with full prototype-chain inheritanceProblemDetailExceptionfor one-off problems carrying extension members and status-specific response headers- Configurable
typeURI generation withtypeBaseUriand automatic kebab-case slug derivation - Four
instancestrategies:'request-uri','uuid','none', or a custom callback - Optional catch-all mode for non-
HttpExceptionthrowables (produces 500 Problem Details) - Custom
exceptionMappercallback for full control over any exception responseHeaderscallback for status companions such asRetry-AfterandWWW-Authenticate- Default
error-level logging of unhandled exceptions whencatchAllExceptions: true(override viaonUnhandledcallback) ProblemDetailsFactoryis injectable — use it directly in GraphQL, microservices, or custom filters- Optional
@nestjs/swaggerintegration:ProblemDetailDtoandValidationProblemDetailDtofor OpenAPI documentation, plus aapplyProblemDetailResponses()helper that auto-applies@ApiResponsedecorators to all controllers underapplication/problem+json - Works with both Express and Fastify adapters
- Zero runtime dependencies;
class-validatorand@nestjs/swaggerare optional peer dependencies
Installation
npm install @camcima/nestjs-rfc9457yarn add @camcima/nestjs-rfc9457pnpm add @camcima/nestjs-rfc9457Peer dependencies
| Package | Version | Required |
| ------------------ | ---------------------------------------------- | -------------------------------------- |
| @nestjs/common | ^10.0.0 \|\| ^11.0.0 \|\| ^12.0.0 | Yes |
| @nestjs/core | ^10.0.0 \|\| ^11.0.0 \|\| ^12.0.0 | Yes |
| reflect-metadata | ^0.1.13 \|\| ^0.2.0 | Yes |
| class-validator | ^0.14.0 \|\| ^0.15.0 | No (optional, for Tier 2 validation) |
| @nestjs/swagger | ^7.0.0 \|\| ^8.0.0 \|\| ^11.0.0 \|\| ^12.0.0 | No (optional, for OpenAPI integration) |
Note:
reflect-metadatamust be imported once at your application's entry point. NestJS's standard bootstrap already does this, so no extra setup is needed in a typical app — the library relies on it for@ProblemType()decorator metadata.
Coding Agent Skill
This repository ships an agent skill that teaches AI coding agents (Claude Code, Cursor, Cline, Copilot, and others) how to install and wire @camcima/nestjs-rfc9457 into a NestJS project. It covers module registration, Tier 1/Tier 2 validation, Swagger integration, custom exception types via @ProblemType(), and async configuration with ConfigService.
Install via the Vercel skills CLI
From the root of the NestJS project where you want the agent to use the skill:
npx skills add camcima/nestjs-rfc9457The CLI auto-detects your agent (Claude Code, Cursor, Cline, etc.) and installs the skill into the right location. After installation, ask your agent something like "set up RFC 9457 problem details in this project" — the skill activates automatically and the agent will follow it to install the package, register Rfc9457Module, and apply any optional integrations you ask for.
To list installed skills: npx skills list. To remove: npx skills remove configure-nestjs-rfc9457.
Manual install
If you don't use the Vercel CLI, copy the skill folder directly into your agent's skill directory. Common locations:
| Agent | Path |
| ------------- | ------------------------------------------ |
| Claude Code | .claude/skills/configure-nestjs-rfc9457/ |
| Cursor | .cursor/skills/configure-nestjs-rfc9457/ |
| Generic / SDK | .agents/skills/configure-nestjs-rfc9457/ |
The skill is a single self-contained SKILL.md — no scripts or assets are required.
Quick Start
Import Rfc9457Module once in your root AppModule. Because the module is global, you do not need to import it in any other module — the exception filter applies everywhere in your application automatically. Do not call forRoot() in more than one module: each call registers another global exception filter.
// app.module.ts
import { Module } from '@nestjs/common';
import { Rfc9457Module } from '@camcima/nestjs-rfc9457';
@Module({
imports: [Rfc9457Module.forRoot()],
})
export class AppModule {}That is all the configuration you need. Every HttpException thrown anywhere in your application will now produce an RFC 9457 response.
Before and after
Before (standard NestJS NotFoundException):
{
"statusCode": 404,
"message": "User 42 not found",
"error": "Not Found"
}After (with @camcima/nestjs-rfc9457):
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "User 42 not found"
}The response Content-Type is set to application/problem+json as required by the RFC.
Hybrid applications (WebSockets / microservices): the filter only handles HTTP contexts. For non-HTTP transports it rethrows the exception untouched so it never corrupts the transport with an HTTP reply — but the rethrow does not re-enter Nest's default WS/RPC handlers. If your app uses gateways or microservice listeners, bind transport-scoped exception filters for those contexts.
Committed responses: if an exception is thrown after the response has already been committed (headers sent — e.g. mid-stream), the filter cannot safely write a Problem Details body over it. It logs the exception and ends the response instead of attempting a second write, mirroring
BaseExceptionFilter's own behavior.
Configuration
Rfc9457Module.forRoot() accepts an optional Rfc9457ModuleOptions object.
Rfc9457Module.forRoot({
typeBaseUri: 'https://api.example.com/problems',
instanceStrategy: 'request-uri',
catchAllExceptions: true,
exceptionMapper: (exception, request) => {
/* ... */
},
validationExceptionMapper: (messages, request) => {
/* ... */
},
responseHeaders: (problem, exception, request) => {
/* ... */
},
});typeBaseUri
Type: string | Default: undefined
When set, the library generates type URIs by combining the base URI with a kebab-case slug derived from the HTTP status phrase. When omitted, type defaults to "about:blank" (per RFC 9457 §4.2).
Rfc9457Module.forRoot({
typeBaseUri: 'https://api.example.com/problems',
});A NotFoundException (404) becomes:
{
"type": "https://api.example.com/problems/not-found",
"title": "Not Found",
"status": 404
}Slug derivation uses the HTTP status phrase from Node's built-in http.STATUS_CODES:
"Not Found"→not-found"Internal Server Error"→internal-server-error"Unprocessable Entity"→unprocessable-entity
about:blankandtitle(RFC 9457 §4.2.1): whentyperesolves to"about:blank", the RFC saystitleSHOULD be the generic HTTP status phrase for that status code. The library fills in that phrase automatically whenever a resolution step (anHttpException,@ProblemType(), or a mapper) does not itself supply atitle— but it never rewrites an explicittitleyourexceptionMapperor@ProblemType()metadata sets. If you set a domain-specifictitlewithout also settingtype, the response pairs that title with"about:blank"; set a domain-specifictypeURI alongside it (or configuretypeBaseUri) to keep the two consistent.
instanceStrategy
Type: 'request-uri' | 'uuid' | 'none' | ((request, exception) => string | undefined) | Default: 'none'
Controls how the instance field is populated.
'none' — instance is omitted from the response (default):
Rfc9457Module.forRoot({ instanceStrategy: 'none' });'request-uri' — uses the request URL path:
Rfc9457Module.forRoot({ instanceStrategy: 'request-uri' });
// instance: "/api/users/42"The query string is stripped before the path is used as instance, so query
parameters (which often carry tokens or PII) are never echoed into the response
body. If you need the full URL including the query string, use a custom callback
that returns request.url.
Express's originalUrl is preferred when present. Inside a mounted router
Express rewrites req.url relative to the mount point, so a handler mounted at
/api would otherwise report instance: "/users/42" for a request the client
sent to /api/users/42. Fastify does not define originalUrl and its url is
already the full path, so nothing changes there.
'uuid' — generates a urn:uuid:<v4> per occurrence:
Rfc9457Module.forRoot({ instanceStrategy: 'uuid' });
// instance: "urn:uuid:a8098c1a-f86e-11da-bd1a-00112444be1e"Custom callback — full control, receives the request and the original exception:
Rfc9457Module.forRoot({
instanceStrategy: (request, exception) => {
return `https://errors.example.com/log?path=${request.url}`;
},
});Return undefined from a custom callback to omit instance for that occurrence.
The request parameter implements Rfc9457Request:
interface Rfc9457Request {
url: string;
method: string;
originalUrl?: string;
}Both Express's Request and Fastify's FastifyRequest are structurally
assignable to this interface, so you can pass them directly. To read
adapter-specific fields inside a callback, narrow to the concrete request type:
instanceStrategy: (request) => {
const req = request as unknown as import('express').Request;
return `https://errors.example.com/log?id=${req.headers['x-request-id']}`;
};catchAllExceptions
Type: boolean | Default: false
When false (default), exceptions that are not HttpException instances are passed to NestJS's default error handling via super.catch(). When true, any throwable — including plain Error objects and non-HTTP exceptions — is caught and produces a generic 500 Problem Details response. Internal error information is never exposed in the response body.
Errors raised by the HTTP adapter itself keep their own status (e.g. a 413 for an oversized body), matching NestJS's default handling. Two kinds are recognized by their statusCode plus a marker: errors built by the http-errors package, which Express's body parser throws, carry a boolean expose; Fastify's own errors carry an FST_ code. The message becomes detail only for client errors (for http-errors, only when expose is true). Other errors that merely carry a statusCode still produce a 500.
Rfc9457Module.forRoot({ catchAllExceptions: true });Observability: when this branch fires (a non-HttpException reaches the filter and no exceptionMapper claims it), the library logs the exception at error level via NestJS's built-in Logger (context Rfc9457ExceptionFilter) before sending the generic 500. This keeps unexpected throwables visible in server logs even though the response body is intentionally bland. To redirect or replace this logging, use the onUnhandled callback described below.
suppress5xxDetail
Type: boolean | Default: false
When true, the detail member is stripped from every problem response with a 5xx status, regardless of its source — an HttpException message, an exceptionMapper result, or @ProblemType() metadata. This is intentionally blunt: it is an opt-in production-hardening switch guaranteeing that no internal error text reaches clients on a server error, rather than a fine-grained per-field filter.
Rfc9457Module.forRoot({ suppress5xxDetail: true });Default is false to match NestJS semantics, where an explicit HttpException message is client-facing by design. 4xx responses are never affected.
exceptionMapper
Type: (exception: unknown, request: Rfc9457Request) => ProblemDetail | null
A callback that runs first in the resolution chain. Return a ProblemDetail object to take full control of the response, or null to fall through to the next resolution step (@ProblemType() metadata, then validation handling, then default mapping).
Rfc9457Module.forRoot({
exceptionMapper: (exception, request) => {
if (exception instanceof DatabaseException) {
return {
type: 'https://api.example.com/problems/database-error',
title: 'Database Error',
status: 503,
detail: 'A temporary database error occurred',
};
}
return null; // fall through to default handling
},
});If the returned ProblemDetail omits status, the factory falls back to exception.getStatus() (if it is an HttpException) or 500.
Status invariants
Every problem response this library emits carries a 400–599 status. This is library policy, not an RFC requirement: RFC 9457 allows problem details with any HTTP status code, but notes that they "most naturally fit the semantics of 4xx and 5xx responses" (§1). Two rules enforce the policy:
- A
statussupplied byexceptionMapper,@ProblemType()metadata, orProblemDetailExceptionmust be an integer in 400–599. A value outside the range is ignored — the library logs a warning and falls back toexception.getStatus()(for anHttpException) or500. - An
HttpExceptionwhose own status is outside 400–599 (e.g.new HttpException('moved', 302)) is not rendered as a problem document at all. The filter hands it back to NestJS, which sends its standard response at the requested status. A redirect or success response is not a problem to report, and silently rewriting a deliberate redirect into a 500 would be worse.
An exceptionMapper still takes precedence: if it claims such an exception and returns a valid error status, that problem response is sent normally.
If you call ProblemDetailsFactory directly, rule 2 does not apply — the factory must return something, so a non-error status is clamped to 500 and a warning is logged. Prefer letting the filter make the delegation decision.
onUnhandled
Type: (exception: unknown, request: Rfc9457Request, problem: Readonly<ProblemDetail>) => void | Default: built-in Logger.error(...) (context Rfc9457ExceptionFilter)
Called when a non-HttpException reaches the catch-all branch (i.e. catchAllExceptions: true AND the exceptionMapper returned null). Use this to send unhandled exceptions to a structured sink (Sentry, Datadog, a custom pino child logger) or to suppress the default log entirely.
Rfc9457Module.forRoot({
catchAllExceptions: true,
instanceStrategy: 'uuid',
onUnhandled: (exception, request, problem) => {
// Route to Sentry, Datadog, etc.
sentry.captureException(exception, {
tags: { method: request.method, url: request.url },
// `problem.instance` is the identifier the client sees. Recording it
// here is what lets a support ticket quoting that URN be traced back
// to this stack trace.
extra: { instance: problem.instance },
});
},
});The third parameter is the fully resolved problem body that is about to be sent. It is typed Readonly because the response is serialized from the same object as soon as the callback returns: mutating it (through a cast, or through a nested extension value the shallow Readonly does not cover) changes what the client receives, which is not what this hook is for.
The filter still sends the generic 500 Problem Details response after invoking onUnhandled. This callback exists purely for observability — it never changes the HTTP response.
When onUnhandled is not provided, the library calls Logger.error(...) with either the exception's stack string or a { exception } structured context (for non-Error values). When an instance was generated for the occurrence, it is appended to the log message (… [instance: urn:uuid:…]) so the default logging is correlatable too. The log context is Rfc9457ExceptionFilter so it can be filtered or silenced via NestJS's logger configuration.
responseHeaders
Type: (problem: Readonly<ProblemDetail>, exception: unknown, request: Rfc9457Request) => Record<string, string> | undefined | Default: undefined
Supplies transport response headers that accompany a problem response. Some statuses are only fully specified by a header: Retry-After on 429 and 503, WWW-Authenticate on 401. Those belong in the header block, not the body, and this is the channel for them.
Rfc9457Module.forRoot({
responseHeaders: (problem) => {
if (problem.status === 401) return { 'WWW-Authenticate': 'Bearer realm="api"' };
if (problem.status === 429 && typeof problem.retryAfterSeconds === 'number') {
return { 'Retry-After': String(problem.retryAfterSeconds) };
}
return undefined;
},
});Called once per problem response with the resolved body, the originating exception, and the request. Return undefined to add nothing.
Content-Type is reserved: it is written after these headers and always ends up application/problem+json. A throw inside the callback is contained like every other callback — it is logged and the response goes out without the extra headers. Each header name and value, whether from this callback or from a ProblemDetailException, is checked with Node's own header validation first; an invalid entry (e.g. a value containing a newline) is dropped and logged by name, and the rest of the response is sent unchanged.
For a header that belongs to one specific occurrence rather than to a global policy, pass it at the throw site instead — see ProblemDetailException. Throw-site headers are applied first, and this callback is merged over them, so a global policy can override a throw-site value.
validationStatuses
Type: number[] | Default: [400]
The HTTP status codes at which ValidationPipe default output is treated as a Tier 1 validation error. Set this when you configure ValidationPipe({ errorHttpStatusCode }):
// main.ts
app.useGlobalPipes(new ValidationPipe({ errorHttpStatusCode: 422 }));
// app.module.ts
Rfc9457Module.forRoot({ validationStatuses: [400, 422] });Detection is an explicit allow-list because the validation response shape is indistinguishable from business HttpExceptions constructed with a message array — NestJS sets the error field to the status phrase in both cases (e.g. new ConflictException(['order already shipped']) produces { message: [...], error: 'Conflict' }). Declare only statuses your application reserves for validation; business exceptions at other statuses are never misclassified. At undeclared statuses, validation messages are still preserved by joining them into detail.
validationExceptionMapper
Type: (messages: string[], request: Rfc9457Request, status: number) => ProblemDetail
Overrides the default Tier 1 validation error response. Receives the flat string array from the exception's getResponse().message, the request, and the HTTP status the exception carried (one of validationStatuses). Only applies to Tier 1 (flat string) validation errors — Tier 2 structured errors from Rfc9457ValidationException bypass this callback.
Rfc9457Module.forRoot({
validationExceptionMapper: (messages, request, status) => ({
type: 'https://api.example.com/problems/validation-error',
title: 'Validation Error',
status, // echo the detected status — do not hard-code it
detail: 'One or more fields failed validation',
violations: messages,
}),
});Callback failure policy
The error path is total: a failure inside any user-supplied callback never replaces the problem-details response.
exceptionMapperthrows → the failure is logged (contextRfc9457ExceptionFilterwhen the mapper runs in the filter,ProblemDetailsFactorywhen it runs in the factory) and resolution continues down the standard chain (decorator → validation → HttpException → fallback).validationExceptionMapperthrows → logged (contextProblemDetailsFactory); the response falls back to the default Tier 1 validation body (status, status-phrasetitle,detail: "Request validation failed", and theerrorsarray) — it does not re-enter the resolution chain.instanceStrategythrows → logged; theinstancemember is omitted.onUnhandledthrows → logged together with the original exception; the generic 500 problem response is still sent.responseHeadersthrows → logged; the response is sent without the callback's headers.- A header entry is invalid (from
responseHeadersor aProblemDetailException) → that entry is dropped and logged by name, never by value; the remaining headers and the problem response are sent.
Callback errors are never included in the response body.
exceptionMapper, validationExceptionMapper, and instanceStrategy are
synchronous contracts — their return types don't admit a Promise, so an
async callback is rejected at compile time. onUnhandled returns void,
which means an async callback type-checks; the filter handles that case
too: if the callback returns a thenable, its rejection is caught, logged
together with the original exception, and never surfaces as an unhandled
rejection. The generic 500 response is sent synchronously either way —
the library does not await the callback.
Async Configuration
Use Rfc9457Module.forRootAsync() to inject configuration from a service such as ConfigService.
useFactory
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Rfc9457Module } from '@camcima/nestjs-rfc9457';
@Module({
imports: [
ConfigModule.forRoot(),
Rfc9457Module.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
typeBaseUri: config.get<string>('PROBLEM_TYPE_BASE_URI'),
instanceStrategy: 'uuid',
catchAllExceptions: config.get<boolean>('CATCH_ALL_EXCEPTIONS', false),
}),
}),
],
})
export class AppModule {}useClass
Implement the Rfc9457OptionsFactory interface:
import { Injectable } from '@nestjs/common';
import { Rfc9457OptionsFactory, Rfc9457ModuleOptions } from '@camcima/nestjs-rfc9457';
@Injectable()
export class Rfc9457ConfigService implements Rfc9457OptionsFactory {
createRfc9457Options(): Rfc9457ModuleOptions {
return {
typeBaseUri: 'https://api.example.com/problems',
instanceStrategy: 'uuid',
};
}
}Rfc9457Module.forRootAsync({
useClass: Rfc9457ConfigService,
});useExisting
Reuse an existing provider that implements Rfc9457OptionsFactory:
Rfc9457Module.forRootAsync({
imports: [SharedConfigModule],
useExisting: SharedConfigService,
});Custom Exception Types
Use the @ProblemType() decorator to attach RFC 9457 problem type metadata to your exception classes. The decorator stores a template with type identity fields (type, title, status). Occurrence-specific fields (detail, instance) are always resolved at runtime by the factory from the exception message and the configured instance strategy.
import { HttpException } from '@nestjs/common';
import { ProblemType } from '@camcima/nestjs-rfc9457';
@ProblemType({
type: 'https://api.example.com/problems/insufficient-funds',
title: 'Insufficient Funds',
status: 422,
})
export class InsufficientFundsException extends HttpException {
constructor(
public readonly balance: number,
public readonly required: number,
) {
super(`Balance ${balance} is less than required ${required}`, 422);
}
}When this exception is thrown, the response is:
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient Funds",
"status": 422,
"detail": "Balance 50 is less than required 100"
}The decorator accepts a ProblemTypeMetadata object:
interface ProblemTypeMetadata {
type?: string; // URI for the problem type
title?: string; // Short human-readable summary
status?: number; // HTTP status code
}All three fields are optional. If status is omitted, the factory uses exception.getStatus() for HttpException subclasses or falls back to 500 in catch-all mode. If type is omitted and typeBaseUri is configured, the slug for the status code is used.
Inheritance
Metadata lookup walks the prototype chain, so child classes automatically inherit their parent's @ProblemType() metadata:
// Parent defines the problem type
@ProblemType({
type: 'https://api.example.com/problems/payment-error',
title: 'Payment Error',
status: 402,
})
export class PaymentException extends HttpException {
constructor(message: string) {
super(message, 402);
}
}
// Child inherits parent's @ProblemType() metadata
export class CardDeclinedException extends PaymentException {
constructor() {
super('Card was declined');
}
}A child class can fully override the parent's metadata by applying its own @ProblemType() decorator. There is no merging — the child's decorator replaces the parent's entirely.
@ProblemType({
type: 'https://api.example.com/problems/card-declined',
title: 'Card Declined',
status: 402,
})
export class CardDeclinedException extends PaymentException {
constructor() {
super('Card was declined');
}
}@ProblemType() can also decorate plain Error subclasses (not extending HttpException), but these are only handled by the factory when catchAllExceptions: true is set. Because that combination silently produces NestJS's default error body instead of your problem type, the filter logs a warning (once per exception class) naming the class and how to fix it, rather than leaving you to wonder why the decorator had no effect.
ProblemDetailException: one-off problems with extension members
@ProblemType() describes a reusable problem type. When you need a one-off problem — particularly one carrying occurrence-specific extension members — throw a ProblemDetailException instead. It takes a complete problem document and passes every member through to the response body:
import { ProblemDetailException } from '@camcima/nestjs-rfc9457';
throw new ProblemDetailException({
type: 'https://api.example.com/problems/insufficient-funds',
title: 'Insufficient Funds',
status: 402,
detail: 'Your balance is too low to cover this transfer.',
balance: 30,
cost: 50,
});{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient Funds",
"status": 402,
"detail": "Your balance is too low to cover this transfer.",
"balance": 30,
"cost": 50
}status is required and must be an error status (400–599); anything else throws a RangeError at construction. Normalization still applies: a bare type slug is expanded against typeBaseUri, a missing title is filled from the status phrase, and the configured instance strategy runs.
Why this exists. A plain HttpException cannot carry extension members. Given new HttpException({ message: 'Balance too low', balance: 30 }, 402), NestJS's response object is read for its message only — balance is dropped. Since extension members are the whole point of RFC 9457's extensibility, this class is the supported way to emit them from a throw site.
Combined with @ProblemType(). Decorate a subclass to declare the reusable identity once, then supply per-occurrence data at each throw. Instance members win per-member:
@ProblemType({
type: 'https://api.example.com/problems/payment-error',
title: 'Payment Error',
status: 402,
})
export class PaymentProblem extends ProblemDetailException {}
throw new PaymentProblem({ status: 409, detail: 'Already settled', settledAt });
// type and title come from the decorator; status, detail and settledAt from the throwResponse headers. Pass headers for this occurrence as the second argument:
throw new ProblemDetailException(
{ status: 429, title: 'Too Many Requests', retryAfterSeconds: 60 },
{ headers: { 'Retry-After': '60' } },
);Precedence is unchanged: a global exceptionMapper that claims the exception still wins, and suppress5xxDetail still strips detail from a 5xx (extension members are left alone).
Validation Integration
Tier 1 — Automatic (zero config)
When NestJS's ValidationPipe rejects a request, it throws a BadRequestException whose response contains a message array of strings. The library detects this automatically and produces a structured validation error response with no configuration required.
// main.ts — standard ValidationPipe setup, nothing extra needed
app.useGlobalPipes(new ValidationPipe());Response:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Request validation failed",
"errors": ["email must be an email", "age must not be less than 0"]
}To customize the Tier 1 response, use the validationExceptionMapper option described in the Configuration section.
Custom status codes. If you configure ValidationPipe({ errorHttpStatusCode: 422 }) (or any other 4xx), declare that status in the validationStatuses module option (validationStatuses: [400, 422]) and the library produces the same structured validation response at that status, with the matching title (e.g. Unprocessable Entity). Without the declaration, the messages are still preserved — joined into detail — but the errors array is not emitted. Detection is an explicit opt-in per status because the validation output shape is indistinguishable from business exceptions constructed with message arrays.
Tier 2 — Enhanced structured errors (opt-in)
For rich, structured validation output with property, constraints, and nested children arrays, use the createRfc9457ValidationPipeExceptionFactory helper.
Step 1 — Install class-validator if you have not already:
npm install class-validator class-transformerStep 2 — Use the factory as the ValidationPipe exception factory:
// main.ts
import { ValidationPipe } from '@nestjs/common';
import { createRfc9457ValidationPipeExceptionFactory } from '@camcima/nestjs-rfc9457';
app.useGlobalPipes(
new ValidationPipe({
exceptionFactory: createRfc9457ValidationPipeExceptionFactory(),
}),
);Response for a DTO with nested validation:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Request validation failed",
"errors": [
{
"property": "email",
"constraints": {
"isEmail": "email must be an email"
}
},
{
"property": "address",
"children": [
{
"property": "zip",
"constraints": {
"isPostalCode": "zip must be a postal code"
}
}
]
}
]
}Nested validation errors are preserved as children arrays matching the class-validator ValidationError tree. They are not flattened to dotted paths (e.g., "address.zip") — the original structure is preserved.
Custom status (e.g. 422). To use a different status, configure both the pipe and the factory:
app.useGlobalPipes(
new ValidationPipe({
errorHttpStatusCode: 422,
exceptionFactory: createRfc9457ValidationPipeExceptionFactory({ status: 422 }),
}),
);createRfc9457ValidationPipeExceptionFactory throws a RangeError if status is outside the 400–599 error range.
Breaking change (vs earlier releases (<=0.4.x)):
Rfc9457ValidationExceptionnow extendsHttpExceptionrather thanBadRequestException, so its status is configurable. Code that narrows oninstanceof BadRequestExceptionno longer matches; narrow onRfc9457ValidationException(orHttpException) instead.
Swagger / OpenAPI Integration
The library ships optional Swagger support under a separate import path so it does not require @nestjs/swagger as a mandatory dependency. Install @nestjs/swagger as usual if you have not already:
npm install @nestjs/swaggerAll Swagger-related exports are imported from the /swagger subpath:
import {
ProblemDetailDto,
ValidationProblemDetailDto,
ValidationErrorDto,
applyProblemDetailResponses,
} from '@camcima/nestjs-rfc9457/swagger';Auto-applying error schemas to all controllers
The applyProblemDetailResponses() helper uses NestJS's DiscoveryService to programmatically attach @ApiResponse decorators to every controller in your application. Responses are documented under application/problem+json as required by RFC 9457.
Step 1 — Import DiscoveryModule in your app module:
import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { Rfc9457Module } from '@camcima/nestjs-rfc9457';
@Module({
imports: [DiscoveryModule, Rfc9457Module.forRoot()],
})
export class AppModule {}Step 2 — Call the helper inside the lazy document factory passed to SwaggerModule.setup():
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { applyProblemDetailResponses } from '@camcima/nestjs-rfc9457/swagger';
const config = new DocumentBuilder().setTitle('My API').build();
SwaggerModule.setup('/api', app, () => {
applyProblemDetailResponses(app);
return SwaggerModule.createDocument(app, config);
});By default, this documents 400 and 500 responses on every route using ProblemDetailDto. The generated OpenAPI spec will show application/problem+json as the response media type with the correct schema.
Idempotent by design. applyProblemDetailResponses is safe to call more than once. For a given controller and status, only the first call's options are applied — later calls for that same pair are no-ops. This means lazy document factories that run repeatedly (hot reload, multiple SwaggerModule.setup() calls for separate specs, etc.) never duplicate @ApiResponse metadata, and you don't need to guard the call site with your own "already applied" bookkeeping.
Calling it more than once. Safe: for a given controller class and status, the first call's options are applied and later calls are ignored, so lazy document factories, hot reload, and multiple
SwaggerModule.setup()calls do not duplicate metadata.One consequence is worth knowing if you run two applications in one process (an e2e suite, a monorepo harness) that share controller classes: the first application to be documented decides the options for that class, and a later call with different
validationStatusesis ignored. This is a constraint of@nestjs/swaggerrather than a caching choice —@ApiResponsestores its metadata on the class itself, so both applications necessarily read the same annotations. Applying per application would not give each its own view; it would append a second response object that@nestjs/swaggermerges into one entry with a doubled description. Give each application its own controller classes if they must be documented differently.
Options
applyProblemDetailResponses accepts an optional second argument:
interface ApplyProblemDetailResponsesOptions {
/** HTTP status codes to document. Default: [400, 500]. */
statuses?: number[];
/**
* Statuses that use ValidationProblemDetailDto (with the errors array)
* instead of the base ProblemDetailDto. Default: [].
*/
validationStatuses?: number[];
/**
* Return false to skip a controller (e.g. health-check controllers).
* Default: include all controllers.
*/
filter?: (controller: DiscoveredController) => boolean;
}Excluding controllers
Pass a filter to skip controllers you don't want documented with the default error responses — for example a health-check endpoint:
applyProblemDetailResponses(app, {
filter: (controller) => controller.metatype?.name !== 'HealthController',
});Documenting additional statuses
applyProblemDetailResponses(app, {
statuses: [400, 401, 403, 404, 500],
});Documenting Tier 2 structured validation errors
If you use Rfc9457ValidationException (Tier 2) for validation, you can tell the helper to use ValidationProblemDetailDto for specific statuses. This DTO includes the errors array of structured ValidationErrorDto objects:
applyProblemDetailResponses(app, {
statuses: [400, 500],
validationStatuses: [400],
});This documents 400 responses with the ValidationProblemDetailDto schema (which includes errors: ValidationErrorDto[]) and 500 responses with the base ProblemDetailDto.
Using DTOs manually for per-route documentation
For finer control, use the DTO classes directly with @ApiResponse() on individual routes:
import { ApiResponse } from '@nestjs/swagger';
import { ProblemDetailDto, ValidationProblemDetailDto } from '@camcima/nestjs-rfc9457/swagger';
@Get(':id')
@ApiResponse({
status: 404,
description: 'Not Found',
content: {
'application/problem+json': {
schema: { $ref: '#/components/schemas/ProblemDetailDto' },
},
},
})
findOne(@Param('id') id: string) {
// ...
}Or more concisely using the type shorthand (documents as application/json instead of application/problem+json):
@ApiResponse({ status: 404, type: ProblemDetailDto })Extending DTOs for custom extension members
If your API returns extension members (additional fields beyond the five standard RFC 9457 members), extend ProblemDetailDto to document them:
import { ApiProperty } from '@nestjs/swagger';
import { ProblemDetailDto } from '@camcima/nestjs-rfc9457/swagger';
export class InsufficientFundsProblemDto extends ProblemDetailDto {
@ApiProperty({ example: 50 })
balance!: number;
@ApiProperty({ example: 100 })
required!: number;
}Available DTOs
| DTO | Description |
| ---------------------------- | ------------------------------------------------------------------------------------ |
| ProblemDetailDto | The five standard RFC 9457 fields (type, title, status, detail, instance) |
| ValidationProblemDetailDto | Extends ProblemDetailDto with errors: ValidationErrorDto[] for Tier 2 validation |
| ValidationErrorDto | Structured validation error (property?, constraints?, children?) |
Design note
The auto-apply helper uses ProblemDetailDto for all statuses by default. This is intentional: a single HTTP status (e.g. 400) can produce different response shapes at runtime — a plain problem detail for non-validation errors, errors: string[] for Tier 1 validation, or errors: ValidationErrorDto[] for Tier 2 validation. The base DTO is the common denominator that is always correct. Use validationStatuses to opt in to the more specific schema when your application uses Tier 2 validation exclusively.
Advanced Usage
Using ProblemDetailsFactory directly
ProblemDetailsFactory is an injectable service exported by Rfc9457Module. You can inject it into any provider to produce Problem Details responses in contexts outside the standard HTTP filter — for example, GraphQL error formatters or microservice exception handlers.
import { Injectable } from '@nestjs/common';
import { ProblemDetailsFactory, Rfc9457Request } from '@camcima/nestjs-rfc9457';
@Injectable()
export class GraphQLErrorFormatter {
constructor(private readonly problemDetailsFactory: ProblemDetailsFactory) {}
format(exception: unknown, context: { path: string; method: string }) {
const request: Rfc9457Request = {
url: context.path,
method: context.method,
};
const { status, body } = this.problemDetailsFactory.create(exception, request);
return { extensions: { problem: body, httpStatus: status } };
}
}The create method signature is:
create(exception: unknown, request: Rfc9457Request): { status: number; body: ProblemDetail }statusis the definitive HTTP status code to use for the transport layer.bodyis the RFC 9457 Problem Details object to serialize.
The factory applies the full resolution chain (mapper → decorator → validation → default → fallback) and all normalization rules (type, instance, title) regardless of how it is called.
Custom exception filter
You can build your own filter on top of ProblemDetailsFactory if you need to intercept specific exception types before the global filter sees them:
import { Catch, ArgumentsHost } from '@nestjs/common';
import { BaseExceptionFilter, HttpAdapterHost } from '@nestjs/core';
import { ProblemDetailsFactory, PROBLEM_CONTENT_TYPE } from '@camcima/nestjs-rfc9457';
@Catch(MySpecialException)
export class MySpecialExceptionFilter extends BaseExceptionFilter {
constructor(
private readonly factory: ProblemDetailsFactory,
private readonly adapterHost: HttpAdapterHost,
) {
super(adapterHost.httpAdapter);
}
catch(exception: MySpecialException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest();
const response = ctx.getResponse();
const { status, body } = this.factory.create(exception, request);
// Write through the HTTP adapter so the filter works on Express and
// Fastify alike, and set the RFC 9457 media type explicitly — the
// adapter's default is application/json, which would make the response
// non-conformant even though the body is correct. Pass a serialized
// string: given an object whose `statusCode` is >= 400, both adapters
// reset Content-Type to application/json.
const httpAdapter = this.adapterHost.httpAdapter;
httpAdapter.setHeader(response, 'Content-Type', PROBLEM_CONTENT_TYPE);
httpAdapter.reply(response, JSON.stringify(body), status);
}
}
Content-Type: application/problem+jsonis what tells a client the body is a problem document. If you write the response yourself withres.json(...)you getapplication/jsonand lose that signal — always set the header, whichever mechanism you use.
API Reference
| Export | Kind | Description |
| ---------------------------------------------- | ---------------- | --------------------------------------------------------------------------- |
| Rfc9457Module | Class | Dynamic module. Use forRoot(options?) or forRootAsync(options) |
| ProblemDetailsFactory | Injectable class | Core resolver; injectable for use outside the HTTP filter |
| Rfc9457ExceptionFilter | Injectable class | Global exception filter; registered automatically by the module |
| ProblemType | Decorator | Class decorator that attaches problem type metadata to exception classes |
| ProblemDetailException | Class | Throw a complete problem document, extension members and headers included |
| ProblemDetailExceptionOptions | Interface | Options for ProblemDetailException (headers) |
| ProblemDetailWithStatus | Type | ProblemDetail with a required status — the ProblemDetailException arg |
| ProblemDetail | Interface | RFC 9457 response body shape with index signature for extension members |
| ProblemTypeMetadata | Interface | Decorator options (type, title, status) |
| Rfc9457ModuleOptions | Interface | Options accepted by forRoot() |
| Rfc9457OptionsFactory | Interface | Implement for useClass / useExisting async patterns |
| Rfc9457AsyncModuleOptions | Interface | Options accepted by forRootAsync() |
| InstanceStrategy | Type | Union type for instanceStrategy option |
| Rfc9457Request | Interface | Minimal request context compatible with Express and Fastify |
| Rfc9457ValidationException | Class | Exception wrapping structured ValidationError[]; thrown by Tier 2 factory |
| createRfc9457ValidationPipeExceptionFactory | Function | Returns an exceptionFactory for ValidationPipe to enable Tier 2 errors |
| Rfc9457ValidationPipeExceptionFactoryOptions | Interface | Options for createRfc9457ValidationPipeExceptionFactory (status) |
| RFC9457_MODULE_OPTIONS | Symbol | DI token for the module options |
| PROBLEM_CONTENT_TYPE | Constant | 'application/problem+json' |
Swagger subpath (@camcima/nestjs-rfc9457/swagger):
| Export | Kind | Description |
| ------------------------------------ | --------- | ------------------------------------------------------------------------------------- |
| ProblemDetailDto | Class | Swagger DTO for the five standard RFC 9457 fields |
| ValidationProblemDetailDto | Class | Extends ProblemDetailDto with errors: ValidationErrorDto[] |
| ValidationErrorDto | Class | Swagger DTO for a structured validation error (property, constraints, children) |
| applyProblemDetailResponses | Function | Auto-applies @ApiResponse decorators to all controllers via DiscoveryService |
| ApplyProblemDetailResponsesOptions | Interface | Options for applyProblemDetailResponses |
| DiscoveredController | Interface | Structural controller view passed to the filter option |
Example Responses
Basic 404 (no typeBaseUri)
throw new NotFoundException('User 42 not found');{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "User 42 not found"
}Basic 404 (with typeBaseUri and instanceStrategy: 'request-uri')
Rfc9457Module.forRoot({
typeBaseUri: 'https://api.example.com/problems',
instanceStrategy: 'request-uri',
});
throw new NotFoundException('User 42 not found');
// request path: /api/users/42{
"type": "https://api.example.com/problems/not-found",
"title": "Not Found",
"status": 404,
"detail": "User 42 not found",
"instance": "/api/users/42"
}Validation error (Tier 2 structured)
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Request validation failed",
"errors": [
{
"property": "email",
"constraints": {
"isEmail": "email must be an email"
}
},
{
"property": "address",
"children": [
{
"property": "zip",
"constraints": {
"isPostalCode": "zip must be a postal code"
}
}
]
}
]
}Custom problem type with @ProblemType()
@ProblemType({
type: 'https://api.example.com/problems/insufficient-funds',
title: 'Insufficient Funds',
status: 422,
})
export class InsufficientFundsException extends HttpException {
/* ... */
}
throw new InsufficientFundsException(50, 100);{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient Funds",
"status": 422,
"detail": "Balance 50 is less than required 100"
}Catch-all 500 (with catchAllExceptions: true)
throw new Error('Connection refused');{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500
}Internal error messages are never included in the response to avoid leaking sensitive information.
Examples
See the nestjs-rfc9457-examples repository for complete working NestJS applications demonstrating all features, including runnable demo scripts.
Security
Vulnerability reports go through GitHub Security Advisories; see SECURITY.md for scope and what to include.
CI
| Tool | Purpose | Trigger | | --------------- | -------------------------------------------------------- | ------------------------- | | CodeQL | Static analysis for security vulnerabilities | Push, PR, weekly schedule | | OSV-Scanner | Dependency vulnerability scanning (production deps only) | Push, PR | | Dependabot | Automated dependency and GitHub Actions updates | Weekly PRs | | Codecov | Test coverage tracking | Push, PR |
Local (via Lefthook)
| Hook | Tool | Purpose |
| ------------ | ------------------------------------------------ | ---------------------------- |
| pre-commit | ESLint + Prettier | Code quality on staged files |
| pre-push | Gitleaks | Secret scanning before push |
Gitleaks must be installed locally. The pre-push hook will skip if Gitleaks is not available.
Manual local checks
# Dependency audit (production only)
pnpm run audit:deps
# Secret scanning
pnpm run audit:secrets
# Full pnpm audit (all dependencies)
pnpm auditContributing
Contributions are welcome. Please open an issue before submitting a pull request for significant changes.
# Clone the repository
git clone https://github.com/camcima/nestjs-rfc9457.git
cd nestjs-rfc9457
# Install dependencies
pnpm install
# Run unit tests
pnpm run test:unit
# Run e2e tests
pnpm run test:e2e
# Run all tests with coverage
pnpm run test:cov
# Build
pnpm run buildThis project uses Conventional Commits enforced by commitlint, and Lefthook for pre-commit hooks (lint + format on staged files) plus a pre-push gitleaks scan of the commits being pushed.
CHANGELOG.md is generated from those commit messages when a release is cut, so a pull request no longer needs a hand-written changelog entry — the commit subject is the entry. Entries written by hand before this switch are kept: the generator only prepends the new release section.
