Les formulaires financiers sont une catégorie spécifique de problème d'interface que la plupart des tutoriels Angular ne vous préparent pas à affronter. Un tutoriel typique vous montre un formulaire de contact avec un champ nom et un champ email. Une vraie demande de prêt a des sections conditionnelles qui apparaissent selon le type d'emploi, des règles de validation inter-champs qui référencent plusieurs entrées simultanément, des validateurs asynchrones qui appellent des services back-end pour vérifier l'éligibilité en temps réel, et des tableaux de champs dynamiques où l'utilisateur peut ajouter ou supprimer des co-emprunteurs ou des sources de revenus.
Au fil des années, du portail d'activation d'assurance que j'ai construit chez BNP Paribas Cardif à la plateforme numérique de demande de prêt sur laquelle j'ai travaillé chez BMO, j'ai développé un ensemble de patterns pour les Angular Reactive Forms que j'utilise de manière récurrente. Cet article les documente de la manière dont j'aurais aimé que quelqu'un les documente pour moi. Les exemples de code sont illustratifs, écrits pour cet article plutôt que tirés du codebase d'un employeur.
La fondation : les Reactive Forms typés
Avant tout, si vous êtes sur Angular 14 ou supérieur et que vous n'utilisez pas les reactive forms strictement typés, vous laissez une quantité significative de sécurité sur la table. Dans les applications financières où une valeur incorrecte peut avoir des conséquences monétaires réelles, la sécurité de typage dans vos formulaires n'est pas optionnelle. C'est une exigence.
interface LoanApplicationForm {
applicant: FormGroup<{
firstName: FormControl<string>;
lastName: FormControl<string>;
dateOfBirth: FormControl<string | null>;
annualIncome: FormControl<number | null>;
employmentType: FormControl<'employed' | 'self-employed' | 'retired' | null>;
}>;
loanDetails: FormGroup<{
requestedAmount: FormControl<number | null>;
termMonths: FormControl<number | null>;
purpose: FormControl<string | null>;
}>;
coApplicants: FormArray<FormGroup<CoApplicantForm>>;
}
const form = new FormGroup<LoanApplicationForm>({
applicant: new FormGroup({
firstName: new FormControl<string>('', { nonNullable: true }),
lastName: new FormControl<string>('', { nonNullable: true }),
dateOfBirth: new FormControl<string | null>(null),
annualIncome: new FormControl<number | null>(null),
employmentType: new FormControl<'employed' | 'self-employed' | 'retired' | null>(null),
}),
loanDetails: new FormGroup({
requestedAmount: new FormControl<number | null>(null),
termMonths: new FormControl<number | null>(null),
purpose: new FormControl<string | null>(null),
}),
coApplicants: new FormArray<FormGroup<CoApplicantForm>>([]),
});L'option nonNullable sur FormControl est particulièrement importante pour les champs qui doivent toujours avoir une valeur. Cela signifie qu'appeler reset() sur le contrôle le ramène à sa valeur initiale plutôt qu'à null, ce qui prévient toute une classe d'erreurs d'exécution dans les workflows financiers où un champ de montant null pourrait se propager dans la logique de calcul.
Formulaires multi-étapes : isolation d'état vs formulaire unifié
Il y a deux approches principales pour les formulaires multi-étapes en Angular. Vous pouvez maintenir un grand FormGroup qui représente toute la demande, ou maintenir des formulaires séparés par étape et composer l'objet de soumission final à la fin. Chaque approche a de vrais compromis.
Je préfère généralement l'approche FormGroup unifié pour les applications financières pour une raison clé : la validation inter-étapes. Les demandes de prêt ont fréquemment des règles de validation qui couvrent plusieurs étapes. Le montant de prêt demandé à l'étape deux est validé par rapport au revenu entré à l'étape un. Si vos formulaires sont isolés par étape, implémenter cela proprement est difficile. Si vous avez un seul formulaire réactif, le validateur inter-étapes est juste un validateur inter-champs standard.
L'approche unifiée nécessite de la discipline avec votre service de formulaire. J'isole l'instance de formulaire dans un service injectable qui persiste entre les composants d'étapes, j'expose des getters typés pour chaque section, et je m'assure que les composants d'étape ne détiennent aucun état de formulaire eux-mêmes. Le composant ne fait que rendre et gérer l'interaction utilisateur. L'état du formulaire vit entièrement dans le service.
Validation dynamique basée sur l'entrée utilisateur
C'est là que les formulaires financiers deviennent véritablement complexes. Les règles de validation pour un travailleur autonome sont complètement différentes de celles pour un salarié. Les retraités ont besoin de champs de documentation de pension qui sont invisibles et non pertinents pour les autres types d'emploi. L'approche naïve est d'afficher et masquer les champs et aussi d'ajouter et retirer les validateurs manuellement. Cela mène à des bugs subtils où les validateurs restent actifs sur des champs masqués.
Mon pattern préféré est d'appeler setValidators sur le groupe visible, clearValidators sur les groupes masqués, et de toujours terminer par updateValueAndValidity, combiné avec le mécanisme disable d'Angular :
private syncValidatorsToEmploymentType(employmentType: string | null): void {
const selfEmployedGroup = this.form.get('applicant.selfEmployedDetails');
const pensionGroup = this.form.get('applicant.pensionDetails');
if (employmentType === 'self-employed') {
selfEmployedGroup?.enable();
selfEmployedGroup?.setValidators(selfEmployedValidators);
pensionGroup?.disable();
pensionGroup?.clearValidators();
} else if (employmentType === 'retired') {
pensionGroup?.enable();
pensionGroup?.setValidators(pensionValidators);
selfEmployedGroup?.disable();
selfEmployedGroup?.clearValidators();
} else {
selfEmployedGroup?.disable();
selfEmployedGroup?.clearValidators();
pensionGroup?.disable();
pensionGroup?.clearValidators();
}
selfEmployedGroup?.updateValueAndValidity();
pensionGroup?.updateValueAndValidity();
}
// Subscribe to employment type changes in the form service constructor
this.form.get('applicant.employmentType')!.valueChanges
.pipe(distinctUntilChanged(), takeUntilDestroyed())
.subscribe(value => this.syncValidatorsToEmploymentType(value));L'appel disable() est critique. Les contrôles de formulaire désactivés sont exclus de form.value, ce qui signifie que les champs non pertinents sont automatiquement retirés du payload sans nettoyage manuel. Si vous devez lire toutes les valeurs quel que soit l'état désactivé, par exemple, pour repeupler un brouillon, utilisez getRawValue(), qui inclut les contrôles désactivés. Dans la logique de soumission, utilisez toujours form.value pour que les sections désactivées n'atteignent jamais le serveur.
Validateurs inter-champs
Un validateur inter-champs est un validateur attaché à un FormGroup plutôt qu'à un FormControl individuel. Il reçoit le groupe entier comme argument et peut comparer n'importe quelles valeurs en son sein. Pour les applications financières, c'est essentiel.
// Illustrative affordability check. The 0.44 ceiling is loosely inspired by CMHC's 44% TDS
// limit for insured mortgages. It is not a TDS calculation: real TDS is housing costs plus
// every other debt payment over gross income, and it applies to mortgage insurance, not to a
// generic consumer loan. Treat the number as a product rule you configure.
export function loanAffordabilityValidator(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const annualIncome = group.get('annualIncome')?.value as number | null;
const requestedAmount = group.get('requestedAmount')?.value as number | null;
const termMonths = group.get('termMonths')?.value as number | null;
if (!annualIncome || !requestedAmount || !termMonths) {
return null; // Let individual field validators handle null cases
}
// Straight-line split of the principal, no interest. A real payment runs an amortization
// formula with the rate, which is the back end's job here.
const estimatedMonthlyPayment = requestedAmount / termMonths;
const monthlyIncome = annualIncome / 12;
const paymentToIncomeRatio = estimatedMonthlyPayment / monthlyIncome;
if (paymentToIncomeRatio > 0.44) {
return {
affordability: {
ratio: paymentToIncomeRatio,
maxAllowed: 0.44,
message: 'Monthly payment exceeds the configured payment-to-income limit',
},
};
}
return null;
};
}
// Applied to the parent group, not an individual control
const loanApplicationGroup = new FormGroup(
{ ...controls },
{ validators: [loanAffordabilityValidator()] }
);Afficher les erreurs de validation inter-champs nécessite un pattern de template légèrement différent. L'erreur vit sur le groupe, pas sur un contrôle individuel, donc on y accède avec form.get('groupName')?.errors?.['affordability'] plutôt que les erreurs du contrôle individuel.
Cycle de vie de l'état du formulaire
Validateurs asynchrones : vérifications d'éligibilité back-end
Certaines règles de validation ne peuvent pas être calculées côté client. Ce NAS est-il valide et pas déjà au dossier ? Cette adresse de propriété est-elle dans une région que le prêteur dessert ? Le demandeur satisfait-il une règle d'admissibilité que seul le back-end connaît ? Cela nécessite des validateurs asynchrones qui appellent des services back-end.
Les validateurs asynchrones introduisent de la latence, et vous devez gérer cette latence avec soin pour éviter une expérience utilisateur frustrante. Mon pattern inclut toujours le debouncing, un indicateur d'état pending, et la gestion d'erreur quand le service lui-même échoue :
export function sinValidatorFactory(eligibilityService: EligibilityService): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const value = control.value as string;
if (!value || value.length !== 9) {
return of(null);
}
return timer(400).pipe(
switchMap(() =>
eligibilityService.validateSIN(value).pipe(
map(result => {
if (!result.valid) {
return { sinInvalid: { message: result.message } };
}
if (result.duplicate) {
return { sinDuplicate: true };
}
return null;
}),
catchError(() => of({ sinServiceUnavailable: true }))
)
)
);
};
}
// In the template, check the pending state
// <span *ngIf="sinControl.pending">Verifying...</span>
// <span *ngIf="sinControl.errors?.['sinServiceUnavailable']">Verification unavailable, please continue</span>Le timer(400) avant l'appel API agit comme un debounce. Sans cela, chaque frappe de touche déclencherait une requête back-end. Le catchError qui retourne of({ sinServiceUnavailable: true }) est important : une panne de service devient une erreur précise que le template peut expliquer. Gardez en tête que toute erreur retournée par un validateur asynchrone rend le contrôle invalide. Si une panne ne doit pas empêcher l'utilisateur de compléter le formulaire, la logique de soumission doit traiter sinServiceUnavailable comme un avertissement et non comme un blocage, et la vérification définitive se fait côté serveur au moment de la soumission.
Patterns FormArray pour les champs dynamiques
Les demandes de prêt permettent souvent aux utilisateurs d'ajouter plusieurs sources de revenus, plusieurs propriétés ou plusieurs co-demandeurs. FormArray est le mécanisme Angular pour cela, mais gérer des FormArrays typés avec des groupes imbriqués complexes nécessite des patterns soigneux.
interface CoApplicantForm {
firstName: FormControl<string>;
lastName: FormControl<string>;
annualIncome: FormControl<number | null>;
relationship: FormControl<string | null>;
}
@Injectable({ providedIn: 'root' })
export class LoanFormService {
// The unified LoanApplicationForm from the first sample, built once and kept here
readonly form = buildLoanApplicationForm();
// The array is a branch of the unified form, not a second form standing next to it. That is
// what keeps a cross-step validator able to read co-applicant income alongside the applicant's.
get coApplicants(): FormArray<FormGroup<CoApplicantForm>> {
return this.form.controls.coApplicants;
}
addCoApplicant(): void {
if (this.coApplicants.length >= 3) return; // Product rule, not a regulation
this.coApplicants.push(
new FormGroup<CoApplicantForm>({
firstName: new FormControl<string>('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(2)],
}),
lastName: new FormControl<string>('', {
nonNullable: true,
validators: [Validators.required],
}),
annualIncome: new FormControl<number | null>(null, [
Validators.required,
Validators.min(0),
]),
relationship: new FormControl<string | null>(null, [Validators.required]),
})
);
}
removeCoApplicant(index: number): void {
this.coApplicants.removeAt(index);
}
get coApplicantGroups(): FormGroup<CoApplicantForm>[] {
return this.coApplicants.controls;
}
}Suivi de l'état dirty et sauvegarde en brouillon
Les formulaires longs ont besoin d'une fonctionnalité de sauvegarde en brouillon. Les utilisateurs ne devraient pas perdre leur progression s'ils ferment l'onglet du navigateur. Mon pattern est de souscrire au flux valueChanges du formulaire et de sauvegarder automatiquement vers un endpoint de brouillon back-end, régulé avec auditTime pour enregistrer le dernier état à intervalle régulier, combiné avec un guard de changements non sauvegardés qui empêche la navigation s'il y a un delta non sauvegardé significatif.
L'insight clé ici est que form.dirty n'est pas un indicateur fiable de changement significatif. Angular marque un formulaire dirty après la première interaction utilisateur, même si l'utilisateur annule immédiatement la valeur. Pour les formulaires financiers, je suis explicitement la dernière valeur sauvegardée et la compare à la valeur actuelle du formulaire en utilisant une vérification d'égalité profonde avant d'afficher l'avertissement de changements non sauvegardés. Cela prévient les faux positifs qui frustreraient les utilisateurs qui ont accidentellement touché un champ puis l'ont corrigé.
Soumission et gestion d'erreurs
La soumission dans les formulaires financiers n'est pas du fire-and-forget. L'utilisateur a besoin de savoir exactement ce qui s'est passé, et le formulaire doit gérer les échecs partiels gracieusement. Mon pattern de soumission implique toujours : marquer tous les contrôles comme touched pour faire remonter toute erreur de validation cachée, effectuer une passe finale de validation synchrone, soumettre avec un état de chargement, gérer les erreurs au niveau des champs depuis la réponse serveur en les mappant vers des instances FormControl spécifiques, et fournir un chemin de récupération clair pour les défaillances de service.
async submitApplication(): Promise<void> {
this.markAllControlsTouched(this.form);
if (this.form.invalid || this.form.pending) {
this.scrollToFirstError();
return;
}
this.isSubmitting = true;
try {
// .value on a typed FormGroup is a Partial of the form's shape, so it will not satisfy a
// required DTO on its own. The submit boundary is where you assert the shape you just
// validated for. Disabled sections stay stripped out, which is the point of using .value here.
const payload = this.form.value as LoanApplicationPayload;
const result = await firstValueFrom(
this.loanService.submitApplication(payload)
);
this.router.navigate(['/application', result.applicationId, 'confirmation']);
} catch (error: unknown) {
if (isFieldValidationError(error)) {
this.applyServerErrors(error.fieldErrors);
} else {
this.submissionError = 'Service unavailable. Your draft has been saved.';
}
} finally {
this.isSubmitting = false;
}
}
private applyServerErrors(fieldErrors: Record<string, string>): void {
Object.entries(fieldErrors).forEach(([path, message]) => {
const control = this.form.get(path);
control?.setErrors({ serverError: message });
control?.markAsTouched();
});
this.scrollToFirstError();
}Leçons venues de la production
- Ne faites jamais confiance au seul état valid du formulaire pour la soumission. Appelez d'abord markAllAsTouched() pour faire remonter les erreurs cachées, et vérifiez le statut PENDING pour ne jamais soumettre pendant qu'un validateur asynchrone tourne encore.
- Les contrôles désactivés sont exclus de form.value mais pas de form.getRawValue(). Choisissez l'un de manière cohérente et documentez-le.
- Les validateurs inter-champs sur de grands FormGroups tournent à chaque émission de valueChanges de tout contrôle enfant. Pour les validateurs coûteux, passez le groupe en updateOn: 'blur' ou déplacez la vérification dans un validateur asynchrone avec debounce.
- Les messages d'erreur de formulaire doivent être accessibles. Utilisez aria-describedby pour associer les messages d'erreur à leurs champs, pas juste le placement visuel.
- Testez vos validateurs asynchrones avec un service mock qui échoue intentionnellement. Tester le chemin nominal ne suffit pas pour les validateurs de formulaires financiers.
- L'expiration de session pendant un long formulaire est un désastre UX dans le secteur bancaire. Implémentez un heartbeat de session et un compte à rebours visible avec un prompt de sauvegarde en brouillon avant l'expiration de la session.
Les formulaires financiers sont véritablement l'un des problèmes d'interface les plus challengeants du développement web. Ils combinent une haute complexité de données, des exigences de validation strictes, des mandats d'accessibilité, des contraintes réglementaires et des enjeux élevés en cas d'erreur. Les Angular Reactive Forms, bien utilisés, sont à la hauteur du défi. Les patterns que j'ai décrits ici ne sont pas académiques. Ils viennent de formulaires en production, pas de tutoriels.
Références
- Angular Reactive Forms guide. https://angular.dev/guide/forms/reactive-forms
- Angular typed forms guide. https://angular.dev/guide/forms/typed-forms
- AbstractControl API référence. https://angular.dev/api/forms/AbstractControl
- Angular form validation. https://angular.dev/guide/forms/form-validation
- W3C WAI Forms Tutorial. https://www.w3.org/WAI/tutorials/forms/