Creación de validadores síncronos personalizados
Los validadores personalizados permiten crear lógica de validación específica para nuestras necesidades de negocio que no cubren los validadores incorporados de Angular. Un validador personalizado es simplemente una función que recibe un control y retorna un objeto de errores o null.
Anatomía de un ValidatorFn
Un ValidatorFn es una función que implementa la siguiente firma:
export type ValidatorFn = (control: AbstractControl) => ValidationErrors | null;
La función recibe el AbstractControl (FormControl, FormGroup o FormArray) y debe retornar:
null
si el valor es válido- Un objeto ValidationErrors si hay errores
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function minLengthValidator(minLength: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value) {
return null; // No validar valores vacíos
}
return control.value.length >= minLength
? null
: { minlength: { requiredLength: minLength, actualLength: control.value.length } };
};
}
Validadores básicos personalizados
Empecemos con validadores simples que no requieren parámetros. Estos validadores siguen el mismo patrón pero son funciones directas:
// Validador para números pares
export function evenNumberValidator(control: AbstractControl): ValidationErrors | null {
const value = control.value;
if (value === null || value === undefined || value === '') {
return null;
}
const numValue = Number(value);
return !isNaN(numValue) && numValue % 2 === 0
? null
: { evenNumber: { value } };
}
// Validador para formato de teléfono español
export function spanishPhoneValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null;
}
const phoneRegex = /^(\+34|0034|34)?[6789]\d{8}$/;
return phoneRegex.test(control.value)
? null
: { spanishPhone: { value: control.value } };
}
Validadores parametrizables
Los validadores parametrizables son más flexibles ya que aceptan configuración. Se implementan como funciones que retornan ValidatorFn:
// Validador de rango numérico
export function rangeValidator(min: number, max: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value) {
return null;
}
const numValue = Number(control.value);
if (isNaN(numValue)) {
return { range: { invalidNumber: true } };
}
if (numValue < min || numValue > max) {
return { range: { min, max, actual: numValue } };
}
return null;
};
}
// Validador de palabras prohibidas
export function forbiddenWordsValidator(forbiddenWords: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value) {
return null;
}
const text = control.value.toLowerCase();
const foundWord = forbiddenWords.find(word =>
text.includes(word.toLowerCase())
);
return foundWord
? { forbiddenWord: { word: foundWord } }
: null;
};
}
Aplicación en formularios
Para aplicar validadores personalizados, los incluimos en el array de validadores al crear el FormControl:
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-custom-validation',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="userForm">
<div>
<label for="age">Edad:</label>
<input id="age" type="number" formControlName="age">
@if (userForm.get('age')?.hasError('range')) {
<div class="error">
La edad debe estar entre {{ getError('age', 'range')?.min }}
y {{ getError('age', 'range')?.max }} años
</div>
}
</div>
<div>
<label for="comment">Comentario:</label>
<textarea id="comment" formControlName="comment"></textarea>
@if (userForm.get('comment')?.hasError('forbiddenWord')) {
<div class="error">
No se permite la palabra: {{ getError('comment', 'forbiddenWord')?.word }}
</div>
}
</div>
</form>
`
})
export class CustomValidationComponent {
userForm: FormGroup;
constructor(private fb: FormBuilder) {
this.userForm = this.fb.group({
age: ['', [rangeValidator(18, 65)]],
comment: ['', [forbiddenWordsValidator(['spam', 'publicidad'])]]
});
}
getError(controlName: string, errorType: string) {
return this.userForm.get(controlName)?.getError(errorType);
}
}
Validadores cross-field
Los validadores cross-field comparan múltiples campos dentro del mismo FormGroup. Se aplican al grupo en lugar de a controles individuales:
// Validador para confirmar contraseña
export function passwordMatchValidator(control: AbstractControl): ValidationErrors | null {
const password = control.get('password');
const confirmPassword = control.get('confirmPassword');
if (!password || !confirmPassword) {
return null;
}
return password.value === confirmPassword.value
? null
: { passwordMismatch: true };
}
// Validador de fechas (desde <= hasta)
export function dateRangeValidator(control: AbstractControl): ValidationErrors | null {
const startDate = control.get('startDate');
const endDate = control.get('endDate');
if (!startDate?.value || !endDate?.value) {
return null;
}
const start = new Date(startDate.value);
const end = new Date(endDate.value);
return start <= end
? null
: { dateRange: { start: startDate.value, end: endDate.value } };
}
Implementación de validadores condicionales
Los validadores condicionales se ejecutan basándose en el valor de otros campos:
export function conditionalValidator(
condition: (control: AbstractControl) => boolean,
validator: ValidatorFn
): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!condition(control)) {
return null;
}
return validator(control);
};
}
// Uso del validador condicional
export function createUserForm(fb: FormBuilder): FormGroup {
const form = fb.group({
userType: [''],
companyName: [''],
taxId: ['']
});
// Solo validar datos de empresa si el tipo es 'business'
form.get('companyName')?.setValidators([
conditionalValidator(
(control) => form.get('userType')?.value === 'business',
Validators.required
)
]);
form.get('taxId')?.setValidators([
conditionalValidator(
(control) => form.get('userType')?.value === 'business',
Validators.pattern(/^[A-Z]\d{8}$/)
)
]);
return form;
}
Ejemplo completo con múltiples validadores
@Component({
selector: 'app-registration-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="registrationForm" (ngSubmit)="onSubmit()">
<div>
<input formControlName="username" placeholder="Usuario">
@if (registrationForm.get('username')?.errors; as errors) {
<div class="errors">
@if (errors['required']) { <div>El usuario es requerido</div> }
@if (errors['forbiddenName']) { <div>Usuario no permitido</div> }
@if (errors['minlength']) { <div>Mínimo 3 caracteres</div> }
</div>
}
</div>
<div formGroupName="passwords">
<input formControlName="password" type="password" placeholder="Contraseña">
<input formControlName="confirmPassword" type="password" placeholder="Confirmar contraseña">
@if (registrationForm.get('passwords')?.errors?.['passwordMismatch']) {
<div class="error">Las contraseñas no coinciden</div>
}
</div>
<button type="submit" [disabled]="registrationForm.invalid">
Registrarse
</button>
</form>
`
})
export class RegistrationFormComponent {
registrationForm: FormGroup;
constructor(private fb: FormBuilder) {
this.registrationForm = this.fb.group({
username: ['', [
Validators.required,
Validators.minLength(3),
forbiddenNameValidator(['admin', 'root'])
]],
passwords: this.fb.group({
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required]
}, { validators: passwordMatchValidator })
});
}
onSubmit() {
if (this.registrationForm.valid) {
console.log('Formulario válido:', this.registrationForm.value);
}
}
}
function forbiddenNameValidator(forbiddenNames: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const forbidden = forbiddenNames.includes(control.value?.toLowerCase());
return forbidden ? { forbiddenName: { value: control.value } } : null;
};
}
Los validadores síncronos personalizados ofrecen flexibilidad total para implementar cualquier lógica de validación que requiera nuestra aplicación, desde validaciones simples hasta complejas reglas de negocio que involucren múltiples campos.
Validadores asíncronos personalizados y casos de uso
Los validadores asíncronos nos permiten realizar validaciones que requieren operaciones no bloqueantes, como consultas HTTP para verificar disponibilidad de datos en el servidor. A diferencia de los validadores síncronos, estos retornan Promises u Observables.
Anatomía de un AsyncValidatorFn
Un AsyncValidatorFn implementa la siguiente interfaz:
export type AsyncValidatorFn = (control: AbstractControl) =>
Promise<ValidationErrors | null> | Observable<ValidationErrors | null>;
La función debe retornar:
- Promise<ValidationErrors | null> para validaciones basadas en promesas
- Observable<ValidationErrors | null> para validaciones reactivas
import { AbstractControl, ValidationErrors, AsyncValidatorFn } from '@angular/forms';
import { Observable, of, map, catchError, delay } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
export function usernameAvailabilityValidator(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
}
const http = inject(HttpClient);
return http.get<{available: boolean}>(`/api/check-username/${control.value}`).pipe(
map(response => response.available ? null : { usernameTaken: { value: control.value } }),
catchError(() => of({ validationError: { message: 'Error verificando disponibilidad' } }))
);
};
}
Validadores asíncronos con debounce
Para optimizar las consultas al servidor, implementamos debounce que retrasa la ejecución hasta que el usuario deje de escribir:
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
export function emailAvailabilityValidator(delayMs: number = 500): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
}
const http = inject(HttpClient);
return of(control.value).pipe(
debounceTime(delayMs), // Esperar a que el usuario pare de escribir
distinctUntilChanged(), // Solo validar si el valor cambió
switchMap(value =>
http.post<{exists: boolean}>('/api/validate-email', { email: value }).pipe(
map(response => response.exists
? { emailExists: { email: value } }
: null
),
catchError(error => of({ networkError: { message: 'Error de conexión' } }))
)
)
);
};
}
Validadores asíncronos con Promise
También podemos implementar validadores basados en Promise para casos más simples:
export function slugAvailabilityValidator(): AsyncValidatorFn {
return async (control: AbstractControl): Promise<ValidationErrors | null> => {
if (!control.value) {
return null;
}
try {
const response = await fetch(`/api/slugs/${control.value}/exists`);
const data = await response.json();
return data.exists
? { slugExists: { slug: control.value } }
: null;
} catch (error) {
return { validationError: { message: 'Error verificando slug' } };
}
};
}
// Validador con configuración personalizada
export function customSlugValidator(apiEndpoint: string, timeout: number = 5000): AsyncValidatorFn {
return (control: AbstractControl): Promise<ValidationErrors | null> => {
if (!control.value) {
return Promise.resolve(null);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
return fetch(`${apiEndpoint}/${control.value}`, {
signal: controller.signal
})
.then(response => response.json())
.then(data => {
clearTimeout(timeoutId);
return data.available ? null : { slugTaken: { value: control.value } };
})
.catch(() => {
clearTimeout(timeoutId);
return { networkTimeout: { timeout } };
});
};
}
Casos de uso empresariales
Los validadores asíncronos son especialmente útiles en escenarios empresariales complejos:
// Validador para códigos de empleado únicos
export function employeeCodeValidator(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value || control.value.length < 4) {
return of(null);
}
const http = inject(HttpClient);
return http.get<{isUnique: boolean, department: string}>
(`/api/employees/validate-code/${control.value}`).pipe(
map(result => {
if (!result.isUnique) {
return {
employeeCodeExists: {
code: control.value,
department: result.department
}
};
}
return null;
}),
catchError(() => of({ employeeValidationError: true }))
);
};
}
// Validador de permisos de usuario
export function userPermissionValidator(requiredPermission: string): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
}
const http = inject(HttpClient);
return http.post<{hasPermission: boolean}>('/api/validate-permission', {
userId: control.value,
permission: requiredPermission
}).pipe(
map(response => response.hasPermission
? null
: { insufficientPermissions: { required: requiredPermission } }
),
catchError(() => of({ permissionCheckFailed: true }))
);
};
}
Aplicación y manejo de estados
Los validadores asíncronos introducen el estado pending que debemos manejar en nuestros componentes:
@Component({
selector: 'app-async-validation',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="userForm">
<div>
<label for="username">Nombre de usuario:</label>
<input
id="username"
formControlName="username"
placeholder="Escriba su nombre de usuario">
@if (userForm.get('username')?.pending) {
<div class="loading">🔄 Verificando disponibilidad...</div>
}
@if (userForm.get('username')?.hasError('usernameTaken')) {
<div class="error">
❌ El usuario "{{ userForm.get('username')?.value }}" no está disponible
</div>
}
@if (userForm.get('username')?.hasError('validationError')) {
<div class="error">⚠️ Error de conexión. Inténtelo más tarde.</div>
}
@if (userForm.get('username')?.valid && userForm.get('username')?.value) {
<div class="success">✅ Usuario disponible</div>
}
</div>
<div>
<label for="email">Email corporativo:</label>
<input
id="email"
type="email"
formControlName="email"
placeholder="usuario@empresa.com">
@if (userForm.get('email')?.pending) {
<div class="loading">🔄 Validando email...</div>
}
@if (userForm.get('email')?.hasError('emailExists')) {
<div class="error">Este email ya está registrado</div>
}
</div>
<button
type="submit"
[disabled]="userForm.invalid || userForm.pending">
@if (userForm.pending) {
Validando...
} @else {
Crear cuenta
}
</button>
</form>
`,
styles: [`
.loading { color: #007bff; font-size: 0.9em; }
.error { color: #dc3545; font-size: 0.9em; }
.success { color: #28a745; font-size: 0.9em; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
`]
})
export class AsyncValidationComponent {
userForm: FormGroup;
constructor(private fb: FormBuilder) {
this.userForm = this.fb.group({
username: ['',
[Validators.required, Validators.minLength(3)], // Síncronos
[usernameAvailabilityValidator()] // Asíncronos
],
email: ['',
[Validators.required, Validators.email],
[emailAvailabilityValidator(300)] // Debounce de 300ms
]
});
}
onSubmit() {
if (this.userForm.valid) {
console.log('Usuario válido:', this.userForm.value);
}
}
}
Validadores asíncronos cross-field
Los validadores cross-field asíncronos pueden validar combinaciones de campos contra el servidor:
export function projectCodeValidator(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const department = control.get('department')?.value;
const code = control.get('projectCode')?.value;
if (!department || !code) {
return of(null);
}
const http = inject(HttpClient);
return http.post<{isValid: boolean, conflicts: string[]}>('/api/validate-project', {
department,
code
}).pipe(
debounceTime(400),
map(result => result.isValid
? null
: { projectConflict: { conflicts: result.conflicts } }
),
catchError(() => of({ projectValidationFailed: true }))
);
};
}
// Aplicación en FormGroup
export function createProjectForm(fb: FormBuilder): FormGroup {
return fb.group({
department: ['', Validators.required],
projectCode: ['', [Validators.required, Validators.pattern(/^[A-Z]{2}\d{4}$/)]]
}, {
asyncValidators: [projectCodeValidator()]
});
}
Combinación de validadores síncronos y asíncronos
En formularios complejos empresariales, combinamos ambos tipos de validadores para una validación completa:
export function createAdvancedUserForm(fb: FormBuilder): FormGroup {
return fb.group({
personalInfo: fb.group({
firstName: ['', [Validators.required, Validators.minLength(2)]],
lastName: ['', [Validators.required, Validators.minLength(2)]],
employeeId: ['',
[Validators.required, Validators.pattern(/^EMP\d{6}$/)],
[employeeCodeValidator()]
]
}),
credentials: fb.group({
username: ['',
[Validators.required, Validators.minLength(4)],
[usernameAvailabilityValidator()]
],
email: ['',
[Validators.required, Validators.email],
[emailAvailabilityValidator(400)]
]
}, { validators: credentialsValidator }), // Cross-field síncrono
permissions: fb.group({
role: ['', Validators.required],
accessLevel: ['', Validators.required]
}, {
asyncValidators: [userPermissionValidator('CREATE_USERS')] // Cross-field asíncrono
})
});
}
Los validadores asíncronos proporcionan validación en tiempo real contra sistemas backend, mejorando la experiencia de usuario al prevenir errores antes del envío del formulario. La clave está en optimizar las consultas con debounce y manejar adecuadamente los estados de carga y error.

Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Angular es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Angular
Explora más contenido relacionado con Angular y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
- Comprender la estructura y creación de validadores síncronos personalizados en Angular.
- Implementar validadores parametrizables y cross-field para validar múltiples campos.
- Desarrollar validadores asíncronos que permitan validaciones basadas en operaciones no bloqueantes como consultas HTTP.
- Aplicar validadores condicionales y manejar estados de validación en formularios reactivos.
- Combinar validadores síncronos y asíncronos para validaciones complejas en aplicaciones empresariales.