🚨 ¡Nueva review! 🔇 Los mejores cascos con ANC del mercado: los Sony WH-1000XM4 . ¡Échale un ojo! 👀

Exception Filters en NestJS: manejo de errores like a pro

Serie NestJS #13 — HttpException, built-in exceptions, @Catch, filtros custom, jerarquía de excepciones y respuestas de error tipadas

Escrito por domin el 7 de abril de 2026

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 son la respuesta de NestJS a todo este jaleo.

EA, amo al lío.

Diagrama de flujo de excepciones en NestJS con filtros atrapando errores antes de la respuesta.

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 :

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ónCódigoCuándo usarla
BadRequestException400Datos inválidos, body malformado, parámetro incorrecto
UnauthorizedException401Falta autenticación, token inválido o expirado
ForbiddenException403Autenticado pero sin permisos para la acción
NotFoundException404Recurso no encontrado
MethodNotAllowedException405Método HTTP no soportado en esta ruta
NotAcceptableException406Content type no soportado
RequestTimeoutException408La request tardó demasiado
ConflictException409Conflicto (email duplicado, versión desactualizada)
GoneException410Recurso eliminado permanentemente
PayloadTooLargeException413Body demasiado grande
UnsupportedMediaTypeException415Content-Type no soportado
UnprocessableEntityException422Semánticamente incorrecto (validación lógica)
InternalServerErrorException500Error inesperado del servidor
NotImplementedException501Funcionalidad no implementada aún
BadGatewayException502API externa devolvió una respuesta inválida
ServiceUnavailableException503Servicio temporalmente no disponible
GatewayTimeoutException504API 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:


5. Exception Filters custom: @Catch

El filtro por defecto de NestJS transforma las HttpException en respuestas JSON, pero ¿y si quieres

Para eso creas un Exception Filter custom .

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:

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?


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 TypeOrmExceptionFilter es 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);
}
Úsalo cuando...
  • 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
Evítalo cuando...
  • 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. HttpExceptionFilter atrapa HttpException, TypeOrmExceptionFilter atrapa QueryFailedError, AllExceptionsFilter atrapa 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

💥 HttpException

Clase base para todas las excepciones HTTP. 17 excepciones built-in para cada código de error. Nunca uses throw new Error().

🪤 Exception Filters

@Catch + ExceptionFilter. Transforman excepciones en respuestas HTTP limpias. Se registran con APP_FILTER o useGlobalFilters.

📋 Formato consistente

statusCode, message, error, path, method, timestamp. Mismo formato para TODOS los errores de la API.

🗄️ TypeORM Filter

Transforma errores de PostgreSQL (unique, FK, null) en respuestas HTTP apropiadas. Red de seguridad para race conditions.

🏷️ Excepciones de dominio

EntityNotFound, DuplicateEntity, BusinessRule. Extienden de las built-in. Código más expresivo.

🔒 Seguridad

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?