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'
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 estructuraltdnError 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'
| Miembro | Tipo | Descripción |
|---|---|---|
tdnErrors (input, requerido) | InputSignal<string> | Nombre del control, o ruta con puntos ('address.street'), resuelta sobre el formulario raíz del [formGroup] envolvente. |
control | Signal<AbstractControl> | El control seguido. Lanza TdnErrorsDirective: FormControl not found: <name> si la ruta no resuelve. |
errors | WritableSignal<ValidationErrors | null> | Errores actuales del control; null si es válido. |
state | WritableSignal<Record<string, boolean>> | Banderas del control que leen las reglas when: touched, untouched, dirty, pristine, valid, invalid, pending, disabled, enabled. |
showing | Signal<boolean> | true mientras alguno de sus mensajes está en pantalla. |
elementId | Signal<string> | Id del bloque: el tuyo si el elemento ya tiene id, o tdn-errors-<n>. |
setErrors(errors) | (errors: ValidationErrors | null) => void | Sobrescribe 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].
| Input | Tipo | Default | Descripción |
|---|---|---|---|
tdnError (requerido) | InputSignal<ErrorOptions> | — | Clave(s) de error que activan el mensaje. Se muestra si alguna está presente. |
when | InputSignal<ErrorOptions | null> | null | Estado(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;
}
}
}
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.
| Estado | Se cumple cuando |
|---|---|
touched | El control fue tocado (blur, markAsTouched(), markAllAsTouched()) |
untouched | El control no ha sido tocado |
dirty | El valor fue modificado o se llamó markAsDirty() |
pristine | El valor no ha sido modificado |
valid / invalid | Resultado de la validación |
pending | Hay validadores asíncronos en curso |
disabled / enabled | Estado 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, otdn-errors-<n>en otro caso. - Dentro de un
tdn-form-element, eltdnControldel mismo control encuentra su bloque y, mientras un mensaje está visible, escribearia-describedby="<id del bloque>"yaria-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,textareayselectlos atributos van en el propio elemento; en un componente personalizado, en el descendiente marcado condata-tdn-aria-target(por ejemplo el trigger detdn-select). Un componente sin ese marcador, comotdn-input-pin, se describe a mano con la referencia exportada. - Un bloque fuera del
tdn-form-elementde 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 displaytdnError 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
*tdnErrorno 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
displayfijado por la aplicación anulahidden.
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ón | Firma | Comportamiento |
|---|---|---|
getFormGroupErrors | (fb?: FormGroup | null) => void | Registra 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) => void | Llama 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
- Usa
when="touched"en los errores de campos requeridos para no mostrarlos antes de la interacción. - Sé específico: "El correo debe tener un formato válido" en lugar de "Error de validación".
- Un solo bloque
[tdnErrors]por control, dentro de sutdn-form-element. - No deshabilites el botón de envío: revela los errores con
FormRevealService. - Mantén el mismo estilo de mensajes en toda la aplicación; los estilos de web-foundation ya lo garantizan.
Ver También
tdnControl: cableado ARIA y signals de estadoTdnFormElement: el contenedor del campoFormRevealService: revelar el primer error al enviar- Validadores: validadores listos para formularios reactivos