# PATRÓN: VALIDACIÓN DE DATOS **Versión:** 1.0.0 **Fecha:** 2025-12-08 **Aplica a:** Backend (NestJS/Express), Frontend (React) **Prioridad:** OBLIGATORIA --- ## PROPÓSITO Definir patrones estándar de validación de datos para garantizar consistencia en toda la aplicación. --- ## PRINCIPIO FUNDAMENTAL ``` ╔══════════════════════════════════════════════════════════════════════╗ ║ VALIDACIÓN EN CAPAS ║ ║ ║ ║ 1. Frontend: UX inmediata (opcional pero recomendada) ║ ║ 2. DTO: Validación de entrada (OBLIGATORIA) ║ ║ 3. Service: Validación de negocio (cuando aplica) ║ ║ 4. Database: Constraints (última línea de defensa) ║ ╚══════════════════════════════════════════════════════════════════════╝ ``` --- ## 1. VALIDACIÓN EN DTO (NestJS - class-validator) ### Decoradores Básicos ```typescript import { IsString, IsEmail, IsNotEmpty, IsOptional, IsUUID, IsInt, IsNumber, IsBoolean, IsDate, IsArray, IsEnum, IsUrl, IsPhoneNumber, } from 'class-validator'; ``` ### Decoradores de Longitud ```typescript import { Length, MinLength, MaxLength, Min, Max, ArrayMinSize, ArrayMaxSize, } from 'class-validator'; ``` ### Decoradores de Formato ```typescript import { Matches, IsAlpha, IsAlphanumeric, IsAscii, Contains, IsISO8601, IsCreditCard, IsHexColor, IsJSON, } from 'class-validator'; ``` --- ## 2. PATRONES POR TIPO DE DATO ### Email ```typescript // PATRÓN ESTÁNDAR @ApiProperty({ description: 'Correo electrónico del usuario', example: 'usuario@empresa.com', }) @IsEmail({}, { message: 'El correo electrónico no es válido' }) @IsNotEmpty({ message: 'El correo electrónico es requerido' }) @MaxLength(255, { message: 'El correo no puede exceder 255 caracteres' }) @Transform(({ value }) => value?.toLowerCase().trim()) email: string; ``` ### Password ```typescript // PATRÓN ESTÁNDAR - Contraseña segura @ApiProperty({ description: 'Contraseña (mín 8 caracteres, 1 mayúscula, 1 número, 1 especial)', example: 'MiPassword123!', }) @IsString() @IsNotEmpty({ message: 'La contraseña es requerida' }) @MinLength(8, { message: 'La contraseña debe tener al menos 8 caracteres' }) @MaxLength(100, { message: 'La contraseña no puede exceder 100 caracteres' }) @Matches( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/, { message: 'La contraseña debe contener mayúscula, minúscula, número y carácter especial' } ) password: string; ``` ### UUID ```typescript // PATRÓN ESTÁNDAR @ApiProperty({ description: 'ID único del recurso', example: '550e8400-e29b-41d4-a716-446655440000', }) @IsUUID('4', { message: 'El ID debe ser un UUID válido' }) @IsNotEmpty({ message: 'El ID es requerido' }) id: string; // UUID Opcional (para referencias) @ApiPropertyOptional({ description: 'ID del usuario relacionado', }) @IsOptional() @IsUUID('4', { message: 'El ID de usuario debe ser un UUID válido' }) userId?: string; ``` ### Nombre/Texto Simple ```typescript // PATRÓN ESTÁNDAR @ApiProperty({ description: 'Nombre del usuario', example: 'Juan Pérez', minLength: 2, maxLength: 100, }) @IsString({ message: 'El nombre debe ser texto' }) @IsNotEmpty({ message: 'El nombre es requerido' }) @MinLength(2, { message: 'El nombre debe tener al menos 2 caracteres' }) @MaxLength(100, { message: 'El nombre no puede exceder 100 caracteres' }) @Transform(({ value }) => value?.trim()) name: string; ``` ### Número Entero ```typescript // PATRÓN ESTÁNDAR - Entero positivo @ApiProperty({ description: 'Cantidad de items', example: 10, minimum: 1, }) @IsInt({ message: 'La cantidad debe ser un número entero' }) @Min(1, { message: 'La cantidad mínima es 1' }) @Max(10000, { message: 'La cantidad máxima es 10,000' }) quantity: number; // Entero con valor por defecto @ApiPropertyOptional({ description: 'Página actual', default: 1, }) @IsOptional() @IsInt() @Min(1) @Transform(({ value }) => parseInt(value) || 1) page?: number = 1; ``` ### Número Decimal (Dinero) ```typescript // PATRÓN ESTÁNDAR - Precio/Dinero @ApiProperty({ description: 'Precio del producto', example: 99.99, minimum: 0, }) @IsNumber( { maxDecimalPlaces: 2 }, { message: 'El precio debe tener máximo 2 decimales' } ) @Min(0, { message: 'El precio no puede ser negativo' }) @Max(999999.99, { message: 'El precio máximo es 999,999.99' }) price: number; ``` ### Boolean ```typescript // PATRÓN ESTÁNDAR @ApiProperty({ description: 'Estado activo del usuario', example: true, }) @IsBoolean({ message: 'El valor debe ser verdadero o falso' }) @IsNotEmpty() isActive: boolean; // Boolean opcional con default @ApiPropertyOptional({ description: 'Enviar notificación', default: false, }) @IsOptional() @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) sendNotification?: boolean = false; ``` ### Enum ```typescript // PATRÓN ESTÁNDAR export enum UserStatus { ACTIVE = 'active', INACTIVE = 'inactive', SUSPENDED = 'suspended', } @ApiProperty({ description: 'Estado del usuario', enum: UserStatus, example: UserStatus.ACTIVE, }) @IsEnum(UserStatus, { message: 'El estado debe ser: active, inactive o suspended' }) @IsNotEmpty() status: UserStatus; ``` ### Fecha ```typescript // PATRÓN ESTÁNDAR - Fecha ISO @ApiProperty({ description: 'Fecha de nacimiento', example: '1990-05-15', }) @IsISO8601({}, { message: 'La fecha debe estar en formato ISO 8601' }) @IsNotEmpty() birthDate: string; // Fecha como Date object @ApiProperty({ description: 'Fecha de inicio', example: '2024-01-15T10:30:00Z', }) @IsDate({ message: 'Debe ser una fecha válida' }) @Type(() => Date) @IsNotEmpty() startDate: Date; ``` ### URL ```typescript // PATRÓN ESTÁNDAR @ApiPropertyOptional({ description: 'URL del avatar', example: 'https://example.com/avatar.jpg', }) @IsOptional() @IsUrl({}, { message: 'La URL no es válida' }) @MaxLength(500) avatarUrl?: string; ``` ### Teléfono ```typescript // PATRÓN ESTÁNDAR - México @ApiPropertyOptional({ description: 'Número de teléfono', example: '+521234567890', }) @IsOptional() @IsPhoneNumber('MX', { message: 'El número de teléfono no es válido' }) phone?: string; // Alternativa con Regex @IsOptional() @Matches(/^\+?[1-9]\d{1,14}$/, { message: 'Formato de teléfono inválido' }) phone?: string; ``` ### Array ```typescript // PATRÓN ESTÁNDAR - Array de strings @ApiProperty({ description: 'Etiquetas del producto', example: ['electrónica', 'ofertas'], type: [String], }) @IsArray({ message: 'Las etiquetas deben ser un arreglo' }) @IsString({ each: true, message: 'Cada etiqueta debe ser texto' }) @ArrayMinSize(1, { message: 'Debe haber al menos una etiqueta' }) @ArrayMaxSize(10, { message: 'Máximo 10 etiquetas permitidas' }) tags: string[]; // Array de UUIDs @ApiProperty({ description: 'IDs de categorías', type: [String], }) @IsArray() @IsUUID('4', { each: true, message: 'Cada ID debe ser un UUID válido' }) @ArrayMinSize(1) categoryIds: string[]; ``` ### JSON/Object ```typescript // PATRÓN ESTÁNDAR - Objeto flexible @ApiPropertyOptional({ description: 'Metadatos adicionales', example: { key: 'value' }, }) @IsOptional() @IsObject({ message: 'Los metadatos deben ser un objeto' }) metadata?: Record; // Objeto con estructura definida (usar class anidada) class AddressDto { @IsString() @IsNotEmpty() street: string; @IsString() @IsNotEmpty() city: string; @IsString() @Length(5, 5) zipCode: string; } @ApiProperty({ type: AddressDto }) @ValidateNested() @Type(() => AddressDto) address: AddressDto; ``` --- ## 3. DTOs ESTÁNDAR ### CreateDto Pattern ```typescript /** * DTO para crear usuario * * @example * { * "email": "user@example.com", * "password": "SecurePass123!", * "name": "Juan Pérez" * } */ export class CreateUserDto { @ApiProperty({ description: 'Email del usuario', example: 'user@example.com' }) @IsEmail() @IsNotEmpty() @MaxLength(255) @Transform(({ value }) => value?.toLowerCase().trim()) email: string; @ApiProperty({ description: 'Contraseña', example: 'SecurePass123!' }) @IsString() @IsNotEmpty() @MinLength(8) @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/) password: string; @ApiProperty({ description: 'Nombre completo', example: 'Juan Pérez' }) @IsString() @IsNotEmpty() @MinLength(2) @MaxLength(100) @Transform(({ value }) => value?.trim()) name: string; @ApiPropertyOptional({ description: 'Teléfono', example: '+521234567890' }) @IsOptional() @IsPhoneNumber('MX') phone?: string; } ``` ### UpdateDto Pattern ```typescript import { PartialType, OmitType } from '@nestjs/swagger'; /** * DTO para actualizar usuario * * Todos los campos son opcionales. * Email y password NO se pueden actualizar aquí. */ export class UpdateUserDto extends PartialType( OmitType(CreateUserDto, ['email', 'password'] as const) ) {} ``` ### QueryDto Pattern (Filtros y Paginación) ```typescript /** * DTO para búsqueda y paginación de usuarios */ export class QueryUsersDto { @ApiPropertyOptional({ description: 'Página', default: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number = 1; @ApiPropertyOptional({ description: 'Items por página', default: 20 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit?: number = 20; @ApiPropertyOptional({ description: 'Buscar por nombre o email' }) @IsOptional() @IsString() @MaxLength(100) search?: string; @ApiPropertyOptional({ description: 'Filtrar por estado', enum: UserStatus }) @IsOptional() @IsEnum(UserStatus) status?: UserStatus; @ApiPropertyOptional({ description: 'Ordenar por campo' }) @IsOptional() @IsIn(['name', 'email', 'createdAt']) sortBy?: string = 'createdAt'; @ApiPropertyOptional({ description: 'Dirección de orden', enum: ['asc', 'desc'] }) @IsOptional() @IsIn(['asc', 'desc']) sortOrder?: 'asc' | 'desc' = 'desc'; } ``` --- ## 4. VALIDACIÓN EN SERVICE (Lógica de Negocio) ```typescript @Injectable() export class UserService { async create(dto: CreateUserDto): Promise { // Validación de negocio (después de DTO) // 1. Verificar unicidad const existing = await this.repository.findOne({ where: { email: dto.email }, }); if (existing) { throw new ConflictException('El email ya está registrado'); } // 2. Validar reglas de negocio if (await this.isEmailDomainBlocked(dto.email)) { throw new BadRequestException('Dominio de email no permitido'); } // 3. Validar límites const userCount = await this.repository.count({ where: { tenantId: dto.tenantId }, }); if (userCount >= this.MAX_USERS_PER_TENANT) { throw new ForbiddenException('Límite de usuarios alcanzado'); } // Proceder con creación const user = this.repository.create(dto); return this.repository.save(user); } } ``` --- ## 5. VALIDACIÓN EN FRONTEND (React) ### Con React Hook Form + Zod ```typescript import { z } from 'zod'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; // Schema de validación (espejo del DTO backend) const createUserSchema = z.object({ email: z .string() .min(1, 'El email es requerido') .email('Email inválido') .max(255), password: z .string() .min(8, 'Mínimo 8 caracteres') .regex( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/, 'Debe contener mayúscula, minúscula, número y carácter especial' ), name: z .string() .min(2, 'Mínimo 2 caracteres') .max(100, 'Máximo 100 caracteres'), phone: z .string() .regex(/^\+?[1-9]\d{1,14}$/, 'Teléfono inválido') .optional(), }); type CreateUserForm = z.infer; // Uso en componente const UserForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm({ resolver: zodResolver(createUserSchema), }); const onSubmit = async (data: CreateUserForm) => { // data ya está validado await userService.create(data); }; return (
{errors.email && {errors.email.message}} {/* ... */}
); }; ``` --- ## 6. MENSAJES DE ERROR ESTÁNDAR ```typescript // Mensajes consistentes en español const VALIDATION_MESSAGES = { required: (field: string) => `${field} es requerido`, minLength: (field: string, min: number) => `${field} debe tener al menos ${min} caracteres`, maxLength: (field: string, max: number) => `${field} no puede exceder ${max} caracteres`, email: 'El correo electrónico no es válido', uuid: 'El ID no es válido', enum: (values: string[]) => `El valor debe ser uno de: ${values.join(', ')}`, min: (field: string, min: number) => `${field} debe ser mayor o igual a ${min}`, max: (field: string, max: number) => `${field} debe ser menor o igual a ${max}`, pattern: (field: string) => `${field} tiene un formato inválido`, unique: (field: string) => `${field} ya existe`, }; ``` --- ## CHECKLIST DE VALIDACIÓN ``` DTO: [ ] Cada campo tiene @ApiProperty o @ApiPropertyOptional [ ] Campos requeridos tienen @IsNotEmpty [ ] Campos opcionales tienen @IsOptional [ ] Tipos validados (@IsString, @IsNumber, etc.) [ ] Longitudes validadas (@MinLength, @MaxLength) [ ] Formatos validados (@IsEmail, @IsUUID, etc.) [ ] Transformaciones aplicadas (@Transform) [ ] Mensajes de error en español Service: [ ] Validación de unicidad [ ] Validación de existencia (referencias) [ ] Validación de reglas de negocio [ ] Validación de permisos Frontend: [ ] Schema Zod espeja DTO backend [ ] Mensajes de error visibles [ ] Validación en submit [ ] Validación en blur (opcional) ``` --- **Versión:** 1.0.0 | **Sistema:** SIMCO | **Tipo:** Patrón de Validación