Saltar al contenido principal

Manejo de Errores

Paquete: @tiendana/ngx-core/forms

El sistema de errores de ngx-core se compone de dos directivas: [tdnErrors] (TdnErrorsDirective) sigue un control del formulario reactivo y tdnError (TdnErrorDirective) muestra un mensaje cuando un error concreto está presente. Es la forma estándar de mostrar validaciones en toda pantalla tdn-*: no escribas *ngIf="form.get('x')?.hasError(...)" a mano.

Características​

✅ Declarativo: un bloque por control y un mensaje por error
✅ Reacciona a markAllAsTouched() y markAsDirty(), por lo que un envío revela los errores al instante
✅ Reglas when por mensaje (touched, dirty, pristine...)
✅ Cableado ARIA automático: aria-describedby y aria-invalid mientras hay un mensaje visible
✅ Expone errors, state y showing como signals con exportAs: 'tdnErrors'

Nombres de clase

Las dos son directivas: TdnErrorsDirective y TdnErrorDirective. No existe TdnErrorsComponent; si lo ves en algún import, es un nombre incorrecto.

Estructura Básica​

<tdn-form-element>
<label for="email">Correo</label>
<input id="email" type="email" formControlName="email" tdnControl />

<div [tdnErrors]="'email'">
<span tdnError="required" when="touched">El correo es obligatorio</span>
<span tdnError="email" when="touched">El correo no es válido</span>
</div>
</tdn-form-element>

El bloque va dentro del tdn-form-element del control, después de él. Los mensajes son elementos con el atributo tdnError; cada uno se oculta o se muestra según el control.

tdnError no es estructural

tdnError es una directiva de atributo que alterna la propiedad hidden del elemento: el nodo siempre está en el DOM. *tdnError="..." lo convertiría en un ng-template y el mensaje nunca se renderizaría. Escribe <span tdnError="required" when="touched">.

API​

tdnErrors (TdnErrorsDirective)​

Selector: [tdnErrors] · exportAs: 'tdnErrors'

MiembroTipoDescripción
tdnErrors (input, requerido)InputSignal<string>Nombre del control, o ruta con puntos ('address.street'), resuelta sobre el formulario raíz del [formGroup] envolvente.
controlSignal<AbstractControl>El control seguido. Lanza TdnErrorsDirective: FormControl not found: <name> si la ruta no resuelve.
errorsWritableSignal<ValidationErrors | null>Errores actuales del control; null si es válido.
stateWritableSignal<Record<string, boolean>>Banderas del control que leen las reglas when: touched, untouched, dirty, pristine, valid, invalid, pending, disabled, enabled.
showingSignal<boolean>true mientras alguno de sus mensajes está en pantalla.
elementIdSignal<string>Id del bloque: el tuyo si el elemento ya tiene id, o tdn-errors-<n>.
setErrors(errors)(errors: ValidationErrors | null) => voidSobrescribe la señal errors sin tocar el control; el siguiente evento del control la reemplaza.

Para mostrar un error del servidor que persista, llama a setErrors sobre el control (control.setErrors({ rejected: true })), no sobre el bloque.

tdnError (TdnErrorDirective)​

Selector: [tdnError]. Debe estar anidado dentro de un [tdnErrors].

InputTipoDefaultDescripción
tdnError (requerido)InputSignal<ErrorOptions>—Clave(s) de error que activan el mensaje. Se muestra si alguna está presente.
whenInputSignal<ErrorOptions | null>nullEstado(s) del control que deben cumplirse todos. Sin when, se muestra en cuanto existe el error.

Todos los mensajes cuyo error esté presente se muestran a la vez (no hay prioridad entre ellos); si quieres ver uno solo, usa reglas when que no se solapen.

Tipos​

/** Una o varias claves de error o estados: 'required' o ['required', 'email'] */
export type ErrorOptions = string | string[];

/** Contexto de un error. Exportado, pero ninguna directiva lo consume actualmente. */
export interface ErrorDetails {
control: AbstractControl;
errorName: string;
}

Uso Básico​

