nestjs-safe-response
v0.9.0
Published
Standardized API response wrapper for NestJS with Swagger integration, pagination, and custom error code mapping
Maintainers
Readme
nestjs-safe-response
Standardized API response wrapper for NestJS — auto-wraps success/error responses, pagination metadata, and Swagger schema generation with a single module import.
Features
- Automatic response wrapping — all controller returns wrapped in
{ success, statusCode, data }structure - Error standardization — exceptions converted to
{ success: false, error: { code, message, details } } - Pagination metadata — offset (
page/limit/total) and cursor (nextCursor/hasMore) pagination with auto-calculated meta and HATEOAS links - Sort/Filter metadata —
@SortMeta()and@FilterMeta()decorators to include sorting and filtering info in responsemeta - Request ID tracking — opt-in
requestIdfield in all responses with incoming header reuse, auto-generation, and response header propagation - Response time — opt-in
meta.responseTime(ms) for performance monitoring - RFC 9457 Problem Details — opt-in standard error format with
application/problem+json - Swagger integration —
@ApiSafeResponse(Dto)for success schemas,@ApiSafeErrorResponse()/@ApiSafeErrorResponses()for error schemas — all with the wrapped envelope - Global error Swagger —
applyGlobalErrors()injects common error responses (401, 403, 500) into all OpenAPI operations - Frontend client types —
nestjs-safe-response/clientprovides zero-dependency TypeScript types and type guards for frontend consumers - nestjs-i18n integration — automatic error/success message translation via adapter pattern
- nestjs-cls integration — inject CLS store values (traceId, correlationId) into response
meta - class-validator support — validation errors parsed into
detailsarray with "Validation failed" message - Custom error codes — map exceptions to machine-readable codes via
errorCodeMapper - Opt-out per route —
@RawResponse()skips wrapping for health checks, SSE, file downloads - Platform-agnostic — works with both Express and Fastify adapters out of the box
- Context-safe — automatically skips wrapping for non-HTTP contexts (RPC, WebSocket)
- Dynamic Module —
register()/registerAsync()with full DI support
Installation
npm install nestjs-safe-responsePeer Dependencies
npm install @nestjs/common @nestjs/core @nestjs/swagger rxjs reflect-metadataQuick Start
import { Module } from '@nestjs/common';
import { SafeResponseModule } from 'nestjs-safe-response';
@Module({
imports: [SafeResponseModule.register()],
})
export class AppModule {}That's it. All routes now return standardized responses.
With Fastify
Works the same way — no extra configuration needed:
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
await app.listen(3000);Response Format
Success
{
"success": true,
"statusCode": 200,
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"data": { "id": 1, "name": "John" },
"timestamp": "2025-03-21T12:00:00.000Z",
"path": "/api/users/1"
}
requestIdis only present whenrequestIdoption is enabled. See Request ID.
Error
{
"success": false,
"statusCode": 400,
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"error": {
"code": "BAD_REQUEST",
"message": "Validation failed",
"details": ["email must be an email", "name should not be empty"]
},
"timestamp": "2025-03-21T12:00:00.000Z",
"path": "/api/users"
}Decorators
@ApiSafeResponse(Model)
Documents the Swagger data field with a specific DTO type.
@Get(':id')
@ApiSafeResponse(UserDto)
async findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}Options: isArray, statusCode, description
@ApiPaginatedSafeResponse(Model)
Generates paginated Swagger schema with meta.pagination.
@Get()
@Paginated({ maxLimit: 100 })
@ApiPaginatedSafeResponse(UserDto)
async findAll(@Query('page') page = 1, @Query('limit') limit = 20) {
const [items, total] = await this.usersService.findAndCount({
skip: (page - 1) * limit,
take: limit,
});
return { data: items, total, page, limit };
}Response:
{
"success": true,
"statusCode": 200,
"data": [{ "id": 1 }, { "id": 2 }],
"meta": {
"pagination": {
"type": "offset",
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5,
"hasNext": true,
"hasPrev": false
}
}
}@ApiCursorPaginatedSafeResponse(Model)
Generates cursor-paginated Swagger schema with meta.pagination.
@Get()
@CursorPaginated()
@ApiCursorPaginatedSafeResponse(UserDto)
async findAll(@Query('cursor') cursor?: string, @Query('limit') limit = 20) {
const { items, nextCursor, hasMore, totalCount } =
await this.usersService.findWithCursor({ cursor, limit });
return { data: items, nextCursor, hasMore, limit, totalCount };
}Response:
{
"success": true,
"statusCode": 200,
"data": [{ "id": 1 }, { "id": 2 }],
"meta": {
"pagination": {
"type": "cursor",
"nextCursor": "eyJpZCI6MTAwfQ==",
"previousCursor": null,
"hasMore": true,
"limit": 20,
"totalCount": 150
}
}
}The handler must return a CursorPaginatedResult<T>:
interface CursorPaginatedResult<T> {
data: T[];
nextCursor: string | null;
previousCursor?: string | null; // defaults to null
hasMore: boolean;
limit: number;
totalCount?: number; // optional
}@ApiSafeErrorResponse(status, options?)
Documents an error response in Swagger with the SafeErrorResponseDto envelope. Error codes are auto-resolved from DEFAULT_ERROR_CODE_MAP.
@Get(':id')
@ApiSafeResponse(UserDto)
@ApiSafeErrorResponse(404)
@ApiSafeErrorResponse(400, {
code: 'VALIDATION_ERROR',
message: 'Input validation failed',
details: ['email must be an email'],
})
async findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}Options: description, code, message, details
Note: This decorator generates build-time Swagger metadata only. If you use a custom
errorCodeMapperat runtime, the decorator cannot reflect those dynamic codes automatically — pass thecodeoption explicitly to match your runtime mapping.
The details field schema is automatically inferred from the example value:
- Array →
{ type: 'array', items: { type } }(item type inferred from first element: object, number, or string) object→{ type: 'object' }string→{ type: 'string' }
@ApiSafeErrorResponses(configs)
Documents multiple error responses at once. Accepts an array of status codes or config objects.
@Post()
@ApiSafeResponse(UserDto, { statusCode: 201 })
@ApiSafeErrorResponses([400, 401, 409])
async create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
// With mixed configuration
@Post('register')
@ApiSafeErrorResponses([
400,
{ status: 401, description: 'Token expired' },
{ status: 409, code: 'EMAIL_TAKEN', message: 'Email already registered' },
])
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}@RawResponse()
Skips response wrapping for this route.
@Get('health')
@RawResponse()
healthCheck() {
return { status: 'ok' };
}Note: If your controller returns a
BufferorStream, use@RawResponse()to skip response wrapping. Without it, binary data will be serialized as{ type: 'Buffer', data: [...] }, which corrupts the original content.
@ResponseMessage(message)
Adds a custom message to meta.message.
@Post()
@ResponseMessage('User created successfully')
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}@SafeResponse(options?)
Applies standard wrapping + basic Swagger schema. Options: description, statusCode.
@Paginated(options?)
Enables offset pagination metadata auto-calculation. Options: maxLimit, links.
@Get()
@Paginated({ maxLimit: 100, links: true }) // HATEOAS navigation links
findAll() { ... }When links: true, the response includes navigation links in meta.pagination.links:
{
"meta": {
"pagination": {
"type": "offset",
"page": 2, "limit": 20, "total": 100, "totalPages": 5,
"links": {
"self": "/api/users?page=2&limit=20",
"first": "/api/users?page=1&limit=20",
"prev": "/api/users?page=1&limit=20",
"next": "/api/users?page=3&limit=20",
"last": "/api/users?page=5&limit=20"
}
}
}
}@CursorPaginated(options?)
Enables cursor-based pagination metadata auto-calculation. Options: maxLimit, links.
@ProblemType(typeUri: string)
Set the RFC 9457 problem type URI for a specific route. Used when problemDetails is enabled.
@Get(':id')
@ProblemType('https://api.example.com/problems/user-not-found')
findOne(@Param('id') id: string) { ... }@SuccessCode(code: string)
Set a custom success code for this route (method-level only). Takes priority over successCodeMapper module option.
@Get()
@SuccessCode('FETCH_SUCCESS')
findAll() {
return this.usersService.findAll();
}Response:
{
"success": true,
"statusCode": 200,
"code": "FETCH_SUCCESS",
"data": [...]
}Request ID
Enable request ID tracking to include a unique identifier in every response — essential for production debugging and distributed tracing.
SafeResponseModule.register({
requestId: true, // auto-generate UUID v4, read from X-Request-Id header
})Behavior:
- Checks incoming
X-Request-Idheader — reuses the value if present - If no header, generates a UUID v4 via
crypto.randomUUID()(no external dependencies) - Includes
requestIdfield in both success and error response bodies - Sets
X-Request-Idresponse header for downstream tracking
Custom Options
SafeResponseModule.register({
requestId: {
headerName: 'X-Correlation-Id', // custom header name (default: 'X-Request-Id')
generator: () => `req-${Date.now()}`, // custom ID generator
},
})Response Time
Track handler execution time in every response — useful for performance monitoring and SLA tracking.
SafeResponseModule.register({
responseTime: true, // adds meta.responseTime (milliseconds) to all responses
})Response:
{
"success": true,
"statusCode": 200,
"data": { "..." },
"meta": { "responseTime": 42 }
}Uses performance.now() for high-resolution timing. Included in both success and error responses (when the interceptor ran before the error).
RFC 9457 Problem Details
Enable RFC 9457 standard error responses — used by Stripe, GitHub, and Cloudflare.
SafeResponseModule.register({
problemDetails: true, // or { baseUrl: 'https://api.example.com/problems' }
})Error response:
{
"type": "https://api.example.com/problems/not-found",
"title": "Not Found",
"status": 404,
"detail": "User with ID 123 not found",
"instance": "/api/users/123",
"code": "NOT_FOUND",
"requestId": "abc-123"
}- Sets
Content-Type: application/problem+jsonautomatically - Uses
@ProblemType(uri)decorator for per-route type URIs, or auto-generates frombaseUrl+ error code - Preserves extension members:
code,requestId,details(validation errors),meta.responseTime - Success responses are not affected — only error responses change format
- Use
@ApiSafeProblemResponse(status)for Swagger documentation
Module Options
SafeResponseModule.register({
timestamp: true, // include timestamp field (default: true)
path: true, // include path field (default: true)
requestId: true, // include request ID tracking (default: false)
responseTime: true, // include response time in meta (default: false)
problemDetails: true, // RFC 9457 error format (default: false)
errorCodeMapper: (exception) => {
if (exception instanceof TokenExpiredError) return 'TOKEN_EXPIRED';
return undefined; // fall back to default mapping
},
dateFormatter: () => new Date().toISOString(), // custom date format
})Async Registration
SafeResponseModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
timestamp: config.get('RESPONSE_TIMESTAMP', true),
}),
inject: [ConfigService],
})Additional Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| requestId | boolean \| RequestIdOptions | undefined | Enable request ID tracking in responses |
| responseTime | boolean | false | Include response time (ms) in meta.responseTime |
| problemDetails | boolean \| ProblemDetailsOptions | false | Enable RFC 9457 Problem Details error format |
| successCodeMapper | (statusCode: number) => string \| undefined | undefined | Maps HTTP status codes to success code strings |
| transformResponse | (data: unknown) => unknown | undefined | Transform data before response wrapping (sync only) |
| swagger | SwaggerOptions | undefined | Swagger documentation options (e.g., globalErrors) |
| context | ContextOptions | undefined | Inject CLS store values (traceId, etc.) into response meta. Requires nestjs-cls. |
| i18n | boolean \| I18nAdapter | undefined | Enable i18n for error/success messages. true auto-detects nestjs-i18n, or pass a custom adapter. |
Success Code Mapping
SafeResponseModule.register({
successCodeMapper: (statusCode) => {
const map: Record<number, string> = { 200: 'OK', 201: 'CREATED' };
return map[statusCode];
},
})Response Transformation
SafeResponseModule.register({
transformResponse: (data) => {
if (data && typeof data === 'object' && 'password' in data) {
const { password, ...rest } = data as Record<string, unknown>;
return rest;
}
return data;
},
})@Exclude() Integration
Using with class-transformer
nestjs-safe-response works with NestJS's ClassSerializerInterceptor when registered in the correct order. SafeResponseModule must be imported before ClassSerializerInterceptor is registered, so that serialization runs first and response wrapping runs second.
import { ClassSerializerInterceptor, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({
imports: [SafeResponseModule.register()],
providers: [
{ provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor },
],
})
export class AppModule {}Default Error Code Mapping
| HTTP Status | Error Code |
|-------------|------------|
| 400 | BAD_REQUEST |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT |
| 422 | UNPROCESSABLE_ENTITY |
| 429 | TOO_MANY_REQUESTS |
| 500 | INTERNAL_SERVER_ERROR |
Override with errorCodeMapper option.
Testing & Reliability
This library is built with multiple layers of verification to ensure production reliability.
Test Suite
| Category | Count | What it covers |
|----------|-------|----------------|
| Unit tests | 278 | Interceptor, Exception Filter, Module DI, Decorators, Client Type Guards, i18n Adapter, Global Errors |
| E2E tests (Express) | 45 | Full HTTP request/response cycle including all v0.9.0 features |
| E2E tests (Fastify) | 14 | Platform parity verification with v0.9.0 features |
| E2E tests (Swagger) | 41 | OpenAPI schema output verification including Problem Details and Global Errors |
| Type tests | 72 | Public API type signature via tsd including client types and v0.9.0 options |
| Snapshots | 2 | Swagger components/schemas + paths regression detection |
npm test # unit tests
npm run test:e2e # E2E tests (Express + Fastify + Swagger)
npm run test:cov # unit tests with coverage (90%+ enforced)
npm run test:types # public API type verification
npm run bench # performance benchmarkCI Pipeline
Every push runs the full matrix on GitHub Actions:
Node 18/20/22 × NestJS 10/11 × @nestjs/swagger 8/11
→ build → test:cov (threshold enforced) → test:e2e → test:typesCoverage Threshold
Enforced in CI — the build fails if coverage drops below:
| Metric | Threshold | |--------|-----------| | Lines | 90% | | Statements | 90% | | Branches | 80% | | Functions | 60% |
OpenAPI Schema Validation
Generated Swagger documents are validated against the OpenAPI spec using @apidevtools/swagger-parser in E2E tests. If the library produces an invalid OpenAPI schema, the tests fail.
API Contract Snapshots
Swagger components/schemas and paths are snapshot-tested. Any unintended schema change breaks the test — update snapshots with npx jest --config test/jest-e2e.json -u when schema changes are intentional.
Performance
Example benchmark results (npm run bench, 500 iterations, single run on one machine — your results will vary):
| Path | Raw NestJS | With nestjs-safe-response | Overhead | |------|-----------|--------------------------|----------| | Success (200) | ~0.5ms | ~0.6ms | < 0.1ms | | Error (404) | ~0.7ms | ~0.6ms | negligible |
Response wrapping overhead is sub-millisecond. The benchmark uses supertest in a single-process setup, so absolute numbers fluctuate across environments. Run npm run bench in your own environment for representative results.
Compatibility
| Dependency | Version | |------------|---------| | NestJS | v10, v11 | | Platform | Express, Fastify | | @nestjs/swagger | v8, v11 | | Node.js | >= 18 | | RxJS | v7 |
License
MIT
