Validators custom — sync, async, cross-field, composition, schema interop
TL;DR — Un validator custom est une fonction pure qui inspecte un
AbstractControlet retourne soitnull(valide) soit un objetValidationErrors({ [key: string]: any }). Les sync (ValidatorFn) retournent directement la valeur ; les async (AsyncValidatorFn) retournent unObservable<ValidationErrors | null>ou unePromise. Pour des validators paramétrés, on utilise une factory (minWords(n: number): ValidatorFn). Pour les cross-field, on place le validator sur leFormGroupparent. Les async doivent toujours s'accompagner deupdateOn: 'blur'ou d'undebounceTimeinterne, sinon l'API est spammée. L'affichage des erreurs se centralise (i18n, message map), et la validation peut être déléguée à un schéma (Zod, class-validator) via un adapter, ce qui aligne front et back sur une seule source de vérité.
🧠 Mental model — ASCII + analogie
Un validator est un prédicat décoré : il regarde une valeur, et soit dit "c'est bon" (null), soit explique ce qui cloche ({ tooShort: { actual: 3, required: 8 } }). Angular compose tous les validators d'un contrôle en OR-de-erreurs : tous tournent, leurs erreurs se mergent dans control.errors.
control.value = "ab"
│
┌────────────────┴────────────────┐
│ │
┌───────▼──────┐ ┌──────▼──────┐
│ required │ │ minLength(8)│
└───────┬──────┘ └──────┬──────┘
null│ │ { minlength: { actualLength: 2, requiredLength: 8 } }
│ │
└───────────────┬─────────────────┘
▼
control.errors = {
minlength: { actualLength: 2, requiredLength: 8 }
}
control.valid = false
control.status = 'INVALID'Analogie : un validator est un inspecteur dans une chaîne d'assemblage. Chaque inspecteur regarde le produit, met une étiquette rouge s'il trouve un défaut, sinon laisse passer. À la fin, le produit est valid s'il n'a aucune étiquette. Les async validators sont des inspecteurs qui doivent appeler un labo externe — leur verdict n'est pas immédiat (status: 'PENDING' pendant qu'ils attendent).
Le pipeline de validation, dans l'ordre exact
Quand une valeur change (selon updateOn), Angular exécute la séquence suivante — la connaître par cœur évite 80 % des bugs de validation :
value change (updateOn: change | blur | submit)
│
▼
1. updateValueAndValidity() ← le moteur démarre
│
▼
2. SYNC validators (tous, en parallèle logique)
│ merge des ValidationErrors → control.errors
│
├── au moins une erreur sync ? ──► status = INVALID, FIN. Les async NE tournent PAS.
│
▼ (tous sync OK)
3. status = PENDING
│
▼
4. ASYNC validators lancés (composeAsync → forkJoin interne)
│ l'Observable/Promise précédent est unsubscribe (annulé) si une nouvelle run démarre
▼
5. résolution → merge errors → status = VALID | INVALID
│
▼
6. statusChanges.emit() + remontée au parent (FormGroup recalcule SON validité)Trois conséquences que tout senior doit avoir en tête :
- Les sync gardent les async. Inutile d'ajouter
if (control.invalid) return of(null)dans un async pour les erreurs sync — Angular ne l'aura jamais lancé. Ce garde ne sert que contre des erreurs async antérieures ou un état transitoire. PENDINGremonte. UnFormGroupestPENDINGtant qu'un de ses descendants l'est. D'oùform.pendingpour désactiver le submit.- L'annulation est gérée par Angular pour vous au niveau du control : si la valeur rechange pendant qu'un async tourne, Angular
unsubscribel'ancien Observable. LeswitchMapinterne, lui, protège contre les courses à l'intérieur d'une même run (ex. untimersuivi d'un HTTP). Les deux niveaux sont complémentaires.
Le 4ᵉ état que tout le monde oublie : DISABLED
control.status n'a pas trois valeurs mais quatre : VALID | INVALID | PENDING | DISABLED. Le piège senior : un contrôle disabled ne tourne aucun validator et n'apparaît pas dans group.value (il faut group.getRawValue() pour l'inclure). Conséquences concrètes :
| Question | Réponse |
|---|---|
control.disable() → le validator tourne-t-il ? | Non. status = 'DISABLED', errors = null. |
Un champ disabled casse-t-il un cross-field qui le lit ? | Oui — group.get('x')?.value renvoie la dernière valeur, mais group.value.x est undefined. Lire via getRawValue() dans le cross-field si le champ peut être désactivé. |
form.valid quand un enfant est DISABLED et un autre INVALID ? | INVALID — un enfant désactivé est ignoré, pas compté comme valide ni invalide. |
| Comment désactiver sans perdre la validation ? | Garder le contrôle enabled mais readonly côté template, ou utiliser { emitEvent: false } pour ne pas re-déclencher. |
C'est la source numéro un des bugs « mon submit reste grisé / s'active alors qu'un champ est faux » : un wizard qui disable() des étapes futures fausse le form.valid global si on n'y prend pas garde. Règle : désactiver = sortir du contrat de validation, pas « valeur en lecture seule ».
Tableau de décision : sync vs async vs cross-field vs schéma
| Besoin | Mécanisme | Où l'attacher | Coût |
|---|---|---|---|
| Règle locale instantanée (format, longueur, regex) | ValidatorFn sync | validators du control | ~0, tourne à chaque keystroke |
| Règle dépendant de plusieurs champs | ValidatorFn sync | validators du FormGroup | léger, mais re-tourne si n'importe quel enfant change |
| Vérification serveur (unicité, existence) | AsyncValidatorFn | asyncValidators + updateOn:'blur' | réseau — à débouncer/cacher impérativement |
| Schéma partagé front/back | adapter Zod/class-validator | sync ou async selon le schéma | aligne les deux bords, perd la granularité des codes |
| Règle qui dépend d'un autre control à distance (autre form) | valueChanges + setErrors manuel, ou signal | en dehors du pipeline | hors-contrat, à isoler dans un service |
🛠️ Code minimal (ts + html)
Sync validators (factories paramétrées)
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Le mot de passe doit contenir au moins une majuscule, un chiffre, un symbole.
export const strongPassword: ValidatorFn = (
control: AbstractControl,
): ValidationErrors | null => {
const value: string = control.value ?? '';
const errors: ValidationErrors = {};
if (!/[A-Z]/.test(value)) errors['noUppercase'] = true;
if (!/[0-9]/.test(value)) errors['noDigit'] = true;
if (!/[!@#$%^&*]/.test(value)) errors['noSymbol'] = true;
return Object.keys(errors).length ? errors : null;
};
// Factory paramétrée.
export function minWords(min: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value: string = control.value ?? '';
const wordCount = value.trim().split(/\s+/).filter(Boolean).length;
return wordCount < min
? { minWords: { actual: wordCount, required: min } }
: null;
};
}
// Validator regex paramétré avec un message contextuel.
export function pattern(regex: RegExp, label: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return regex.test(control.value ?? '') ? null : { pattern: { label, regex: regex.source } };
};
}Cross-field validator (sur le FormGroup parent)
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export const dateRange: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const start = group.get('startDate')?.value as Date | null;
const end = group.get('endDate')?.value as Date | null;
if (!start || !end) return null;
return start.getTime() <= end.getTime()
? null
: { dateRange: { start: start.toISOString(), end: end.toISOString() } };
};
export function fieldsMatch(controlA: string, controlB: string, errorKey = 'fieldsMatch'): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const a = group.get(controlA)?.value;
const b = group.get(controlB)?.value;
if (a == null || b == null) return null;
return a === b ? null : { [errorKey]: true };
};
}Usage :
this.form = this.fb.group(
{
password: this.fb.control('', [Validators.required, Validators.minLength(10), strongPassword]),
confirm: this.fb.control('', Validators.required),
startDate: this.fb.control<Date | null>(null),
endDate: this.fb.control<Date | null>(null),
},
{
validators: [fieldsMatch('password', 'confirm', 'passwordMismatch'), dateRange],
},
);Async validator avec debounce, switchMap, et updateOn: 'blur'
import { inject } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { Observable, map, of, switchMap, tap, timer } from 'rxjs';
export function uniqueUsername(): AsyncValidatorFn {
const http = inject(HttpClient);
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) return of(null);
return timer(300).pipe(
switchMap(() =>
http.get<{ available: boolean }>(`/api/usernames/check`, {
params: { name: control.value },
}),
),
map((res) => (res.available ? null : { usernameTaken: true })),
);
};
}Branchement sur le contrôle :
username: this.fb.control('', {
validators: [Validators.required, Validators.minLength(3)],
asyncValidators: [uniqueUsername()],
updateOn: 'blur', // évite de spammer l'API à chaque frappe
}),Point critique : on utilise timer(300).pipe(switchMap(...)) au lieu de debounceTime(300) parce qu'un async validator est rappelé sur un nouveau Observable à chaque exécution — il n'y a pas de flux continu sur lequel un debounceTime aurait du sens. timer introduit le délai à l'intérieur du validator. Combiné à updateOn: 'blur', l'API n'est appelée qu'au blur, et le timer agit comme une garde supplémentaire si plusieurs blurs sont déclenchés en rafale.
Composant d'affichage d'erreurs centralisé
import { Component, Input, inject } from '@angular/core';
import { AbstractControl } from '@angular/forms';
export const ERROR_MESSAGES: Record<string, (params: any) => string> = {
required: () => 'Ce champ est requis.',
email: () => 'Format d\'email invalide.',
minlength: ({ requiredLength, actualLength }) =>
`Au moins ${requiredLength} caractères (actuel : ${actualLength}).`,
pattern: ({ label }) => `Format invalide${label ? ` (${label})` : ''}.`,
minWords: ({ required, actual }) => `Au moins ${required} mots (actuel : ${actual}).`,
noUppercase: () => 'Doit contenir au moins une majuscule.',
noDigit: () => 'Doit contenir au moins un chiffre.',
noSymbol: () => 'Doit contenir au moins un symbole (!@#$%^&*).',
usernameTaken: () => 'Ce nom d\'utilisateur est déjà pris.',
passwordMismatch: () => 'Les mots de passe ne correspondent pas.',
dateRange: () => 'La date de début doit précéder la date de fin.',
};
@Component({
selector: 'app-form-errors',
standalone: true,
template: `
@if (shouldShow()) {
<ul class="error-list">
@for (entry of visibleErrors(); track entry.key) {
<li>{{ entry.message }}</li>
}
</ul>
}
`,
})
export class FormErrorsComponent {
@Input({ required: true }) control!: AbstractControl;
shouldShow(): boolean {
return (this.control.touched || this.control.dirty) && !!this.control.errors;
}
visibleErrors(): { key: string; message: string }[] {
const errs = this.control.errors ?? {};
return Object.entries(errs).map(([key, params]) => ({
key,
message: ERROR_MESSAGES[key]?.(params) ?? `Erreur : ${key}`,
}));
}
}Utilisation :
<input formControlName="username" />
<app-form-errors [control]="form.controls.username" />C'est extensible (on enrichit ERROR_MESSAGES), i18n-ready (on remplace les strings par des clés $localize ou des messages d'un service i18n), et réutilisable partout.
🎯 Patterns courants
Forme du ValidationErrors
Le format standard est { [key: string]: any }. Le mieux est de stocker un objet contextualisé plutôt qu'un simple true, parce que l'objet sert ensuite à composer le message d'erreur :
// Faible
return { minWords: true };
// Bon
return { minWords: { required: 5, actual: 2 } };Pourquoi ? Parce que le message "Au moins 5 mots (actuel : 2)" devient possible sans dupliquer la logique. Les validators built-in d'Angular suivent déjà cette convention (minlength: { requiredLength, actualLength }).
Composition de validators
Validators.compose([...]) n'est presque jamais nécessaire — le tableau passé en deuxième argument de FormControl ou via fb.control('', [...]) est déjà composé automatiquement. On utilise compose quand on construit une ValidatorFn à partir d'une liste, par exemple dans une factory de directive.
Validators.composeAsync([...]) existe aussi pour les async. La règle d'évaluation : tous les validators sync tournent, puis les async sont lancés seulement si tous les sync passent. C'est volontaire : pas la peine d'appeler l'API pour vérifier l'unicité d'un email manifestement malformé.
Validators paramétrés via factory
C'est le pattern le plus puissant. La factory clôt un paramètre dans sa closure et retourne le ValidatorFn :
export function inRange(min: number, max: number): ValidatorFn {
return (control) => {
const v = Number(control.value);
if (Number.isNaN(v)) return { inRange: { error: 'NaN' } };
if (v < min) return { inRange: { error: 'below', min, max, actual: v } };
if (v > max) return { inRange: { error: 'above', min, max, actual: v } };
return null;
};
}
// Usage
qty: this.fb.control(0, inRange(1, 100)),Cross-field au niveau group
Un validator placé sur un FormControl n'a accès qu'à sa propre valeur. Pour comparer deux champs, on attache au FormGroup parent via la clé validators du second argument de fb.group(...). Le validator reçoit l'AbstractControl qui est en fait le group — on caste implicitement.
this.fb.group({ /* ... */ }, { validators: [crossFieldValidator] });L'erreur s'attache au group, pas aux contrôles enfants. Pour l'afficher proprement, on lit form.errors?.['passwordMismatch'] côté template. Si on veut que l'erreur "appartienne" à un contrôle enfant pour des raisons d'UX, on la propage manuellement :
export const fieldsMatchPropagate = (a: string, b: string): ValidatorFn => (group) => {
const ca = group.get(a);
const cb = group.get(b);
if (!ca || !cb || ca.value === cb.value) {
if (cb?.errors?.['mismatch']) {
const { mismatch, ...rest } = cb.errors;
cb.setErrors(Object.keys(rest).length ? rest : null);
}
return null;
}
cb.setErrors({ ...cb.errors, mismatch: true });
return { mismatch: true };
};Attention : setErrors côté enfant déclenche des recalculs ; mal utilisé, on crée une boucle. Préférer la version simple (erreur sur le group) à 95 %.
Async validators et performance
L'erreur classique : un async validator qui appelle l'API à chaque keystroke. Avec updateOn: 'change' (le défaut), on consomme 30 requêtes pour "[email protected]". Solutions cumulables :
updateOn: 'blur'sur le contrôle : recalcul uniquement à la perte de focus. C'est l'option qui économise le plus.timer(300)dans le validator : un délai interne qui amortit les blurs répétés.switchMapannule la requête précédente si une nouvelle arrive.- Vérification préalable : si la valeur est vide ou clairement invalide, retourner
of(null)immédiatement sans appeler l'API. - Cache local : un
Map<string, ValidationErrors | null>qui mémorise les résultats récents.
export function uniqueUsernameCached(): AsyncValidatorFn {
const http = inject(HttpClient);
const cache = new Map<string, ValidationErrors | null>();
return (control) => {
const value: string = control.value ?? '';
if (!value) return of(null);
if (cache.has(value)) return of(cache.get(value) ?? null);
return timer(300).pipe(
switchMap(() => http.get<{ available: boolean }>(`/api/usernames/check?name=${value}`)),
map((res) => (res.available ? null : { usernameTaken: true })),
tap((result) => cache.set(value, result)),
);
};
}Production : observabilité, sécurité, et le mensonge de Validators.email
Un validator custom est du code en prod ; on le traite comme tel.
Observabilité. Les validators async sont des appels réseau silencieux. En prod, on veut savoir lesquels échouent, lesquels timeout, et le taux de rejet métier (usernameTaken à 90 % = un signal produit, pas un bug). On instrumente dans l'adaptateur, jamais dans la règle pure :
export function uniqueUsernameObserved(metrics: MetricsClient): AsyncValidatorFn {
const http = inject(HttpClient);
return (control) => {
const value: string = control.value ?? '';
if (!value) return of(null);
const t0 = performance.now();
return timer(300).pipe(
switchMap(() => http.get<{ available: boolean }>(`/api/usernames/check?name=${value}`)),
tap((res) => metrics.timing('validator.username.latency', performance.now() - t0, { taken: String(!res.available) })),
map((res) => (res.available ? null : { usernameTaken: true })),
catchError((e) => { metrics.increment('validator.username.error', { code: e.status }); return of(null); }),
);
};
}Sécurité. Un validator est une garde UX, jamais une garde de sécurité. Tout ce qui est validé côté client est trivialement contournable (DevTools, requête directe). Le serveur revalide toujours — d'où l'intérêt d'un schéma partagé (Zod/class-validator) : même règle, deux exécutions, le client pour l'UX et le serveur pour la vérité. Ne jamais mettre une règle d'autorisation (« seul un admin peut ce montant ») dans un validator front : c'est de la décoration, pas du contrôle.
ReDoS. Une regex « catastrophique » dans un validator sync tourne sur le main thread à chaque keystroke avec une entrée attaquant-contrôlée. ^(a+)+$ sur "aaaa...!" fige l'onglet (backtracking exponentiel). Audit : éviter les quantificateurs imbriqués, préférer des regex linéaires ou la lib re2 côté serveur. C'est le pitfall 10 vu sous l'angle sécurité.
Validators.email est volontairement laxiste. Il accepte a@b (pas de TLD requis) et rejette des adresses RFC-valides exotiques. C'est by design : la seule validation fiable d'un email est l'envoi d'un lien de confirmation. Pour un format strict, un pattern custom ; pour la vérité, un async qui vérifie le MX puis un double opt-in. Ne pas sur-investir dans la regex email.
Statut PENDING et UX
Pendant qu'un async validator tourne, control.status vaut 'PENDING'. On l'utilise pour afficher un spinner et désactiver le bouton submit :
<input formControlName="username" />
@if (form.controls.username.pending) {
<small>Vérification...</small>
}
<button type="submit" [disabled]="form.invalid || form.pending">S'inscrire</button>Messages d'erreur et i18n
Trois stratégies, du plus simple au plus robuste :
- Messages inline dans le template :
@if (errs['required']) { Champ requis. }. Simple, mais duplique les messages dans toute la codebase. - Map centralisée (cf.
ERROR_MESSAGESplus haut) : un objet{ [key]: (params) => string }. Idéal pour des apps mono-locale. - Service i18n : un
ErrorMessageServicequi résout les clés via$localize,ngx-translate, ou@angular/localize. Permet le multi-locale et le hot-swap.
@Injectable({ providedIn: 'root' })
export class ErrorMessageService {
private translate = inject(TranslateService);
resolve(key: string, params: any): string {
return this.translate.instant(`validation.${key}`, params);
}
}Interop avec Zod ou class-validator
Quand le schéma de validation est défini ailleurs (souvent partagé entre front et back), on génère les validators Angular depuis ce schéma plutôt que de les écrire à la main.
Avec Zod :
import { z, ZodSchema } from 'zod';
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function fromZod(schema: ZodSchema): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const result = schema.safeParse(control.value);
if (result.success) return null;
const issues = result.error.issues;
return {
schema: {
messages: issues.map((i) => i.message),
codes: issues.map((i) => i.code),
},
};
};
}
// Usage
const usernameSchema = z.string().min(3).max(20).regex(/^[a-z0-9_]+$/);
this.fb.control('', fromZod(usernameSchema));Avec class-validator (typique côté NestJS) :
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
export function fromClass<T extends object>(cls: new () => T, field: keyof T): AsyncValidatorFn {
return async (control) => {
const dto = plainToInstance(cls, { [field]: control.value } as any);
const errors = await validate(dto, { skipMissingProperties: true });
const fieldErrors = errors.filter((e) => e.property === field);
return fieldErrors.length
? { schema: { constraints: fieldErrors.map((e) => e.constraints) } }
: null;
};
}L'avantage : un seul schéma maintenu côté back-end ou dans un package partagé. Le tradeoff : on perd la granularité des codes d'erreur (required, minlength...) au profit d'un seul schema avec un payload — il faut adapter l'affichage des erreurs en conséquence.
Note version (Zod 3 vs 4).
result.error.issuesreste l'API stable dans Zod 3 et 4. Zod 4 a renomméZodError.format()/flatten()versz.treeifyError()/z.flattenError()(les anciennes méthodes existent mais sont dépréciées) ; pour un adapter de validator, itérerissuesdirectement est la voie la plus stable, indépendante de la version. Côté codes, Zod 4 a aussi consolidé certainscode(ex.invalid_string→invalid_format) : si vous mappezissue.codesur des clés d'ERROR_MESSAGES, verrouillez la version de Zod et testez le mapping plutôt que de coder les codes en dur sans filet.
Astuce de staff : pour garder la granularité, mappez chaque issue Zod sur sa propre clé d'erreur via le path :
export function fromZodGranular(schema: ZodSchema): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const result = schema.safeParse(control.value);
if (result.success) return null;
// Une clé par code d'issue → l'ERROR_MESSAGES map existante continue de fonctionner.
const errors: ValidationErrors = {};
for (const issue of result.error.issues) {
errors[issue.code] = { message: issue.message, path: issue.path };
}
return errors;
};
}🧬 Signal Forms — la validation à l'ère des signaux (Angular 20+)
Angular 20 stabilise progressivement les Signal Forms (@angular/forms/signals, preview). Le contrat conceptuel d'un validator ne change pas — null = valide, sinon des erreurs — mais la forme du code évolue : un validator devient une fonction sur un contexte réactif plutôt qu'une fonction sur un AbstractControl, et il se déclare dans un schema() co-localisé avec le modèle.
import { form, schema, required, minLength, validate, customError } from '@angular/forms/signals';
import { signal } from '@angular/core';
interface Signup {
username: string;
password: string;
confirm: string;
}
const signupSchema = schema<Signup>((path) => {
required(path.username);
minLength(path.password, 10);
// Validator de champ : reçoit le FieldContext, lit la valeur via .value()
validate(path.password, ({ value }) => {
const v = value();
return /[A-Z]/.test(v) && /[0-9]/.test(v) && /[!@#$%^&*]/.test(v)
? null
: customError({ kind: 'weakPassword', message: 'Majuscule + chiffre + symbole requis.' });
});
// Cross-field : on valide à la racine et on lit deux sous-champs.
validate(path, ({ value }) => {
const { password, confirm } = value();
return password === confirm ? null : customError({ kind: 'passwordMismatch' });
});
});
const model = signal<Signup>({ username: '', password: '', confirm: '' });
const signupForm = form(model, signupSchema);
// signupForm.username().errors() est un signal ; signupForm().valid() aussi.Ce qui change pour un senior :
- Réactivité de bout en bout.
errors(),valid(),pending()sont des signaux : pas devalueChanges.subscribe, pas de zone, fonctionne en zoneless. Uncomputedqui litform().valid()se recalcule tout seul. - Validators conditionnels first-class. Une règle «
confirmrequis seulement sipasswordrempli » s'exprime directement dans leschema()sans réabonnement manuel — l'ancien monde nécessitait unvalueChanges+addValidators/removeValidators. - Async :
validateAsyncaccepte une factory qui retourne uneResourceou unePromise, avec annulation gérée par le framework (l'AbortSignalest fourni) — on n'a plus à câblerswitchMapà la main. - Migration : le contrat
ValidatorFn/AsyncValidatorFnReactive Forms reste supporté et n'est pas déprécié. Écrire la logique métier dans des fonctions pures testables (isStrongPassword(v): boolean) et n'adapter que la coquille (ValidatorFnaujourd'hui,validate()demain) rend la migration triviale. C'est l'argument décisif pour séparer règle et adaptateur.
En 2026, choisissez Reactive Forms pour le code de prod (stable, écosystème complet) et expérimentez Signal Forms sur un module isolé. Le risque n'est pas l'API du validator — il est dans les directives template, les
ControlValueAccessoret les libs tierces qui n'ont pas encore migré.
🤖 Validation dans une UI d'agent IA (streaming + tool-calls)
Cas réel de cette stack : un formulaire qui pilote un agent (prompt + paramètres) puis affiche la réponse en streaming. La validation a deux moments distincts — avant l'envoi (le formulaire) et pendant le stream (les tool_use que l'agent renvoie).
1. Valider le payload avant d'appeler l'agent
Un async validator peut servir de garde de coût : refuser un prompt trop long (donc trop cher en tokens) ou un modèle indisponible, avant même d'ouvrir le stream. On débouce et on court-circuite comme pour n'importe quel async.
import { AsyncValidatorFn } from '@angular/forms';
import { of, timer, switchMap, map, catchError } from 'rxjs';
// Garde de coût : estime les tokens et rejette si > budget.
export function tokenBudget(maxTokens: number, estimate: (s: string) => number): AsyncValidatorFn {
return (control) => {
const text: string = control.value ?? '';
if (!text) return of(null);
return timer(200).pipe(
switchMap(() => of(estimate(text))),
map((n) => (n > maxTokens ? { tokenBudget: { estimated: n, max: maxTokens } } : null)),
catchError(() => of(null)),
);
};
}2. Valider le stream de l'agent (discriminated union des steps)
Pendant le stream, l'agent émet des évènements (text_delta, tool_use, tool_result, error). On les modélise en union discriminée et on valide chaque tool_use à l'arrivée — un argument de tool invalide doit être rejeté avant exécution, sinon on déclenche un effet de bord sur des données non validées (le cœur de la sécurité agentique côté client).
type AgentStep =
| { kind: 'thinking' }
| { kind: 'text'; content: string }
| { kind: 'tool_use'; id: string; name: string; input: unknown; status: 'pending' | 'running' | 'done' | 'error'; error?: string }
| { kind: 'done' };
// Réutilise un schéma Zod par tool — la MÊME source de vérité que le serveur NestJS.
import { z } from 'zod';
const toolSchemas: Record<string, z.ZodSchema> = {
search_docs: z.object({ query: z.string().min(1), top_k: z.number().int().min(1).max(20) }),
send_email: z.object({ to: z.string().email(), subject: z.string().min(1), body: z.string() }),
};
function validateToolUse(name: string, input: unknown): { ok: true } | { ok: false; error: string } {
const schema = toolSchemas[name];
if (!schema) return { ok: false, error: `Outil inconnu: ${name}` };
const r = schema.safeParse(input);
return r.success ? { ok: true } : { ok: false, error: r.error.issues.map((i) => i.message).join(', ') };
}3. Le composant : signal store + Stop câblé à un AbortController
Sous zoneless, on accumule les steps dans un signal append-only et on coalesce les deltas de texte par requestAnimationFrame pour ne pas re-render à chaque token. Le bouton Stop annule côté client (AbortController) ET doit signaler au serveur d'arrêter la génération.
import { Component, signal, computed, inject, DestroyRef } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-agent-console',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<textarea [formControl]="prompt"></textarea>
@if (prompt.pending) { <small>Estimation du coût…</small> }
@if (prompt.errors?.['tokenBudget']; as e) {
<small class="error">Prompt trop long: {{ e.estimated }} tokens (max {{ e.max }}).</small>
}
<button (click)="run()" [disabled]="prompt.invalid || prompt.pending || streaming()">Envoyer</button>
<button (click)="stop()" [disabled]="!streaming()">Stop</button>
<ol class="timeline">
@for (step of steps(); track $index) {
@switch (step.kind) {
@case ('thinking') { <li class="muted">réflexion…</li> }
@case ('text') { <li>{{ step.content }}</li> }
@case ('tool_use') {
<li [class.error]="step.status === 'error'">
{{ step.name }} — {{ step.status }}
@if (step.error) { <span class="error"> ({{ step.error }})</span> }
</li>
}
@case ('done') { <li class="muted">terminé</li> }
}
}
</ol>
`,
})
export class AgentConsoleComponent {
private readonly destroyRef = inject(DestroyRef);
protected readonly prompt = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3)],
asyncValidators: [tokenBudget(8000, estimateTokens)],
updateOn: 'blur',
});
protected readonly steps = signal<AgentStep[]>([]);
protected readonly streaming = signal(false);
private controller: AbortController | null = null;
private pendingText = '';
private rafId: number | null = null;
async run(): Promise<void> {
if (this.prompt.invalid) return;
this.steps.set([]);
this.streaming.set(true);
this.controller = new AbortController();
try {
const res = await fetch('/api/agent/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: this.prompt.value }),
signal: this.controller.signal,
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE: chaque évènement séparé par "\n\n"
let idx: number;
while ((idx = buffer.indexOf('\n\n')) !== -1) {
const raw = buffer.slice(0, idx).replace(/^data: /, '');
buffer = buffer.slice(idx + 2);
this.handleEvent(JSON.parse(raw));
}
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
this.steps.update((s) => [...s, { kind: 'tool_use', id: 'err', name: 'stream', input: null, status: 'error', error: String(err) }]);
}
} finally {
this.flushText();
this.streaming.set(false);
this.controller = null;
}
}
private handleEvent(ev: { type: string; [k: string]: unknown }): void {
switch (ev.type) {
case 'text_delta':
this.pendingText += ev.text as string;
this.scheduleFlush(); // coalesce sous rAF, pas de set par token
break;
case 'tool_use': {
const check = validateToolUse(ev.name as string, ev.input);
this.steps.update((s) => [
...s,
check.ok
? { kind: 'tool_use', id: ev.id as string, name: ev.name as string, input: ev.input, status: 'running' }
: { kind: 'tool_use', id: ev.id as string, name: ev.name as string, input: ev.input, status: 'error', error: check.error },
]);
// Si invalide, on coupe le stream : ne JAMAIS exécuter un tool aux args non validés.
if (!check.ok) this.stop();
break;
}
case 'done':
this.steps.update((s) => [...s, { kind: 'done' }]);
break;
}
}
private scheduleFlush(): void {
if (this.rafId != null) return;
this.rafId = requestAnimationFrame(() => { this.flushText(); this.rafId = null; });
}
private flushText(): void {
if (!this.pendingText) return;
const chunk = this.pendingText;
this.pendingText = '';
this.steps.update((s) => {
const last = s[s.length - 1];
if (last?.kind === 'text') return [...s.slice(0, -1), { kind: 'text', content: last.content + chunk }];
return [...s, { kind: 'text', content: chunk }];
});
}
stop(): void {
this.controller?.abort(); // annule client
// Côté serveur NestJS, l'abort de la requête HTTP est observé via le 'close' de la réponse
// (req.on('close')) → on appelle stream.controller.abort() sur le SDK Anthropic pour stopper
// la génération et arrêter de facturer des tokens. L'annulation doit être bilatérale.
}
}
declare function estimateTokens(s: string): number;Les trois réflexes de senior dans ce code :
- Valider les
tool_useà l'arrivée avec le même schéma que le serveur (Zod partagé) — un argument de tool est une frontière de confiance, exactement comme un input de formulaire. - Annulation bilatérale :
AbortControllercôté Angular et propagation au SDK côté NestJS (req.on('close')→abort()sur le stream). Sans le second, on continue de payer des tokens pour un stream que personne n'écoute. - Coalescing rAF sous zoneless : un
signal.updatepar token ferait des centaines de re-renders/s. On bufferise et onflushune fois par frame.
Côté NestJS, ce flux se sert depuis un endpoint SSE (
@Sseoures.write('data: ...\n\n')), avec un client LLM injecté viaforRootAsync(jamaisnew Anthropic()dans un champ), une garde d'idempotence/coût à l'edge, et la boucle agentique tool-use côté serveur. Modèles phares de référence :claude-opus-4-8,claude-sonnet-4-6,claude-haiku-4-5, en mode streaming avec retries SDK.
🔄 Versions — Angular 16 → 20
| Version | Évolutions pertinentes |
|---|---|
| Angular 14 | Typed Forms : les validators reçoivent un AbstractControl<T> typé. |
| Angular 16 | takeUntilDestroyed() simplifie les async validators qui doivent se nettoyer. inject() utilisable directement dans les fonctions, ce qui rend les validator factories plus ergonomiques. |
| Angular 17 | Control flow @if dans les templates simplifie l'affichage des erreurs. |
| Angular 18 | form.events permet de réagir aux changements de validité de façon unifiée. |
| Angular 19 | Preview signal forms : les validators y deviendront des fonctions sur signaux, mais le contrat null / erreurs reste identique. |
| Angular 20 | Signal Forms (@angular/forms/signals, preview avancée) : schema(), validate(), validateAsync(), customError(), erreurs exposées en signaux. Reactive Forms non déprécié. |
L'API ValidatorFn / AsyncValidatorFn est l'un des contrats les plus stables d'Angular — du code écrit en 2018 fonctionne tel quel en 2026.
⚠️ Pitfalls — 6-10
- Async validator sans
updateOn: 'blur': 30 appels API pour 30 keystrokes. Toujours combiner les deux ou utiliser untimer(N)interne. - Pas de check de valeur vide au début du validator : on appelle l'API pour
"", qui retourne probablement déjà une erreur syncrequired. Court-circuiter :if (!control.value) return of(null). - Cross-field placé sur un enfant : il n'a pas accès à ses frères. Placer sur le group parent (deuxième argument de
fb.group). setErrorscumulatif :control.setErrors({ x: true })remplace les erreurs existantes. Si on veut ajouter, fairecontrol.setErrors({ ...control.errors, x: true }).- Validator qui retourne
falseau lieu denull:falseest considéré comme un objet d'erreur (vide certes, mais truthy enObject.keys). Toujoursnullpour "valide". - Async validator sans
switchMap: si une nouvelle valeur arrive avant la réponse de l'ancienne, la course peut donner un résultat obsolète.switchMapannule. - Validator avec side-effect (par exemple, qui modifie un autre contrôle via
setValue) : on viole le contrat "pure function" et on déclenche des recalculs imprévisibles. Pour synchroniser deux contrôles, utiliservalueChanges+setValue({ emitEvent: false }). Validators.requiredsur un contrôle de checkbox considéré comme valide même décoché :requiredtestevalue !== null && value !== undefined && value !== '', etfalsepasse. UtiliserValidators.requiredTruepour les checkboxes "I agree".- Validator paramétré non factory :
const minWords = (min, ctrl) => ...n'est pas unValidatorFn. Toujours la signature(min: T) => (ctrl) => ValidationErrors | null. - Validator sync lourd (regex catastrophique, parse JSON volumineux) qui tourne à chaque keystroke et bloque le main thread. Soit le rendre async et le throttle, soit déplacer la coûteuse logique hors du validator (vers un effect).
🧪 Testing
Les validators sont des fonctions pures — ils sont trivialement testables sans TestBed.
import { FormControl, FormGroup, ValidationErrors, Validators } from '@angular/forms';
import { minWords, strongPassword, fieldsMatch, dateRange } from './validators';
describe('minWords', () => {
it('returns null when the count meets the minimum', () => {
const result = minWords(3)(new FormControl('one two three'));
expect(result).toBeNull();
});
it('returns an error when below the minimum', () => {
const result = minWords(5)(new FormControl('hello world'));
expect(result).toEqual({ minWords: { actual: 2, required: 5 } });
});
it('handles empty string', () => {
const result = minWords(1)(new FormControl(''));
expect(result?.['minWords']?.actual).toBe(0);
});
});
describe('strongPassword', () => {
// Table-driven en Jasmine : un forEach autour de it() (Jasmine n'a pas it.each).
const cases: Array<[string, ValidationErrors | null]> = [
['Abc123!@', null],
['abc123!@', { noUppercase: true }],
['ABCDEF!@', { noDigit: true }],
['Abc12345', { noSymbol: true }],
];
cases.forEach(([input, expected]) => {
it(`input "${input}" -> ${JSON.stringify(expected)}`, () => {
expect(strongPassword(new FormControl(input))).toEqual(expected);
});
});
});
describe('fieldsMatch', () => {
it('attaches error on the group when fields differ', () => {
const group = new FormGroup({
pwd: new FormControl('abc'),
confirm: new FormControl('def'),
});
const result = fieldsMatch('pwd', 'confirm')(group);
expect(result).toEqual({ fieldsMatch: true });
});
it('returns null when fields match', () => {
const group = new FormGroup({
pwd: new FormControl('same'),
confirm: new FormControl('same'),
});
expect(fieldsMatch('pwd', 'confirm')(group)).toBeNull();
});
});Pour les async validators, on utilise fakeAsync + tick pour traverser les timer et debounceTime :
import { fakeAsync, tick, TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { FormControl } from '@angular/forms';
import { of } from 'rxjs';
import { uniqueUsername } from './validators.async';
describe('uniqueUsername', () => {
let httpStub: { get: jasmine.Spy };
beforeEach(() => {
httpStub = { get: jasmine.createSpy() };
TestBed.configureTestingModule({
providers: [{ provide: HttpClient, useValue: httpStub }],
});
});
it('returns null when API says available', fakeAsync(() => {
httpStub.get.and.returnValue(of({ available: true }));
const validator = TestBed.runInInjectionContext(() => uniqueUsername());
let result: any;
(validator(new FormControl('alice')) as any).subscribe((r: any) => (result = r));
tick(300);
expect(result).toBeNull();
}));
it('returns usernameTaken error when API says unavailable', fakeAsync(() => {
httpStub.get.and.returnValue(of({ available: false }));
const validator = TestBed.runInInjectionContext(() => uniqueUsername());
let result: any;
(validator(new FormControl('alice')) as any).subscribe((r: any) => (result = r));
tick(300);
expect(result).toEqual({ usernameTaken: true });
}));
});🎬 Cas d'usage concrets
Scénario 1 — Banque, validation IBAN/RIB avec algorithme mod-97
Une banque digitale doit valider à la saisie tous les IBAN entrés par les clients (virement, mandat, KYC). La validation officielle ISO 13616 utilise un algorithme mod-97 : le code pays + chiffres de contrôle doivent satisfaire BigInt(rearranged) % 97n === 1n. Au-delà du format, il faut aussi vérifier l'existence du BIC associé (validator async via API interne).
L'équipe écrit un validator ibanFormatValidator sync (format + mod-97), branché en validators, et un ibanReachabilityValidator async (appel API), branché en asyncValidators avec updateOn: 'blur' pour ne déclencher l'appel qu'à la sortie du champ. Le validator sync rejette d'abord les invalides triviaux, ce qui économise des appels API. Erreurs distinctes (ibanFormat, ibanChecksum, ibanUnreachable) pour des messages UI précis.
Test : la suite de validators sync est testée hors TestBed (entrées/sorties pures, ~50 cas). Le validator async est testé avec fakeAsync et un mock HTTP. Couverture > 95 %.
Scénario 2 — E-commerce B2B, validation SIRET
Un site e-commerce B2B vend du matériel professionnel. La création de compte exige un SIRET (14 chiffres, validé par l'algorithme de Luhn). L'équipe écrit un validator siretValidator qui (a) vérifie 14 chiffres exactement, (b) vérifie Luhn, (c) consulte ensuite l'API Insee (async, débouncée 300 ms) pour vérifier que l'entreprise existe et n'est pas radiée.
Le validator async retourne un Observable<ValidationErrors | null> qui renvoie soit null (OK), soit { siretLuhn: true }, soit { siretNotFound: true }, soit { siretClosed: true, closedAt: '2023-04-12' }. Le composant affiche un message contextuel selon la clé d'erreur. La date de fermeture est exposée comme payload dans l'erreur, pas juste un boolean — Reactive Forms autorise n'importe quel objet dans ValidationErrors.
Bénéfice : un seul validator centralise toute la logique SIRET ; les 4 formulaires qui en ont besoin (inscription, KYB, modification, ajout de facturation) le réutilisent en une ligne validators: [siretValidator()].
Scénario 3 — Cabinet juridique, validation email professionnel
Un cabinet d'avocats refuse les emails personnels (gmail, yahoo, hotmail, etc.) pour ses clients corporate — la politique impose un email d'entreprise. L'équipe écrit un validator professionalEmailValidator qui (a) vérifie le format email, (b) extrait le domaine, (c) le compare à une blacklist statique (~30 domaines grand public), et (d) optionnellement appelle l'API MX pour vérifier que le domaine résout un serveur mail.
Le validator sync (a, b, c) est suffisant pour 99 % des cas et donne un feedback immédiat. Le validator async (d) est appelé seulement au submit pour ne pas spammer le DNS. L'erreur nonProfessionalEmail est claire, et un placeholder ("ex: [email protected]") guide l'utilisateur.
Subtilité : l'équipe a choisi d'autoriser les domaines proton.me et tutanota.com (mailers chiffrés souvent utilisés par des contacts presse). La blacklist est externalisée dans la config tenant pour permettre des ajustements sans redéploiement.
🛠️ Exemple end-to-end
Use case : validator IBAN complet d'une banque, avec validation format (mod-97) + validation async (existence côté serveur). Composant qui consomme le validator, et test classe pure.
// iban.utils.ts
const COUNTRY_LENGTHS: Record<string, number> = {
FR: 27, DE: 22, IT: 27, ES: 24, BE: 16, CH: 21, LU: 20, PT: 25, NL: 18, GB: 22,
};
export function normalizeIban(input: string): string {
return input.replace(/\s+/g, '').toUpperCase();
}
export function hasValidLength(iban: string): boolean {
const country = iban.slice(0, 2);
return COUNTRY_LENGTHS[country] === iban.length;
}
export function passesMod97(iban: string): boolean {
// Mod-97 (ISO 13616) : déplace les 4 premiers chars à la fin, remplace les lettres par leurs valeurs (A=10, ...)
const rearranged = iban.slice(4) + iban.slice(0, 4);
const numeric = rearranged
.split('')
.map((ch) => (/[A-Z]/.test(ch) ? (ch.charCodeAt(0) - 55).toString() : ch))
.join('');
return BigInt(numeric) % 97n === 1n;
}// iban.validator.ts
import { AbstractControl, AsyncValidatorFn, ValidationErrors, ValidatorFn } from '@angular/forms';
import { Observable, map, of, switchMap, timer, catchError } from 'rxjs';
import { hasValidLength, normalizeIban, passesMod97 } from './iban.utils';
export const ibanFormatValidator: ValidatorFn = (control: AbstractControl) => {
const raw = (control.value ?? '').trim();
if (!raw) return null;
const iban = normalizeIban(raw);
if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(iban)) return { ibanFormat: true } as ValidationErrors;
if (!hasValidLength(iban)) return { ibanLength: true } as ValidationErrors;
if (!passesMod97(iban)) return { ibanChecksum: true } as ValidationErrors;
return null;
};
export function ibanReachabilityValidator(
check: (iban: string) => Observable<{ reachable: boolean; bic?: string }>,
): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const raw = (control.value ?? '').trim();
if (!raw || control.errors) return of(null); // ne pas appeler si déjà invalide sync
const iban = normalizeIban(raw);
return timer(300).pipe(
switchMap(() => check(iban)),
map((r) => (r.reachable ? null : ({ ibanUnreachable: true } as ValidationErrors))),
catchError(() => of({ ibanCheckFailed: true } as ValidationErrors)),
);
};
}// iban-check.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class IbanCheckService {
private readonly http = inject(HttpClient);
check(iban: string): Observable<{ reachable: boolean; bic?: string }> {
return this.http.post<{ reachable: boolean; bic?: string }>('/api/iban/check', { iban });
}
}// iban-field.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ibanFormatValidator, ibanReachabilityValidator } from './iban.validator';
import { IbanCheckService } from './iban-check.service';
@Component({
selector: 'app-iban-field',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule],
template: `
<label>
IBAN
<input [formControl]="iban" placeholder="FR76 …" autocomplete="off" />
</label>
@if (iban.pending) { <small>Vérification en cours…</small> }
@if (iban.touched && iban.errors?.['ibanFormat']) { <small class="error">Format IBAN invalide.</small> }
@if (iban.touched && iban.errors?.['ibanLength']) { <small class="error">Longueur IBAN incorrecte pour ce pays.</small> }
@if (iban.touched && iban.errors?.['ibanChecksum']) { <small class="error">Clé de contrôle IBAN invalide.</small> }
@if (iban.touched && iban.errors?.['ibanUnreachable']) { <small class="error">IBAN non reconnu par le réseau.</small> }
@if (iban.touched && iban.errors?.['ibanCheckFailed']) { <small class="error">Vérification indisponible, réessayez.</small> }
`,
})
export class IbanFieldComponent {
private readonly check = inject(IbanCheckService);
protected readonly iban = new FormControl('', {
nonNullable: true,
validators: [ibanFormatValidator],
asyncValidators: [ibanReachabilityValidator((v) => this.check.check(v))],
updateOn: 'blur',
});
}// iban.validator.spec.ts (test classe pure)
import { FormControl } from '@angular/forms';
import { ibanFormatValidator, ibanReachabilityValidator } from './iban.validator';
import { fakeAsync, tick } from '@angular/core/testing';
import { of } from 'rxjs';
it('accepts a valid French IBAN', () => {
const ctrl = new FormControl('FR1420041010050500013M02606');
expect(ibanFormatValidator(ctrl)).toBeNull();
});
it('rejects bad checksum', () => {
const ctrl = new FormControl('FR9999041010050500013M02606');
expect(ibanFormatValidator(ctrl)).toEqual({ ibanChecksum: true });
});
it('runs async reachability check', fakeAsync(() => {
const ctrl = new FormControl('FR1420041010050500013M02606');
const validator = ibanReachabilityValidator(() => of({ reachable: false }));
let result: any;
(validator(ctrl) as any).subscribe((r: any) => (result = r));
tick(300);
expect(result).toEqual({ ibanUnreachable: true });
}));Le validator combine sync (format + mod-97) et async (HTTP), produit des codes d'erreur distincts pour des messages UI précis, ne déclenche l'appel API que si le format est déjà valide, et reste testable en quelques lignes sans TestBed.
🔁 Quand utiliser / éviter
Écrire un validator custom quand :
- La règle est métier (mot de passe fort, IBAN valide, code SIRET, plage de dates).
- La règle dépend de plusieurs champs (cross-field).
- La règle nécessite un appel asynchrone (unicité, vérification serveur).
- On veut un message d'erreur riche (paramètres contextuels).
Éviter d'écrire un validator custom quand :
- Un validator built-in (
required,minLength,maxLength,email,pattern,min,max) suffit. - La règle est triviale et peut être exprimée par une regex via
Validators.pattern. - Le contrôle ne devrait pas exister tout court — préférer désactiver l'input qu'ajouter un validator qui rejette systématiquement.
Privilégier la délégation à un schéma quand :
- Le schéma est déjà défini côté back-end (NestJS + class-validator, ou tRPC + Zod).
- La cohérence front/back est critique (le back rejettera de toute façon, autant valider avant).
- L'équipe maintient un seul schéma plutôt que deux ensembles de validators.
🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Chaque exercice suppose le précédent terminé.
1. Cross-field « au moins un » (warm-up)
Objectif : un validator group qui exige qu'au moins un des champs phone/email soit renseigné, avec un payload listant les champs manquants.
Indice/Solution : atLeastOne(...keys: string[]): ValidatorFn. Lire chaque group.get(k)?.value, filtrer les vides, si filled.length === 0 retourner { atLeastOne: { fields: keys } }. Tester en pur (FormGroup, pas de TestBed). Piège à éviter : ne pas attacher l'erreur aux enfants, elle vit sur le group.
2. Async unicité production-grade
Objectif : transformer uniqueUsername() en validator de prod : debounce interne, court-circuit valeur vide/invalide, switchMap anti-course, cache LRU borné (pas un Map qui fuit), gestion d'erreur réseau qui ne bloque pas le submit (erreur réseau → null + warning séparé, pas { usernameTaken: true }).
Indice/Solution : encapsuler le cache dans un petit LRU (Map + suppression du plus ancien au-delà de N). catchError(() => of(null)) pour ne pas transformer une panne d'API en faux positif « pris ». Distinguer « indisponible » (réseau) de « pris » (métier) : la première ne doit pas empêcher la soumission, c'est le back qui tranchera.
3. Validator-as-directive réutilisable (template-driven interop)
Objectif : exposer strongPassword comme directive [appStrongPassword] utilisable dans un formulaire template-driven, via NG_VALIDATORS.
Indice/Solution : @Directive({ selector: '[appStrongPassword]', providers: [{ provide: NG_VALIDATORS, useExisting: StrongPasswordDirective, multi: true }] }) implémentant Validator.validate(control). Pour une factory paramétrée ([appMinWords]="5"), lire l'@Input(), recalculer le ValidatorFn dans ngOnChanges et appeler le registerOnValidatorChange callback pour re-valider.
4. Casser puis réparer : la boucle infinie de setErrors
Objectif : reproduire un freeze. Écrire un cross-field qui propage l'erreur sur l'enfant sans nettoyer et qui appelle updateValueAndValidity() dans le validator → boucle / ExpressionChangedAfterItHasBeenChecked. Puis le réparer.
Indice/Solution : la cause est un side-effect (setErrors + updateValueAndValidity) déclenché pendant la validation, qui re-déclenche la validation. Réparation : (a) ne jamais appeler updateValueAndValidity() depuis un validator ; (b) merger/retirer la clé proprement comme dans fieldsMatchPropagate ; (c) si propagation nécessaire, la faire via valueChanges + setErrors(..., { emitEvent: false }) hors du pipeline. Mesurer avec un console.count dans le validator avant/après.
5. Adapter Zod granulaire + i18n (staff)
Objectif : fromZodGranular qui mappe chaque issue sur une clé d'erreur distincte (too_small, invalid_string…) ET brancher l'ERROR_MESSAGES/ErrorMessageService pour traduire ces clés Zod, en réutilisant le même schéma que le DTO NestJS.
Indice/Solution : itérer result.error.issues, clé = issue.code (ou issue.path.join('.') pour un schéma objet), payload = { message, minimum, ... }. Ajouter too_small: ({ minimum }) => \Au moins ${minimum}.` dans la map. Exporter le schéma depuis un package partagé importé par le front et par le DTO (createZodDto` côté Nest). Vérifier que front et back rejettent identiquement le même input.
6. Garde de coût pour agent IA + annulation bilatérale (staff+)
Objectif : finaliser le AgentConsoleComponent : async validator tokenBudget qui bloque le submit, validation des tool_use à l'arrivée contre des schémas Zod partagés, Stop qui annule client (AbortController) et signale au serveur d'arrêter la génération.
Indice/Solution : le bouton submit lit prompt.invalid || prompt.pending || streaming(). À chaque tool_use reçu, safeParse l'input ; si KO → step status:'error' + this.stop() (ne jamais exécuter un tool aux args non validés). Côté NestJS, req.on('close') → stream.controller.abort() sur le SDK Anthropic. Test du chemin d'annulation : déclencher abort() au milieu du stream et vérifier qu'aucun token supplémentaire n'est consommé (mock du stream + spy sur abort).
7. Casser puis réparer : ReDoS + faux positif réseau (staff+, sécurité)
Objectif : exposer deux failles de prod sur des validators, les mesurer, les corriger. (a) Un validator sync passwordPolicy utilise une regex à backtracking exponentiel (/^(\w+)+$/ ou similaire) — mesurer le freeze sur une entrée pathologique, puis la rendre linéaire. (b) Un async d'unicité transforme une panne d'API (500/timeout) en { usernameTaken: true }, bloquant des inscriptions légitimes pendant un incident — corriger pour que l'erreur réseau renvoie null + un signal d'observabilité séparé.
Indice/Solution : (a) chronométrer avec performance.now() autour du validator sur "a".repeat(30) + "!" ; remplacer la regex imbriquée par des passes simples (/[A-Z]/, /[0-9]/, longueur) — coût linéaire, plus de backtracking. (b) catchError doit distinguer erreur métier (available: false → { usernameTaken: true }) d'erreur d'infra (HTTP ≠ 2xx → of(null) + metrics.increment('validator.error')). Test : mocker un HTTP 500 et asserter que le validator renvoie null (le back tranchera), pas un faux « pris ». Réflexe à retenir : une panne de validation ne doit jamais bloquer plus que ce que la règle bloque.
🎤 En entretien
Q : Pourquoi un cross-field validator se met-il sur le FormGroup et pas sur un FormControl ? Parce qu'un ValidatorFn ne reçoit que son AbstractControl : un control n'a aucune visibilité sur ses frères. Le group est le premier ancêtre commun qui peut lire group.get('a') et group.get('b'). L'erreur s'attache donc au group (form.errors), pas aux enfants — sauf propagation manuelle assumée.
Q : Un async validator appelle l'API à chaque keystroke. Quelles couches de défense, dans quel ordre d'impact ?
updateOn: 'blur'(ou'submit') — supprime la quasi-totalité des appels ; 2) court-circuit valeur vide/invalide (of(null)) ; 3)timer(N)interne +switchMappour amortir et annuler les courses ; 4) cache borné. Et rappeler que les sync gardent déjà les async : Angular ne lance l'API que si tous les sync passent.
Q : Quelle est la différence d'annulation entre switchMap dans le validator et le comportement natif d'Angular ? Angular unsubscribe l'Observable de l'async validator quand une nouvelle run de validation démarre sur le control (nouvelle valeur). Le switchMap interne, lui, annule les émissions à l'intérieur d'une même run (ex. annuler un HTTP si un timer re-déclenche). Les deux niveaux sont complémentaires ; compter sur un seul laisse passer des résultats obsolètes.
Q : Reactive Forms ou Signal Forms en 2026, et qu'est-ce que ça change pour un validator ? Reactive pour la prod (stable, écosystème, CVA/libs tierces matures) ; Signal Forms en preview sur du code isolé. Le contrat du validator (null/erreurs) est identique ; ce qui change est la coquille (validate(path, ctx => …) lisant value() vs (control) => …) et l'exposition des erreurs en signaux (zoneless-friendly, conditionnels first-class). D'où la règle : isoler la règle métier dans une fonction pure et ne migrer que l'adaptateur.
Q : Un validator front suffit-il pour garantir une règle métier ? Et un champ disabled est-il sûr ? Non aux deux. Un validator client est une garde UX uniquement — contournable via DevTools ou une requête directe ; le serveur revalide toujours (idéalement avec le même schéma partagé). Et control.disable() ne « protège » rien : le champ sort du contrat (status: 'DISABLED', errors: null, absent de group.value — il faut getRawValue()), donc un cross-field qui lit un champ désactivé via group.value lit undefined. Désactiver n'est pas sécuriser ; un champ grisé reste falsifiable côté réseau.
🔗 Liens
- Form validation guide —
angular.dev/guide/forms/form-validation ValidatorFnAPI —angular.dev/api/forms/ValidatorFnAsyncValidatorFnAPI —angular.dev/api/forms/AsyncValidatorFnValidatorsbuilt-in —angular.dev/api/forms/Validators- Cross-field validation — section "Cross-field validation" du guide officiel
- Zod —
zod.dev - class-validator —
github.com/typestack/class-validator
Récap final
Un validator custom est une fonction pure qui retourne null (valide) ou un objet ValidationErrors. Les sync sont des ValidatorFn, les async des AsyncValidatorFn retournant un Observable. On utilise des factories pour paramétrer (minWords(5)), on attache les validators cross-field au group parent, et on enrichit les ValidationErrors avec des paramètres contextuels pour composer des messages riches. Les async validators doivent systématiquement combiner updateOn: 'blur', un timer interne, un switchMap, et un court-circuit sur valeur vide — sans ça, l'API est spammée. L'affichage des erreurs se centralise dans un composant <app-form-errors> qui résout les clés via une map (ou un service i18n). Quand un schéma existe (Zod, class-validator), on adapte plutôt que de réécrire. Le testing est trivial : les validators sont des fonctions pures, on les teste sans TestBed ; pour les async, fakeAsync + tick + TestBed.runInInjectionContext suffisent.