@nestarc/safe-response
v0.16.0
Published
Standardized API response wrapper for NestJS with Swagger integration, pagination, and custom error code mapping
Maintainers
Readme
@nestarc/safe-response
Standardized API response wrapper for NestJS — a global module for success/error envelopes, pagination metadata, and decorators for matching Swagger schemas.
Features
- Automatic response wrapping — HTTP controller return values wrapped in
{ success, statusCode, data }, with route and file-response opt-outs - Error standardization — exceptions converted to
{ success: false, error: { code, message, details } } - Field selection (Partial Response) — Google-style
?fields=id,namequery parameter to select specific response fields, with dot-notation, quotas, and prototype-safe path handling - Error catalog —
defineErrors()for centralized error definitions, typedSafeExceptionfactories, and catalog-backed Swagger error decorators - 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 - API version metadata — opt-in
meta.apiVersionfield in all responses for version-aware clients - StreamableFile auto-detection — automatic skip of response wrapping for
StreamableFilereturns (no@RawResponse()needed) - Frontend client types —
@nestarc/safe-response/clientprovides TypeScript types and type guards without server runtime imports (isSuccess,isError,isPaginated,isProblemDetailsResponse,hasResponseTime,hasSort,hasFilters,isDeprecated,hasRateLimit,hasFieldSelection) for frontend consumers - nestjs-i18n integration — automatic error/success message translation via adapter pattern
- API deprecation —
@Deprecated()decorator with RFC 9745/8594Deprecation/Sunsetheaders, Swaggerdeprecated: true, and responsemeta.deprecation - Rate limit metadata — opt-in
meta.rateLimitmirroring ofX-RateLimit-*response headers for frontend consumption - 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 - Composite decorators —
@SafeEndpoint(),@SafePaginatedEndpoint(),@SafeCursorPaginatedEndpoint()combine Swagger + runtime + error docs in a single decorator - Declarative error codes —
errorCodesoption for simple status-to-code mapping without writing a mapper function - Shape-mismatch warnings —
@Paginated(),@CursorPaginated(),@SortMeta(),@FilterMeta()warn when handler data doesn't match expected shape - 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 @nestarc/safe-responsePeer Dependencies
Use the matching major versions for your application; keep the NestJS HTTP adapter on the same major as @nestjs/core.
# NestJS 11 (Node.js >= 20)
npm install @nestjs/common@^11 @nestjs/core@^11 @nestjs/swagger@^11 rxjs@^7 reflect-metadata@^0.2
# NestJS 10 (Node.js >= 18)
npm install @nestjs/common@^10 @nestjs/core@^10 @nestjs/swagger@^8 rxjs@^7 reflect-metadata@^0.2Choose one combination. nestjs-i18n (^10 || ^11) and nestjs-cls (^4 || ^5) are optional peers, needed only for their integrations.
Quick Start
import { Module } from '@nestjs/common';
import { SafeResponseModule } from '@nestarc/safe-response';
@Module({
imports: [SafeResponseModule.register()],
})
export class AppModule {}Register the module once in your root module. It wraps HTTP handler return values and formats errors handled by its global filter. @RawResponse() and StreamableFile bypass success wrapping; manually sent responses and HTTP responses without a body (such as 204) do not receive a JSON envelope. Examples below may omit optional timestamp and path fields for brevity.
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.
When a handler returns undefined, JSON omits data; null remains data: null. The untyped Swagger response allows omission, while DTO and pagination decorators require data. SafeSuccessResponse<T> also allows omission when T includes undefined (including void and the default unknown), while concrete payload types keep data required with TypeScript strictNullChecks enabled.
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.
import { BadRequestException, DefaultValuePipe, Get, ParseIntPipe, Query } from '@nestjs/common';
@Get()
@Paginated({ maxLimit: 100 })
@ApiPaginatedSafeResponse(UserDto)
async findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) requestedLimit: number,
) {
if (page < 1 || requestedLimit < 1) throw new BadRequestException('page and limit must be positive');
const limit = Math.min(requestedLimit, 100);
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.
import { BadRequestException, DefaultValuePipe, Get, ParseIntPipe, Query } from '@nestjs/common';
@Get()
@CursorPaginated({ maxLimit: 100 })
@ApiCursorPaginatedSafeResponse(UserDto)
async findAll(
@Query('cursor') cursor: string | undefined,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) requestedLimit: number,
) {
if (requestedLimit < 1) throw new BadRequestException('limit must be positive');
const limit = Math.min(requestedLimit, 100);
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 Swagger metadata only. It does not read module-level
errorCodesor runerrorCodeMapper— 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);
}@ApiSafeCatalogError(catalog, key) / @ApiSafeCatalogErrors(catalog, keys)
Documents error responses directly from a defineErrors() catalog. The error code
example is the catalog key, while status, message, description, and details come
from the catalog definition unless overridden.
const errors = defineErrors({
USER_NOT_FOUND: {
status: 404,
message: 'User not found',
description: 'The requested user does not exist',
},
EMAIL_TAKEN: { status: 409, message: 'Email already registered' },
});
@Get(':id')
@ApiSafeResponse(UserDto)
@ApiSafeCatalogError(errors, 'USER_NOT_FOUND')
findOne() { ... }
@Post()
@ApiSafeCatalogErrors(errors, ['USER_NOT_FOUND', 'EMAIL_TAKEN'])
create() { ... }@RawResponse()
Skips response wrapping for this route.
@Get('health')
@RawResponse()
healthCheck() {
return { status: 'ok' };
}For binary responses, return new StreamableFile(bufferOrStream) from @nestjs/common; wrapping is skipped automatically on Express and Fastify. @RawResponse() only disables this package's success interceptor and does not make a raw Buffer or stream portable across adapters. Exceptions still pass through the global exception filter.
@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?)
Documents the basic Swagger envelope. Runtime wrapping comes from SafeResponseModule, not this decorator. Options: description, statusCode.
Swagger statusCode options describe the response; use NestJS @HttpCode() or the adapter's passthrough response API to change the actual HTTP status.
@Paginated(options?)
Enables offset pagination metadata auto-calculation. Options: maxLimit, links.
Return numeric pagination fields, using ParseIntPipe for query parameters. maxLimit clamps response metadata only; validate and cap the limit before querying your service, as in the examples above. The decorator does not truncate returned arrays. The same rules apply to cursor pagination.
@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": [{ "id": 1, "name": "John" }]
}@SortMeta() / @FilterMeta()
Include sorting and filtering metadata in the response. The handler must return sort and/or filters fields alongside the data.
@Get()
@Paginated()
@SortMeta()
@FilterMeta()
@ApiPaginatedSafeResponse(UserDto)
async findAll(
@Query('sortBy') sortBy = 'createdAt',
@Query('order') order: 'asc' | 'desc' = 'desc',
@Query('status') status?: string,
) {
const [items, total] = await this.usersService.findAndCount({ sortBy, order, status });
return {
data: items, total, page: 1, limit: 20,
sort: { field: sortBy, order },
filters: { ...(status && { status }) },
};
}Response:
{
"success": true,
"statusCode": 200,
"data": [{ "id": 1, "name": "John" }],
"meta": {
"pagination": { "type": "offset", "page": 1, "limit": 20, "total": 100, "totalPages": 5, "hasNext": true, "hasPrev": false },
"sort": { "field": "createdAt", "order": "desc" },
"filters": { "status": "active" }
}
}@Deprecated(options?)
Mark an endpoint as deprecated. Sets deprecation headers and Swagger deprecated: true.
Options: since, sunset, message, link
@SkipGlobalErrors()
Excludes a route from applyGlobalErrors() global error injection. Useful for routes with custom error schemas.
Composite Decorators
Combine Swagger documentation, runtime behavior, and error responses into a single decorator.
@SafeEndpoint(Model, options?)
@Get()
@SafeEndpoint(UserDto, {
isArray: true,
description: 'List users',
errors: [401, { status: 404, code: 'USER_NOT_FOUND' }],
message: 'Users fetched',
})
findAll() { ... }Equivalent to stacking @ApiSafeResponse() + @ResponseMessage() + @ApiSafeErrorResponses().
Options: statusCode, isArray, description, sort, filter, message, code, errors, deprecated, fieldSelection, problemDetails, errorFormat
@SafePaginatedEndpoint(Model, options?)
@Get()
@SafePaginatedEndpoint(UserDto, {
maxLimit: 100,
links: true,
errors: [401],
})
findAll() { ... }Equivalent to @ApiPaginatedSafeResponse() + @Paginated() + @ApiSafeErrorResponses().
Options: maxLimit, links, sort, filter, description, message, code, errors, deprecated, fieldSelection, problemDetails, errorFormat
@SafeCursorPaginatedEndpoint(Model, options?)
@Get()
@SafeCursorPaginatedEndpoint(UserDto, {
maxLimit: 50,
errors: [401],
})
findAll() { ... }Equivalent to @ApiCursorPaginatedSafeResponse() + @CursorPaginated() + @ApiSafeErrorResponses().
Options: maxLimit, links, sort, filter, description, message, code, errors, deprecated, fieldSelection, problemDetails, errorFormat
problemDetailsoption: Whentrue, error responses useapplication/problem+jsonschema in Swagger. Must match the module-levelproblemDetailssetting — this option only controls Swagger documentation, not runtime behavior.
errorFormatoption: PrefererrorFormat: 'problem' | 'safe'for new composite decorator code. It documents the intended error schema and takes priority overproblemDetailswhen both are provided.
Global Error Swagger Documentation
Inject common error responses (e.g., 401, 403, 500) into all OpenAPI operations at once, instead of decorating every route.
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { applyGlobalErrors, SafeResponseModule, SafeResponseModuleOptions } from '@nestarc/safe-response';
// 1. Share these options with the root module and Swagger bootstrap
const moduleOptions: SafeResponseModuleOptions = {
swagger: { globalErrors: [401, 403, { status: 500, message: 'Unexpected error' }] },
};
// In AppModule's imports:
SafeResponseModule.register(moduleOptions);
// 2. Apply after document creation
const config = new DocumentBuilder().setTitle('My API').build();
const document = SwaggerModule.createDocument(app, config);
applyGlobalErrors(document, moduleOptions); // mutates document in-place
SwaggerModule.setup('api', app, document);The swagger module option alone does not modify the document; call applyGlobalErrors() explicitly. Existing responses for the same status are preserved. Use @SkipGlobalErrors() on routes that should not receive global error schemas.
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:
- Reads the first incoming
X-Request-Idvalue, truncates to 128 characters, and removes characters outsideA–Z,a–z,0–9,-,_,.,~ - If no usable value remains, uses the configured generator or
crypto.randomUUID(); generator output is sanitized too, with UUID fallback if it is empty or throws - 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
Measure elapsed time from this interceptor to response construction. This includes downstream pipes, interceptors, and the handler, but excludes earlier guards and network transfer.
SafeResponseModule.register({
responseTime: true, // adds meta.responseTime (milliseconds) when timing is available
})Response:
{
"success": true,
"statusCode": 200,
"data": { "id": 1, "name": "John" },
"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 Problem Details error responses.
SafeResponseModule.register({
problemDetails: { baseUrl: 'https://api.example.com/problems' },
requestId: true,
})For a request with X-Request-Id: abc-123 that throws new NotFoundException('User with ID 123 not found'):
{
"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 - With
problemDetails: trueand no@ProblemType(),typedefaults toabout:blank - 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, { description, code, message, details })for Swagger documentation; the options object is optional
Frontend Client Types
Public package imports support TypeScript's node, nodenext, and bundler module resolution. The /client declarations include a typesVersions mapping for legacy node resolution; CJS and ESM continue to use the package's exports map.
In 0.16.0, SafeSuccessResponse<T> becomes a conditional type alias. Replace generic interface extensions or declaration merging with an intersection such as type AppSuccess<T> = SafeSuccessResponse<T> & { traceId: string }.
@nestarc/safe-response/client has no runtime imports of NestJS, Swagger, or reflect-metadata. The npm package still declares those server peer dependencies, so package managers may install or require them even when an application imports only /client.
import type { SafeAnyResponse } from '@nestarc/safe-response/client';
import {
isSuccess, isError, isPaginated, isOffsetPagination, isCursorPagination,
isProblemDetailsResponse, hasResponseTime, hasSort, hasFilters,
isDeprecated, hasRateLimit, hasFieldSelection,
} from '@nestarc/safe-response/client';
// SafeAnyResponse includes success, error, and Problem Details responses
const res: SafeAnyResponse<User[]> = await fetch('/api/users').then(r => r.json());
if (isSuccess(res)) {
console.log(res.data); // User[]
if (isPaginated(res.meta) && isOffsetPagination(res.meta.pagination)) {
console.log(`Page ${res.meta.pagination.page} of ${res.meta.pagination.totalPages}`);
}
}
if (isError(res)) {
console.error(res.error.code, res.error.message);
}
// RFC 9457 Problem Details (different shape from standard errors)
if (isProblemDetailsResponse(res)) {
console.error(res.type, res.detail, res.instance);
}
if (isSuccess(res) && hasFieldSelection(res.meta)) {
console.log(res.meta.fields);
}Internationalization (i18n)
Translate error messages and @ResponseMessage() values automatically via nestjs-i18n or a custom adapter.
// Requires nestjs-i18n installed and I18nModule configured in the app
SafeResponseModule.register({ i18n: true });
// Or provide a custom adapter
SafeResponseModule.register({
i18n: {
translate: (key, opts) => myTranslator.t(key, opts?.lang),
resolveLanguage: (request) => {
const req = request as { headers?: Record<string, string | string[] | undefined> };
const header = req.headers?.['accept-language'];
return typeof header === 'string' ? header.split(',')[0].split(';')[0].trim() || 'en' : 'en';
},
},
});i18n: true resolves the configured I18nService; installing the optional peer alone does not register it. A custom adapter does not require nestjs-i18n. Custom adapters are exception-safe — if translate() or resolveLanguage() throws, the original untranslated message is used.
Context Injection (nestjs-cls)
Inject request-scoped context values (e.g., traceId, correlationId) into response meta. Requires nestjs-cls, a configured ClsModule, and an active CLS context established by its middleware, guard, or interceptor. Install the peer and initialize it in your application before enabling this option.
SafeResponseModule.register({
context: {
// Map CLS store keys to response meta fields
fields: { traceId: 'traceId', correlationId: 'correlationId' },
},
});
// Or use a custom resolver for full control
SafeResponseModule.register({
context: {
resolver: (store) => {
const clsService = store as { get(key: string): unknown };
return { traceId: clsService.get('traceId'), region: clsService.get('region') };
},
},
});Response:
{
"success": true,
"statusCode": 200,
"data": { "id": 1, "name": "John" },
"meta": { "traceId": "abc-123", "correlationId": "req-456" }
}API Deprecation
Mark endpoints as deprecated with standard HTTP headers and response metadata.
@Get('v1/users')
@Deprecated({
since: '2026-01-01', // RFC 9745 Deprecation header
sunset: '2026-12-31', // RFC 8594 Sunset header
message: 'Use /v2/users instead',
link: '/v2/users', // Link header with rel="successor-version"
})
findAll() { ... }Headers set automatically:
Deprecation: @1767225600(the validsincedate as Unix seconds; otherwise the current time)Sunset: Thu, 31 Dec 2026 00:00:00 GMTLink: </v2/users>; rel="successor-version"
Response includes meta.deprecation:
{
"success": true,
"statusCode": 200,
"data": [{ "id": 1, "name": "John" }],
"meta": {
"deprecation": {
"deprecated": true,
"since": "2026-01-01T00:00:00.000Z",
"sunset": "2026-12-31T00:00:00.000Z",
"message": "Use /v2/users instead",
"link": "/v2/users"
}
}
}Swagger automatically shows the endpoint as deprecated. Works with both success and error responses.
If since is missing or invalid, the Deprecation header reuses the first handling time for that response; meta.deprecation.since is only included for a valid explicit date. Successor links are added only once, including when a handler throws.
Note: If a Guard throws before the interceptor runs (e.g.,
AuthGuardreturns 401), the error response will not include deprecation headers. This is the same limitation as@ProblemType().
Rate Limit Metadata
Mirror rate limit response headers into the response body for frontend consumption.
SafeResponseModule.register({
rateLimit: true, // reads X-RateLimit-* headers
})Reads headers already set on the application response when the envelope is built, for example by a rate-limit guard or middleware. Headers added later by a proxy or API gateway cannot be mirrored:
{
"success": true,
"statusCode": 200,
"data": [{ "id": 1, "name": "John" }],
"meta": {
"rateLimit": {
"limit": 100,
"remaining": 87,
"reset": 1712025600
}
}
}All three headers (Limit, Remaining, Reset) must contain finite, non-negative numbers; missing or invalid data is suppressed. A valid numeric Retry-After is included as retryAfter. Available in both success and error responses (including 429 Too Many Requests).
Custom Header Prefix
SafeResponseModule.register({
rateLimit: { headerPrefix: 'RateLimit' }, // reads RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset
})Field Selection (Partial Response)
Enable Google-style ?fields=id,name query parameter to select specific fields from the response data.
// Per-route: decorator
@Get(':id')
@FieldSelection()
@ApiSafeResponse(PartialUserDto)
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
// Or global: module option
SafeResponseModule.register({
fieldSelection: true, // enable for all routes
})Request: GET /api/users/1?fields=id,name,address.city
Response:
{
"success": true,
"statusCode": 200,
"data": { "id": 1, "name": "John", "address": { "city": "Seoul" } },
"meta": { "fields": ["id", "name", "address.city"] }
}- Dot-notation for nested fields (
address.city) - Arrays: applied to each element
- Top-level nested arrays keep their shape, with selection applied recursively to their elements; paths such as
addresses.citydo not automatically traverse arrays stored inside object properties - Non-existent fields: silently skipped
- Inherited/prototype paths and reserved segments (
__proto__,prototype,constructor) are ignored - Decorator takes priority over module option; pass
@FieldSelection(false)to disable on a specific route
Field selection does not rewrite Swagger schemas. Define PartialUserDto with optional properties (including optional nested properties), or provide a response schema that permits omitted fields. Using a DTO with required properties can make a selected response violate its documented contract. This feature selects output fields; it does not replace authorization or sensitive-field exclusion.
Custom Options
@FieldSelection({
queryParam: 'select', // custom parameter name (default: 'fields')
separator: ';', // custom separator (default: ',')
maxDepth: 2, // limit nesting depth (default: 3)
maxFields: 20, // limit accepted field paths
maxFieldLength: 80, // drop overly long field paths
})Selecting a parent field includes its whole value, even when a child is also requested (for example, fields=address,address.city). Field selection does not mutate the original payload and supports frozen objects.
Error Catalog
Define errors centrally and throw by key — no more scattered status codes and messages.
import {
defineErrors,
createSafeException,
SafeException,
SafeResponseModule,
} from '@nestarc/safe-response';
// 1. Define errors
const errors = defineErrors({
USER_NOT_FOUND: { status: 404, message: 'User not found' },
EMAIL_TAKEN: { status: 409, message: 'Email already registered' },
VALIDATION_ERROR: { status: 400, message: 'Validation failed', details: ['field is required'] },
});
// 2. Register
SafeResponseModule.register({ errorCatalog: errors });
// 3. Throw by key
throw new SafeException('USER_NOT_FOUND');
// → { success: false, statusCode: 404, error: { code: 'USER_NOT_FOUND', message: 'User not found' } }
// Override message or details per throw
throw new SafeException('USER_NOT_FOUND', { message: 'Profile not found' });
throw new SafeException('VALIDATION_ERROR', { details: ['email is invalid'] });
// Optional typed exception factory for compile-time catalog keys
const CatalogException = createSafeException(errors);
throw new CatalogException('EMAIL_TAKEN');Error code resolution order: SafeException.errorKey > errorCodeMapper > errorCodes > DEFAULT_ERROR_CODE_MAP > 'INTERNAL_SERVER_ERROR'
API Version Metadata
Include the API version in every response for version-aware clients.
SafeResponseModule.register({
version: '2.1.0',
})Response:
{
"success": true,
"statusCode": 200,
"data": { "id": 1, "name": "John" },
"meta": { "apiVersion": "2.1.0" }
}Included in both success and error responses (standard and Problem Details formats).
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. |
| rateLimit | boolean \| RateLimitOptions | undefined | Mirror rate limit response headers into meta.rateLimit |
| i18n | boolean \| I18nAdapter | undefined | Enable i18n for error/success messages. true auto-detects nestjs-i18n, or pass a custom adapter. |
| errorCodes | Record<number, string> | undefined | Declarative error code map merged on top of DEFAULT_ERROR_CODE_MAP |
| version | string | undefined | API version string included in every response's meta.apiVersion |
| errorCatalog | ErrorCatalog | undefined | Centralized error definitions created via defineErrors(). Used by SafeException for status/message resolution. |
| fieldSelection | boolean \| FieldSelectionOptions | undefined | Enable partial response via ?fields= query parameter for all routes |
| suppressWarnings | boolean | false | Suppress shape-mismatch warnings for @Paginated, @CursorPaginated, @SortMeta, @FilterMeta |
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
@nestarc/safe-response works with NestJS's ClassSerializerInterceptor for returned DTO instances. E2E tests verify that @Exclude() removes the password in both module import orders on Express and Fastify. A typical registration is:
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 |
| 405 | METHOD_NOT_ALLOWED |
| 409 | CONFLICT |
| 413 | PAYLOAD_TOO_LARGE |
| 422 | UNPROCESSABLE_ENTITY |
| 429 | TOO_MANY_REQUESTS |
| 500 | INTERNAL_SERVER_ERROR |
| 502 | BAD_GATEWAY |
| 503 | SERVICE_UNAVAILABLE |
Override with errorCodeMapper. Its optional second argument provides { statusCode, defaultCode }; returning undefined keeps the declarative/default mapping.
Declarative Error Codes
For simple status-to-code mappings without a mapper function:
SafeResponseModule.register({
errorCodes: {
404: 'RESOURCE_NOT_FOUND',
409: 'DUPLICATE_ENTRY',
},
})For catalog priority and the complete resolution order, see Error Catalog.
Utility Functions
import { lookupErrorCode, lookupProblemTitle } from '@nestarc/safe-response';
lookupErrorCode(404); // 'NOT_FOUND'
lookupProblemTitle(404); // 'Not Found'Shape-mismatch Warnings
When @Paginated(), @CursorPaginated(), @SortMeta(), or @FilterMeta() are applied but the handler returns data that doesn't match the expected shape, a Logger.warn() is emitted with the route and expected shape.
SafeResponseModule.register({
suppressWarnings: true, // silence shape-mismatch warnings
})Testing & Reliability
Unit tests, real HTTP requests, type checks, and installed-package checks verify behavior and catch regressions.
Test Suite
| Category | Count | What it covers |
|----------|-------|----------------|
| Unit tests | 557 | Interceptor, Exception Filter, Module DI, Decorators, Client Type Guards, i18n Adapter, Global Errors, Shared Utilities, Error Catalog, Field Selection |
| E2E tests (Express) | 73 | Full HTTP request/response cycle, async registration, both serialization orders, and payload-limit errors |
| E2E tests (Fastify) | 64 | HTTP wrapping, async registration, serialization orders, dynamic status codes, and repeated cursor filters |
| E2E tests (Swagger) | 176 | Actual HTTP contracts including undefined payloads, DTO constraints, and fallback/registered global error schemas |
| Type tests | 172 assertions | Public API signatures via tsd, including client/server parity and optional undefined payloads |
| Packed consumer tests | 2 runtime fixtures + 4 type modes | Actual tarball installation, CJS/ESM imports, Fastify responses, Swagger, and public package-name type resolution |
| Release validation | 13 | Tag/ref/version identity and npm channel selection via node:test |
| Benchmark harness | 9 | Configuration, statistics, preflight checks, execution order, and cleanup via node:test |
| 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 the coverage thresholds listed below
npm run test:types # public API type verification
npm run test:package # build, pack, install, and verify the package as a consumer
npm run test:release # release tag/version/channel validation tests
npm run test:bench # benchmark harness regression tests
npm run bench # HTTP latency benchmarkCI Pipeline
Pushes to main and pull requests targeting main run the full matrix on GitHub Actions:
Node 18/20/22/24 × NestJS 10 + @nestjs/swagger 8
Node 20/22/24 × NestJS 11 + @nestjs/swagger 11
→ build → test:cov (threshold enforced) → test:e2e → test:types:built → test:package:builtDependencies are installed on Node 24 before switching to each runtime version. Lint, release validation tests, benchmark harness tests, and Codecov upload run on the Node 24 / NestJS 11 job. Each job builds once; test:types:built checks that output. The local test:types command still builds first.
Package tests install the generated tarball in an isolated temporary consumer with the current matrix's required peer versions. They check published files, public root and /client imports, real HTTP adapter behavior, and strict types with legacy Node, NodeNext CJS/ESM, and Bundler resolution. The install may access npm; lifecycle scripts are disabled. Failed runs retain the consumer and logs for diagnosis. To test an existing artifact without rebuilding or repacking, run npm run test:package:built -- /path/to/package.tgz.
Release Workflow
Publishing a GitHub Release runs the release workflow on Node 24. Before installing dependencies, it verifies that the triggering tag, package.json, and both version entries in package-lock.json agree. Tags must use vX.Y.Z or vX.Y.Z-prerelease without build metadata. The GitHub Release pre-release flag must match the version: stable versions publish to npm latest, and pre-release versions publish to next.
For a manual release, dispatch the workflow from the same existing tag supplied as its tag input:
gh workflow run release.yml --ref v0.16.0 -f tag=v0.16.0Dispatching from a branch or a different tag fails validation. Checkout uses the triggering commit so the package and npm provenance refer to the same source. The workflow runs release validation tests, lint, coverage, E2E, and type checks, builds once, then packs and tests that exact tarball in an isolated consumer before publishing it with lifecycle scripts disabled. Direct local npm publish retains the prepublishOnly build.
Coverage Threshold
Enforced in CI — the build fails if coverage drops below:
| Metric | Threshold | |--------|-----------| | Lines | 95% | | Statements | 95% | | Branches | 90% | | Functions | 95% |
These thresholds apply to Jest's collected unit-test coverage, not every published file. The current configuration excludes **/index.ts and **/interfaces/**, including runtime code in the client, decorators, and error catalog entry points. Those modules have tests, but their coverage is not included in these percentages. coverage/ is generated local output and is not part of the npm package.
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.
Ajv also validates actual HTTP responses against their generated response schemas. Negative checks ensure typed DTO fields and declared error-detail shapes remain enforced.
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
Run the benchmark from the repository with installed development dependencies:
npm run bench
# Short smoke run; too few samples for performance conclusions:
BENCH_ROUNDS=2 BENCH_ITERATIONS=20 BENCH_WARMUP=5 npm run benchbenchmarks/response-overhead.ts compares raw NestJS with the default SafeResponseModule using Express, a small object response (200), and NotFoundException (404), at concurrency 1. Each app listens once on loopback before timing; separate keep-alive clients use the same connection policy. Preflight checks verify the raw and wrapped response bodies before measurement. Errors produce a nonzero exit code and both apps and clients are cleaned up.
Defaults are 6 rounds, 500 measured requests per variant/scenario/round, and 50 warmup requests per variant/scenario/round. Configure them with BENCH_ROUNDS (even integer, 2–100), BENCH_ITERATIONS (integer, 1–10,000), and BENCH_WARMUP (integer, 0–10,000). Each round warms both variants and alternates raw/wrapped measurement order. Output includes runtime and machine information, sample counts, latency percentiles, and the mean and range of paired round differences; calculations retain precision until display.
These are end-to-end HTTP measurements including serialization, transport, and client processing in one process. They do not isolate interceptor CPU cost or represent Fastify, optional features, large payloads, or concurrent throughput. Repeat runs on the same environment and assess variability before drawing conclusions. Negative differences can reflect measurement noise; there is no fixed overhead guarantee or timing threshold in CI. npm run test:bench checks the harness behavior separately from performance measurements.
Compatibility
| Dependency | Version | |------------|---------| | NestJS | v10, v11 | | Platform | Express, Fastify | | @nestjs/swagger | v8, v11 | | Node.js | >= 18 with NestJS 10; >= 20 with NestJS 11 | | RxJS | v7 |
CI tests NestJS 10 with Swagger 8, and NestJS 11 with Swagger 11; the rows above do not promise every cross-major combination. The repository's development tools run on Node 24; consumer runtime checks also cover the older supported Node versions.
License
MIT
