Vamos con el post número 13 de la serie NestJS y el primer post del bloque de Funcionalidades Avanzadas. Los tres bloques anteriores nos dieron la base con estructura (posts 1-5), base de datos (posts 6-9) y seguridad (posts 10-12). Ahora vamos a coger nuestra API y darle un meneo interesante.
Hoy vamos a ver los errores, porque tu API va a fallar, si o si. La pregunta no es si va a fallar, sino cómo va a fallar. Un usuario pide algo que no existe, envía un body inválido, intenta acceder sin permisos, tu base de datos se cae, una API externa no responde… ¿Tu API devuelve un JSON limpio y predecible con el código HTTP correcto? ¿O pinta un stack trace con información interna?
Los 💡 Exception Filters Capa del pipeline de NestJS que captura excepciones lanzadas durante el procesamiento de una request. Transforman la excepción en una respuesta HTTP apropiada. Se ejecutan al final del pipeline: si cualquier capa (Guard, Interceptor, Pipe, Handler) lanza una excepción, el Exception Filter la atrapa. Más info → son la respuesta de NestJS a todo este jaleo.
EA, amo al lío.

1. ¿Dónde metemos los Exception Filters en el pipeline?
¿Recuerdas el pipeline completo del post 5?
Request → Middleware → Guards → Interceptors (pre) → Pipes → Handler → Interceptors (post) → Response
↓
Exception Filter ← captura cualquier throw
Los Exception Filters son la red de seguridad del pipeline. Si cualquier capa lanza una excepción como por ejemplo un Guard que rechaza, un Pipe que valida, el Handler que falla, el Exception Filter la atrapa y la transforma en una respuesta HTTP limpia.
Sin Exception Filters, NestJS tiene un filtro por defecto que hace esto:
{
"statusCode": 500,
"message": "Internal server error"
}
Funciona y está bien, pero es insuficiente para producción. Necesitamos más control.
2. HttpException: la excepción base
Toda excepción HTTP en NestJS extiende de 💡 HttpException Clase base de NestJS para excepciones HTTP. Recibe dos argumentos: el response body (string u objeto) y el status code HTTP. Todas las excepciones built-in de NestJS (NotFoundException, BadRequestException, etc.) extienden de esta clase. Más info → :
import { HttpException, HttpStatus } from '@nestjs/common';
// Forma simple: solo mensaje
throw new HttpException('Usuario no encontrado', HttpStatus.NOT_FOUND);
// → { "statusCode": 404, "message": "Usuario no encontrado" }
// Forma avanzada: objeto custom
throw new HttpException(
{
statusCode: HttpStatus.FORBIDDEN,
message: 'No tienes permisos',
error: 'Forbidden',
},
HttpStatus.FORBIDDEN,
);
// → { "statusCode": 403, "message": "No tienes permisos", "error": "Forbidden" }
El primer argumento define el body de la respuesta. Si pasas un string, NestJS construye el objeto automáticamente con statusCode y message. Si pasas un objeto, se devuelve tal cual.
3. Built-in exceptions: la jerarquía completa
NestJS proporciona excepciones predefinidas para cada código HTTP de error. Usa siempre estas en vez de HttpException directa:
| Excepción | Código | Cuándo usarla |
|---|---|---|
BadRequestException | 400 | Datos inválidos, body malformado, parámetro incorrecto |
UnauthorizedException | 401 | Falta autenticación, token inválido o expirado |
ForbiddenException | 403 | Autenticado pero sin permisos para la acción |
NotFoundException | 404 | Recurso no encontrado |
MethodNotAllowedException | 405 | Método HTTP no soportado en esta ruta |
NotAcceptableException | 406 | Content type no soportado |
RequestTimeoutException | 408 | La request tardó demasiado |
ConflictException | 409 | Conflicto (email duplicado, versión desactualizada) |
GoneException | 410 | Recurso eliminado permanentemente |
PayloadTooLargeException | 413 | Body demasiado grande |
UnsupportedMediaTypeException | 415 | Content-Type no soportado |
UnprocessableEntityException | 422 | Semánticamente incorrecto (validación lógica) |
InternalServerErrorException | 500 | Error inesperado del servidor |
NotImplementedException | 501 | Funcionalidad no implementada aún |
BadGatewayException | 502 | API externa devolvió una respuesta inválida |
ServiceUnavailableException | 503 | Servicio temporalmente no disponible |
GatewayTimeoutException | 504 | API externa no respondió a tiempo |
Todas aceptan un mensaje custom como primer argumento:
import {
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
// Mensaje simple
throw new NotFoundException('Usuario no encontrado');
// Mensaje + descripción
throw new ConflictException('El email ya está registrado');
// Objeto con detalles
throw new BadRequestException({
message: ['El nombre es obligatorio', 'El email no es válido'],
error: 'Validation Error',
});
4. Excepciones en la práctica: el service real
Vamos a ver cómo se usan las excepciones en un service bien implementado. Esto conecta con el CRUD del post 8:
// src/users/users.service.ts
import {
Injectable,
NotFoundException,
ConflictException,
InternalServerErrorException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, type FindOptionsWhere } from 'typeorm';
import { User } from './entities/user.entity';
import { type CreateUserDto } from './dto/create-user.dto';
import { type UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
// Verificar email duplicado → 409 Conflict
const emailExists = await this.usersRepository.exists({
where: { email: createUserDto.email },
});
if (emailExists) {
throw new ConflictException(
`El email ${createUserDto.email} ya está registrado`,
);
}
const user = this.usersRepository.create(createUserDto);
return this.usersRepository.save(user);
}
async findOne(id: string): Promise<User> {
const user = await this.usersRepository.findOne({
where: { id },
});
// Recurso no encontrado → 404
if (!user) {
throw new NotFoundException(`Usuario con ID "${id}" no encontrado`);
}
return user;
}
async update(id: string, updateUserDto: UpdateUserDto): Promise<User> {
// findOne ya lanza NotFoundException si no existe
const user = await this.findOne(id);
// Si quiere cambiar el email, verificar que no exista
if (updateUserDto.email && updateUserDto.email !== user.email) {
const emailTaken = await this.usersRepository.exists({
where: { email: updateUserDto.email },
});
if (emailTaken) {
throw new ConflictException(
`El email ${updateUserDto.email} ya está en uso`,
);
}
}
const merged = this.usersRepository.merge(user, updateUserDto);
return this.usersRepository.save(merged);
}
async remove(id: string): Promise<void> {
const user = await this.findOne(id);
await this.usersRepository.softRemove(user);
}
}
Pay atention:
findOnees la base:updateyremovellaman afindOneque lanzaNotFoundExceptionsi no existe. No repites la verificación.- Cada error tiene su excepción: duplicado →
ConflictException, no encontrado →NotFoundException. No usesBadRequestExceptionpara todo. - Mensajes descriptivos: incluye el ID o el email en el mensaje para que el debug sea más fácil.
5. Exception Filters custom: @Catch
El filtro por defecto de NestJS transforma las HttpException en respuestas JSON, pero ¿y si quieres
- Un formato de error diferente?
- Loggear los errores en un servicio externo?
- Tratar errores de TypeORM de forma especial?
- Añadir un
requestIda cada respuesta de error?
Para eso creas un Exception Filter custom Exception Filter custom Clase decorada con @Catch() que implementa ExceptionFilter. Intercepta excepciones específicas y controla exactamente qué respuesta HTTP se devuelve. Se puede registrar a nivel de método, controller o global. .
5.1. Interfaz ExceptionFilter
import { type ExceptionFilter, type ArgumentsHost } from '@nestjs/common';
export interface ExceptionFilter<T = unknown> {
catch(exception: T, host: ArgumentsHost): void;
}
Dos parámetros:
exception: la excepción lanzada.host: el contexto de la request (acceso alRequestyResponsede Express).
5.2. HttpExceptionFilter: formato de error personalizado
// src/common/filters/http-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { type Request, type Response } from 'express';
interface ErrorResponse {
statusCode: number;
message: string | string[];
error: string;
path: string;
method: string;
timestamp: string;
}
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: HttpException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
// Extraer el mensaje (puede ser string, string[] u objeto)
const message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as Record<string, unknown>).message ?? exception.message;
const errorBody: ErrorResponse = {
statusCode: status,
message: message as string | string[],
error: this.getErrorName(status),
path: request.url,
method: request.method,
timestamp: new Date().toISOString(),
};
// Loggear solo errores 5xx (los 4xx son errores del cliente, no del servidor)
if (status >= HttpStatus.INTERNAL_SERVER_ERROR) {
this.logger.error(
`${request.method} ${request.url} → ${status}`,
exception.stack,
);
} else {
this.logger.warn(
`${request.method} ${request.url} → ${status}: ${JSON.stringify(message)}`,
);
}
response.status(status).json(errorBody);
}
private getErrorName(status: number): string {
const names: Record<number, string> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
408: 'Request Timeout',
409: 'Conflict',
413: 'Payload Too Large',
422: 'Unprocessable Entity',
429: 'Too Many Requests',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
};
return names[status] ?? 'Unknown Error';
}
}
Ahora todas las respuestas de error tienen un formato consistente:
{
"statusCode": 404,
"message": "Usuario con ID \"uuid-123\" no encontrado",
"error": "Not Found",
"path": "/users/uuid-123",
"method": "GET",
"timestamp": "2026-03-21T10:30:00.000Z"
}
¿Por qué es mejor que el filtro por defecto?
pathymethod: sabes exactamente qué endpoint falló sin mirar los logs del servidor.timestamp: correlacionar el error con logs del servidor o herramientas de monitorización.- Logging diferenciado:
warnpara 4xx (error del cliente, esperable),errorpara 5xx (error del servidor, investiga).
6. Catch-all Filter: atrapando lo inesperado
El HttpExceptionFilter solo captura HttpException y sus hijas. Pero ¿qué pasa con errores que no son HttpException? Un TypeError, un ReferenceError, un error de TypeORM, un null.property…
Esos errores llegan al usuario como un 500 genérico con el stack trace expuesto. Necesitas un filtro que atrape todo:
// src/common/filters/all-exceptions.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { type Request, type Response } from 'express';
@Catch() // Sin argumento = atrapa TODAS las excepciones
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
// Si es HttpException, dejar que la maneje el HttpExceptionFilter
if (exception instanceof HttpException) {
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
response.status(status).json({
statusCode: status,
message:
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as Record<string, unknown>).message,
error: HttpStatus[status] ?? 'Error',
path: request.url,
method: request.method,
timestamp: new Date().toISOString(),
});
return;
}
// Error inesperado: loggear TODO y devolver respuesta genérica
const errorMessage =
exception instanceof Error ? exception.message : 'Error desconocido';
const errorStack =
exception instanceof Error ? exception.stack : undefined;
this.logger.error(
`${request.method} ${request.url} → 500: ${errorMessage}`,
errorStack,
);
// NUNCA exponer detalles internos al cliente en producción
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Error interno del servidor',
error: 'Internal Server Error',
path: request.url,
method: request.method,
timestamp: new Date().toISOString(),
});
}
}
El punto clave para errores inesperados, loggeas todo (mensaje, stack trace, URL, método) pero al cliente le devuelves un mensaje genérico. Nunca expongas detalles internos como nombres de tablas, queries SQL o stack traces, porque todo podría ser aprovechado para un ataque.
7. Filtro para errores de TypeORM
Los errores de TypeORM (unique constraint, foreign key violation, etc.) son QueryFailedError, no HttpException. Sin un filtro específico, se convierten en 500 genéricos. Pero un unique constraint violation debería ser un 409 Conflict, no un 500:
// src/common/filters/typeorm-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
Logger,
} from '@nestjs/common';
import { QueryFailedError } from 'typeorm';
import { type Request, type Response } from 'express';
interface PostgresError {
code: string;
detail?: string;
table?: string;
constraint?: string;
}
@Catch(QueryFailedError)
export class TypeOrmExceptionFilter implements ExceptionFilter<QueryFailedError> {
private readonly logger = new Logger(TypeOrmExceptionFilter.name);
catch(exception: QueryFailedError, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const pgError = exception.driverError as PostgresError;
const { status, message } = this.mapPostgresError(pgError);
this.logger.warn(
`${request.method} ${request.url} → ${status}: ${message} [${pgError.code}]`,
);
response.status(status).json({
statusCode: status,
message,
error: HttpStatus[status] ?? 'Error',
path: request.url,
method: request.method,
timestamp: new Date().toISOString(),
});
}
private mapPostgresError(
error: PostgresError,
): { status: number; message: string } {
switch (error.code) {
case '23505': // unique_violation
return {
status: HttpStatus.CONFLICT,
message: this.extractUniqueMessage(error.detail),
};
case '23503': // foreign_key_violation
return {
status: HttpStatus.BAD_REQUEST,
message: 'Referencia a un recurso que no existe',
};
case '23502': // not_null_violation
return {
status: HttpStatus.BAD_REQUEST,
message: 'Faltan campos obligatorios',
};
case '22P02': // invalid_text_representation (UUID inválido, etc.)
return {
status: HttpStatus.BAD_REQUEST,
message: 'Formato de datos inválido',
};
default:
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Error de base de datos',
};
}
}
private extractUniqueMessage(detail?: string): string {
if (!detail) return 'El valor ya existe';
// detail: "Key (email)=(domin@domin.es) already exists."
const match = detail.match(/Key \((.+?)\)=\((.+?)\)/);
if (match) {
return `El campo "${match[1]}" con valor "${match[2]}" ya existe`;
}
return 'El valor ya existe';
}
}
Ahora si un usuario intenta registrarse con un email que ya existe y el service no lo comprueba antes,o si hay una race condition, en vez de un 500 feo recibe:
{
"statusCode": 409,
"message": "El campo \"email\" con valor \"domin@domin.es\" ya existe",
"error": "Conflict",
"path": "/auth/register",
"method": "POST",
"timestamp": "2026-03-21T10:30:00.000Z"
}
Double defense: tu service debería verificar el duplicado antes de intentar guardar (como hicimos en el punto 4). El
TypeOrmExceptionFilteres la red de seguridad para las race conditions: dos requests simultáneas pasan la verificación del service pero la segunda falla en la DB.
8. Excepciones de dominio tipadas
Para proyectos grandes, crear tus propias excepciones de dominio hace el código más expresivo:
// src/common/exceptions/entity-not-found.exception.ts
import { NotFoundException } from '@nestjs/common';
export class EntityNotFoundException extends NotFoundException {
constructor(entityName: string, field: string, value: string) {
super(`${entityName} con ${field} "${value}" no encontrado`);
}
}
// src/common/exceptions/duplicate-entity.exception.ts
import { ConflictException } from '@nestjs/common';
export class DuplicateEntityException extends ConflictException {
constructor(entityName: string, field: string, value: string) {
super(`Ya existe un ${entityName} con ${field} "${value}"`);
}
}
// src/common/exceptions/business-rule.exception.ts
import { UnprocessableEntityException } from '@nestjs/common';
export class BusinessRuleException extends UnprocessableEntityException {
constructor(rule: string) {
super(`Regla de negocio violada: ${rule}`);
}
}
Uso en el service:
import { EntityNotFoundException } from '../common/exceptions/entity-not-found.exception';
import { DuplicateEntityException } from '../common/exceptions/duplicate-entity.exception';
import { BusinessRuleException } from '../common/exceptions/business-rule.exception';
async findOne(id: string): Promise<User> {
const user = await this.usersRepository.findOne({ where: { id } });
if (!user) {
throw new EntityNotFoundException('Usuario', 'ID', id);
}
return user;
}
async create(dto: CreateUserDto): Promise<User> {
const exists = await this.usersRepository.exists({
where: { email: dto.email },
});
if (exists) {
throw new DuplicateEntityException('Usuario', 'email', dto.email);
}
return this.usersRepository.save(this.usersRepository.create(dto));
}
async deactivate(id: string): Promise<User> {
const user = await this.findOne(id);
if (!user.active) {
throw new BusinessRuleException(
'No se puede desactivar un usuario que ya está inactivo',
);
}
user.active = false;
return this.usersRepository.save(user);
}
- Excepciones de dominio que extienden de las built-in. Mensajes consistentes, código autodocumentado
- Una excepción por concepto: EntityNotFound, DuplicateEntity, BusinessRule. No mezcles concerns
- Crear excepciones custom que NO extienden de HttpException. El filtro por defecto no las entiende
- Usar throw new Error("algo") en los services. Se convierte en un 500 genérico sin información útil
9. Registrando los Exception Filters
9.1. A nivel de método
@Delete(':id')
@UseFilters(HttpExceptionFilter)
remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
return this.usersService.remove(id);
}
9.2. A nivel de controller
@Controller('users')
@UseFilters(HttpExceptionFilter)
export class UsersController {
// Todos los endpoints usan este filtro
}
9.3. A nivel global (recomendado)
// src/main.ts
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TypeOrmExceptionFilter } from './common/filters/typeorm-exception.filter';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Filtros globales — el orden importa (último registrado = primero en ejecutarse)
app.useGlobalFilters(
new AllExceptionsFilter(), // Catch-all: última red de seguridad
new TypeOrmExceptionFilter(), // Errores de TypeORM → HTTP apropiado
new HttpExceptionFilter(), // HttpExceptions → formato consistente
);
await app.listen(3000);
}
Orden de ejecución: los filtros se ejecutan del más específico al más genérico. NestJS busca el filtro que coincida con el tipo de excepción.
HttpExceptionFilteratrapaHttpException,TypeOrmExceptionFilteratrapaQueryFailedError,AllExceptionsFilteratrapa todo lo demás.
9.4. Registro global con DI (mejor)
Si tu filtro necesita inyectar dependencias (como un ConfigService o un Logger), regístralo en el módulo en vez de en main.ts:
// src/app.module.ts
import { APP_FILTER } from '@nestjs/core';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { TypeOrmExceptionFilter } from './common/filters/typeorm-exception.filter';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
@Module({
providers: [
// Orden: el último APP_FILTER registrado tiene mayor prioridad
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
{
provide: APP_FILTER,
useClass: TypeOrmExceptionFilter,
},
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
})
export class AppModule {}
La ventaja de APP_FILTER sobre useGlobalFilters(): los filtros participan en la inyección de dependencias. Puedes inyectar ConfigService, Logger, servicios de monitorización, etc.
10. Respuesta de error tipada: la interfaz
Para que todos los filtros devuelvan el mismo formato, define una interfaz:
// src/common/interfaces/error-response.interface.ts
export interface ErrorResponse {
statusCode: number;
message: string | string[];
error: string;
path: string;
method: string;
timestamp: string;
// Opcionales para debug (solo en development)
stack?: string;
detail?: string;
}
Y un helper para construirla:
// src/common/helpers/error-response.helper.ts
import { type Request } from 'express';
import { type ErrorResponse } from '../interfaces/error-response.interface';
export function buildErrorResponse(
request: Request,
statusCode: number,
message: string | string[],
error: string,
): ErrorResponse {
return {
statusCode,
message,
error,
path: request.url,
method: request.method,
timestamp: new Date().toISOString(),
};
}
Todos los filtros usan buildErrorResponse(). Un solo sitio donde cambiar el formato de error.
11. Errores de validación del ValidationPipe
Cuando el ValidationPipe rechaza un body inválido (del post 6), lanza un BadRequestException con un array de mensajes:
{
"statusCode": 400,
"message": [
"El nombre es obligatorio",
"El email no tiene un formato válido",
"La contraseña debe tener al menos 8 caracteres"
],
"error": "Bad Request"
}
Nuestro HttpExceptionFilter ya lo maneja porque BadRequestException extiende de HttpException. Pero si quieres personalizar el formato de los errores de validación, usa exceptionFactory en el ValidationPipe:
// src/main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
exceptionFactory: (errors) => {
const messages = errors.flatMap((error) =>
Object.values(error.constraints ?? {}),
);
return new BadRequestException({
message: messages,
error: 'Validation Error',
validationErrors: errors.map((error) => ({
field: error.property,
constraints: error.constraints,
})),
});
},
}),
);
Ahora las respuestas de validación incluyen qué campo falló y por qué:
{
"statusCode": 400,
"message": ["El nombre es obligatorio", "El email no tiene un formato válido"],
"error": "Validation Error",
"validationErrors": [
{
"field": "name",
"constraints": {
"isNotEmpty": "El nombre es obligatorio"
}
},
{
"field": "email",
"constraints": {
"isEmail": "El email no tiene un formato válido"
}
}
],
"path": "/users",
"method": "POST",
"timestamp": "2026-03-21T10:30:00.000Z"
}
12. Patrón: no devuelvas información sensible
Regla de oro de los errores en producción:
// ❌ NUNCA hagas esto en producción
throw new InternalServerErrorException({
message: 'Error al conectar con la base de datos',
query: 'SELECT * FROM users WHERE id = $1',
connectionString: 'postgresql://admin:secret@db:5432/myapp',
stack: exception.stack,
});
// ✅ Loggea internamente, devuelve genérico al cliente
this.logger.error('Error al conectar con la DB', {
query: exception.query,
message: exception.message,
stack: exception.stack,
});
throw new InternalServerErrorException('Error interno del servidor');
Un atacante con acceso a tus respuestas de error no debería aprender nada sobre tu infraestructura interna.
Para entornos de desarrollo, puedes incluir el stack trace condicionalmente:
// src/common/filters/all-exceptions.filter.ts
import { ConfigService } from '@nestjs/config';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly configService: ConfigService) {}
catch(exception: unknown, host: ArgumentsHost): void {
// ...
const isDevelopment =
this.configService.get<string>('NODE_ENV') === 'development';
const responseBody = {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Error interno del servidor',
error: 'Internal Server Error',
path: request.url,
method: request.method,
timestamp: new Date().toISOString(),
// Stack trace solo en desarrollo
...(isDevelopment && exception instanceof Error
? { stack: exception.stack, detail: exception.message }
: {}),
};
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json(responseBody);
}
}
En desarrollo ves todo el detalle y en producción, el cliente solo ve Error interno del servidor.
13. Estructura de ficheros
Después de este post, la carpeta common queda así:
src/
├── common/
│ ├── exceptions/
│ │ ├── entity-not-found.exception.ts
│ │ ├── duplicate-entity.exception.ts
│ │ └── business-rule.exception.ts
│ ├── filters/
│ │ ├── all-exceptions.filter.ts
│ │ ├── http-exception.filter.ts
│ │ └── typeorm-exception.filter.ts
│ ├── guards/
│ │ └── custom-throttler.guard.ts ← Post 12
│ ├── helpers/
│ │ └── error-response.helper.ts
│ └── interfaces/
│ ├── error-response.interface.ts
│ └── paginated-result.interface.ts ← Post 8
14. Errores comunes
Error 1: Lanzar Error en vez de HttpException
// ❌ Se convierte en 500 genérico, pierde el status code
throw new Error('Usuario no encontrado');
// ✅ Status code correcto, mensaje descriptivo
throw new NotFoundException('Usuario no encontrado');
Error nativo de JavaScript no tiene status code HTTP. El filtro por defecto lo convierte en 500 y siempre usa las excepciones de NestJS.
Error 2: Catch-all que traga HttpExceptions
// ❌ Este filtro atrapa TODO, incluyendo las HttpException
@Catch()
export class BadFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
// Todas las excepciones devuelven 500 porque
// no compruebas si es HttpException
response.status(500).json({ message: 'Error' });
}
}
Tu catch-all debe comprobar si la excepción es HttpException y respetar su status code. Solo las excepciones desconocidas deberían ser 500.
Error 3: Usar try/catch en los controllers
// ❌ Innecesario: NestJS ya atrapa las excepciones del service
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
try {
return await this.usersService.findOne(id);
} catch (error) {
throw new NotFoundException('Usuario no encontrado');
}
}
// ✅ El service lanza NotFoundException, el filtro la atrapa
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
return this.usersService.findOne(id);
}
Los controllers no necesitan try/catch porque el service lanza la excepción correcta, el Exception Filter la atrapa.
Error 4: Exponer stack traces en producción
// ❌ Un atacante ve tu estructura de carpetas, dependencias, versiones...
response.status(500).json({
message: exception.message,
stack: exception.stack,
});
// ✅ Loggea internamente, devuelve genérico
this.logger.error(exception.message, exception.stack);
response.status(500).json({
message: 'Error interno del servidor',
});
15. Recapitulando
Clase base para todas las excepciones HTTP. 17 excepciones built-in para cada código de error. Nunca uses throw new Error().
@Catch + ExceptionFilter. Transforman excepciones en respuestas HTTP limpias. Se registran con APP_FILTER o useGlobalFilters.
statusCode, message, error, path, method, timestamp. Mismo formato para TODOS los errores de la API.
Transforma errores de PostgreSQL (unique, FK, null) en respuestas HTTP apropiadas. Red de seguridad para race conditions.
EntityNotFound, DuplicateEntity, BusinessRule. Extienden de las built-in. Código más expresivo.
Stack traces solo en desarrollo. En producción: loggea todo, devuelve genérico. No des pistas a los atacantes.
En el próximo post nos metemos con los Interceptors para saber cómo modificar la request/response antes y después del handler. Logging automático, transformación de respuestas, timeout y decoradores custom.
EA, nos vemos en los bares!! 🍺
Pon a prueba lo aprendido
1. ¿En qué parte del pipeline de NestJS actúan los Exception Filters?
2. ¿Qué diferencia hay entre throw new Error() y throw new NotFoundException()?
3. ¿Por qué es mejor registrar los Exception Filters con APP_FILTER que con useGlobalFilters()?
4. ¿Cuándo deberías usar un TypeOrmExceptionFilter?
5. ¿Qué información NO deberías incluir en las respuestas de error en producción?