Ver ejemplo completo de formulario con manejo de errores
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import {
TdnFormElement,
TdnControlDirective,
TdnErrorsDirective,
TdnErrorDirective
} from '@tiendana/ngx-core/forms';

@Component({
selector: 'app-login-form',
imports: [ReactiveFormsModule, TdnFormElement, TdnControlDirective, TdnErrorsDirective, TdnErrorDirective],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<tdn-form-element>
<label for="email">Correo Electrónico</label>
<input id="email" type="email" formControlName="email" tdnControl>

<div [tdnErrors]="'email'">
<span tdnError="required" when="touched">El correo es obligatorio</span>
<span tdnError="email" when="touched">Ingresa un correo electrónico válido</span>
</div>
</tdn-form-element>

<button type="submit" class="bttn bttn--primary">Iniciar sesión</button>
</form>
`
})
export class LoginFormComponent {
private readonly fb = inject(FormBuilder);

readonly form = this.fb.group({
email: ['', [Validators.required, Validators.email]]
});

onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
}
}
No deshabilites el botón de envío

Evita [disabled]="form.invalid": un botón apagado esconde el motivo. Deja el botón activo y revela los errores al enviar con FormRevealService, que además lleva al usuario al primer campo inválido.

Reacción al Estado del Control​

[tdnErrors] escucha el stream events del control, no valueChanges ni statusChanges (que no emiten con markAsTouched() ni markAsDirty()). Por eso un markAllAsTouched() al enviar muestra los mensajes when="touched" de inmediato, aunque nadie haya escrito en el formulario.

onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched(); // los mensajes con when="touched" aparecen ya
return;
}
}

Múltiples Errores​

Puedes mostrar mensajes distintos para el mismo control:

<div [tdnErrors]="'password'">
<span tdnError="required" when="touched">La contraseña es obligatoria</span>
<span tdnError="minlength" when="dirty">Mínimo 8 caracteres</span>
<span tdnError="pattern">Debe contener al menos una mayúscula y un número</span>
</div>

Un mismo mensaje puede responder a varias claves y exigir varios estados:

<span [tdnError]="['pattern', 'custom']" [when]="['touched', 'dirty']">Formato inválido</span>

Condiciones when​

when acepta un estado o un arreglo; con un arreglo deben cumplirse todos.

EstadoSe cumple cuando
touchedEl control fue tocado (blur, markAsTouched(), markAllAsTouched())
untouchedEl control no ha sido tocado
dirtyEl valor fue modificado o se llamó markAsDirty()
pristineEl valor no ha sido modificado
valid / invalidResultado de la validación
pendingHay validadores asíncronos en curso
disabled / enabledEstado de habilitación del control

Controles Anidados​

El nombre se resuelve sobre el formulario raíz. Dentro de un formGroupName usa la ruta completa:

<div formGroupName="address">
<tdn-form-element>
<label for="street">Calle</label>
<input id="street" formControlName="street" tdnControl />
<div [tdnErrors]="'address.street'">
<span tdnError="required" when="touched">La calle es obligatoria</span>
</div>
</tdn-form-element>
</div>

Validadores Personalizados​

[tdnErrors] sigue un control, así que el error debe vivir en ese control. Un validador que devuelve su error en el FormGroup no aparece en el bloque de un hijo.

Validador a nivel de control
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

function sameAs(otherName: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const other = control.parent?.get(otherName);
return other && control.value !== other.value ? { sameAs: true } : null;
};
}

form = this.fb.group({
password: ['', Validators.required],
confirmPassword: ['', [Validators.required, sameAs('password')]]
});
<div [tdnErrors]="'confirmPassword'">
<span tdnError="sameAs" when="touched">Las contraseñas no coinciden</span>
</div>

Los validadores de grupo de CustomValidators (match, lowerThan, betweenRange...) ya están pensados para esto: escriben el error en el control destino, de modo que tdnError="match" funciona en el bloque de ese control.

Referencia Exportada​

El bloque se exporta como tdnErrors para leer sus signals en el template:

<div [tdnErrors]="'email'" #emailErrors="tdnErrors">
@if (emailErrors.errors(); as errs) {
<small>{{ errs | json }}</small>
}
</div>

showing() es true solo mientras alguno de los tdnError hijos del bloque está visible, así que un bloque sin hijos nunca lo activa; por eso el ejemplo lee errors(). El pipe json requiere importar JsonPipe.

También es la forma de describir un control personalizado que no recibe el cableado automático:

<tdn-form-element>
<tdn-input-pin tdnControl formControlName="code" [codeLength]="6" label="Código de verificación"
[describedBy]="errors.showing() ? errors.elementId() : ''" [invalid]="errors.showing()" />
<div [tdnErrors]="'code'" #errors="tdnErrors">
<span tdnError="required" when="touched">Escribe el código</span>
<span tdnError="rejected">El código no es válido</span>
</div>
</tdn-form-element>

Accesibilidad​

  • El bloque lleva un id: el tuyo si el elemento ya tiene uno, o tdn-errors-<n> en otro caso.
  • Dentro de un tdn-form-element, el tdnControl del mismo control encuentra su bloque y, mientras un mensaje está visible, escribe aria-describedby="<id del bloque>" y aria-invalid="true". Cuando no hay mensaje visible los quita, de modo que el campo se anuncia inválido solo si el usuario puede ver el motivo.
  • En input, textarea y select los atributos van en el propio elemento; en un componente personalizado, en el descendiente marcado con data-tdn-aria-target (por ejemplo el trigger de tdn-select). Un componente sin ese marcador, como tdn-input-pin, se describe a mano con la referencia exportada.
  • Un bloque fuera del tdn-form-element de su control sigue mostrando sus mensajes, pero el control no queda descrito por él.

Detalle del cableado en tdnControl.

Estilos​

Dentro de un .tdn-form-element, @tiendana/web-foundation ya estiliza los bloques [tdnErrors] (color rojo, tamaño reducido) y oculta los elementos [hidden], por lo que normalmente no necesitas CSS propio.

hidden pierde frente a display

tdnError oculta con hidden. Si una regla de tu aplicación fija display sobre el elemento del mensaje (display: block, display: flex), el mensaje queda visible siempre, sea cual sea el estado del control.

Cosas que Fallan sin Error​

  • *tdnError no renderiza nada (ver arriba).
  • El control se resuelve una vez, la primera vez que se evalúa. Cambiar después el nombre enlazado a [tdnErrors] deja al bloque escuchando el primer control.
  • Un nombre que no resuelve lanza FormControl not found.
  • Un display fijado por la aplicación anula hidden.

Depuración: getFormGroupErrors y getFormArrayErrors​

Dos helpers de desarrollo que escriben en console.debug los errores de validación, para saber de un vistazo qué control bloquea el formulario.

import { getFormGroupErrors, getFormArrayErrors } from '@tiendana/ngx-core/forms';

getFormGroupErrors(this.form);
// Key control: email, keyError: required, err value: true

getFormArrayErrors(this.form.controls.items);
FunciónFirmaComportamiento
getFormGroupErrors(fb?: FormGroup | null) => voidRegistra una línea por cada clave de error de cada control de primer nivel. No recurre a grupos anidados. No hace nada con null o undefined.
getFormArrayErrors(fa?: FormArray | null) => voidLlama a getFormGroupErrors sobre cada elemento del FormArray, tratado como FormGroup.

Son solo una ayuda de desarrollo: no los dejes en el flujo de producción.

Buenas Prácticas​

  1. Usa when="touched" en los errores de campos requeridos para no mostrarlos antes de la interacción.
  2. Sé específico: "El correo debe tener un formato válido" en lugar de "Error de validación".
  3. Un solo bloque [tdnErrors] por control, dentro de su tdn-form-element.
  4. No deshabilites el botón de envío: revela los errores con FormRevealService.
  5. Mantén el mismo estilo de mensajes en toda la aplicación; los estilos de web-foundation ya lo garantizan.

Ver También​