Un sobre JSON por cada ruta de NestJS: interceptores más un filtro global
Envuelva cada respuesta de NestJS en un sobre tipado utilizando un interceptor genérico, un decorador de mensajes y un filtro de excepciones, manteniendo al mismo tiempo a los controladores libres de código genérico.
Cuando cada endpoint devuelve una estructura ligeramente diferente, los clientes de API deben programar de forma defensiva. Un formato consistente permite a los consumidores escribir un único manejador de respuestas en lugar de muchos. NestJS hace esto posible sin tocar cada controlador: un interceptor transforma los resultados exitosos, un decorador proporciona mensajes específicos para cada ruta y un filtro de excepciones da a los errores la misma estructura. Esta guía explica cómo implementar los tres elementos.
Por qué envolver las respuestas en controladores no es escalable
El enfoque ingenuo consiste en armar manualmente el formato en cada manejador:
@Get()
findAll() {
const users = await this.userService.findAll();
return {
code: 200,
status: true,
message: 'Success retrieve users',
data: users
};
}
Al repetirse en docenas de rutas, esta duplicación aumenta con el tiempo. Los controladores deben aceptar entrada y devolver datos del dominio; formatear la carga útil que se envía es un problema transversal que pertenece al ciclo de vida de la solicitud. (También hay que notar que el fragmento utiliza await en un método que no está marcado como async, lo cual no se compilará; otra razón más para sacar esta lógica de los controladores.)
Definir el contrato de respuesta
Comience con los tipos. IResponseEntity<T> es genérico respecto a la carga útil, y un objeto meta opcional contiene detalles de paginación:
// src/common/interfaces/response.interface.ts
export interface ImetaPagination {
page: number;
limit: number;
totalItems: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
export interface IResponseEntity<T> {
code: number;
status: boolean;
message: string;
data?: T;
meta?: ImetaPagination;
}
Mensajes por ruta con un decorador de metadatos
Un endpoint de creación y un endpoint de lista merecen mensajes de éxito diferentes. SetMetadata asigna un valor al manejador de la ruta, y un pequeño wrapper le da un nombre legible:
// src/common/decorators/response-message.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const RESPONSE_MESSAGE_METADATA = 'response_message';
export const ResponseMessage = (message: string) =>
SetMetadata(RESPONSE_MESSAGE_METADATA, message);
El interceptor de transformación
El interceptor implementa NestInterceptor<T, IResponseEntity<T>>, lo que indica que convierte el resultado del manejador de tipo T en un sobre. Antes de llamar al manejador, lee el código de estado actual de la respuesta subyacente y obtiene el mensaje personalizado a través de Reflector, utilizando como valor por defecto 'Success'. Luego dirige el observable del manejador mediante map de RxJS. Si el resultado es un objeto que contiene tanto data como meta, se trata como un resultado paginado y se divide en los campos correspondientes del sobre; todo lo demás se convierte directamente en data.
// src/common/interceptors/transform-response.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { IResponseEntity } from '../interfaces/response.interface';
import { RESPONSE_MESSAGE_METADATA } from '../decorators/response-message.decorator';
@Injectable()
export class TransformResponseInterceptor<T>
implements NestInterceptor<T, IResponseEntity<T>>
{
constructor(private readonly reflector: Reflector) {}
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<IResponseEntity<T>> {
const http = context.switchToHttp();
const response = http.getResponse();
const statusCode = response.statusCode;
// Extract custom message if set; default to 'Success'
const customMessage =
this.reflector.get<string>(
RESPONSE_MESSAGE_METADATA,
context.getHandler(),
) || 'Success';
return next.handle().pipe(
map((res) => {
// Handle cases where service returns { data, meta } for pagination
const hasMeta = res && typeof res === 'object' && 'meta' in res && 'data' in res;
return {
code: statusCode,
status: true,
message: customMessage,
data: hasMeta ? res.data : res,
meta: hasMeta ? res.meta : undefined,
};
}),
);
}
}
Algunos detalles que vale la pena conocer:
reflector.get()solo lee los metadatos del manejador. Si también desea un mensaje predeterminado a nivel de clase,getAllAndOverride()junto concontext.getHandler()ycontext.getClass()soluciona eso.- El estado se lee antes de que se ejecute el manejador. Esto permite obtener los valores predeterminados y
@HttpCode(), pero si un manejador cambia el estado dinámicamente, es más seguro leerresponse.statusCodedentro demap. - La verificación de paginación utiliza tipado por inspección. Un objeto del dominio que casualmente tenga las propiedades
dataymetapodría ser desempaquetado por error; una clase dedicada junto con una verificación deinstanceofes más fiable.
Registrar el interceptor para toda la aplicación
Al registrarlo globalmente en main.ts se aplica el interceptor a cada controlador sin necesidad de @UseInterceptors() en cada clase. Dado que se crea fuera del sistema de módulos, es necesario obtener el Reflector desde la aplicación y pasarlo manualmente:
// src/main.ts
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { TransformResponseInterceptor } from './common/interceptors/transform-response.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const reflector = app.get(Reflector);
app.useGlobalInterceptors(new TransformResponseInterceptor(reflector));
await app.listen(3000);
}
bootstrap();
La alternativa es registrarlo como proveedor bajo el token APP_INTERCEPTOR en el módulo raíz. Nest lo construirá entonces con inyección de dependencias completa.
Controladores después de la refactorización
Los controladores ahora devuelven entidades simples u un objeto { data, meta } y declaran su mensaje mediante el decorador:
// src/users/users.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UsersService } from './users.service';
import { ResponseMessage } from '../common/decorators/response-message.decorator';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@ResponseMessage('User successfully created')
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
@Get()
@ResponseMessage('Users retrieved successfully')
findAll() {
// Returns { data: [...], meta: { page: 1, limit: 10, ... } }
return this.usersService.findAllPaginated();
}
}
Una solicitud al endpoint de lista produce este envoltorio:
{
"code": 200,
"status": true,
"message": "Users retrieved successfully",
"data": [
{
"id": "usr_99",
"name": "Alex Mercer",
"email": "alex@example.com"
}
],
"meta": {
"page": 1,
"limit": 10,
"totalItems": 1,
"totalPages": 1,
"hasNextPage": false,
"hasPrevPage": false
}
}
Dar a los errores la misma estructura
El map de un interceptor solo se ejecuta para los valores que el manejador emite con éxito. Las instancias de HttpException lanzadas, los fallos de validación y los errores inesperados lo omiten y llegan a los clientes en el formato de error predeterminado de Nest.
Un filtro de excepciones cierra esa brecha. El que se muestra a continuación utiliza un simple @Catch(), por lo que maneja todas las excepciones. Deriva el estado a partir de las instancias de HttpException; de lo contrario, recurre al 500. Extrae un mensaje, une arrays (como el listado generado por ValidationPipe) en una sola cadena y responde con status: false y data: null:
// src/common/filters/http-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const exceptionResponse =
exception instanceof HttpException ? exception.getResponse() : null;
const message =
typeof exceptionResponse === 'object' && exceptionResponse !== null
? (exceptionResponse as any).message || exception.toString()
: exception instanceof Error
? exception.message
: 'Internal server error';
response.status(status).json({
code: status,
status: false,
message: Array.isArray(message) ? message.join(', ') : message,
data: null,
});
}
}
Regístrelo a nivel global, por ejemplo con app.useGlobalFilters() o el token APP_FILTER. Una precaución: para los errores que no son de HTTP, este filtro devuelve el valor raw de exception.message al cliente, lo que puede revelar detalles internos. En entornos de producción, registre el error original y envíe un mensaje genérico para las respuestas 500.
Si prefiere adoptar una estructura de error estándar en lugar de una personalizada, vale la pena echar un vistazo a los detalles del problema RFC 9457.
Puntos clave
- Mantenga los controladores simples y deje que el ciclo de vida se encargue del formato de las respuestas.
- Un interceptor genérico con
mapde RxJS gestiona los casos exitosos; un decorador de metadatos personaliza los mensajes según la ruta.
Lecturas relacionadas
- Dentro de los interceptores de NestJS: Arreglando una regresión de latencia del 96% a escala — Aprenda cómo el pipeline de ejecución AOP de NestJS y los problemas al desmontar RxJS causaron un aumento en la latencia P99, y cómo crear un interceptor de auditoría sin asignación de memoria para solucionarlo.
- Equilibrando la generación CRUD de Prisma con control intencional de rutas — Aprenda cómo la generación basada en esquemas de routers CRUD de Prisma puede eliminar el código genérico repetitivo, manteniendo al mismo tiempo las decisiones relacionadas con la confianza, el alcance y la exposición en el código de la aplicación.