~8 min de lecture

Typed Forms : 4 pièges qui corrompent tes payloads et ton typage

TL;DR

form.value n'est pas la photo de ton formulaire : c'est la photo de ses contrôles actifs. Un champ disabled disparaît du payload, un reset() réinjecte des null que ton backend n'attend pas, et le type Partial que ce disabled impose te pousse à caster, ce qui masque les deux premiers bugs. Les réflexes qui désamorcent ces pièges : getRawValue() pour tout ce qui part vers une API, nonNullable par défaut, et form.controls.x plutôt que form.get('x').

Un vendredi, un utilisateur te remonte que son profil a perdu son rôle. Tu ouvres l'onglet réseau, tu rejoues le scénario : le PATCH /users/42 part avec { "email": "..." }. Pas de clé role. Pourtant le champ est bien dans le FormGroup, il est bien rempli à l'écran, et ton onSubmit envoie this.form.value comme il l'a toujours fait.

Le coupable : trois sprints plus tôt, quelqu'un a ajouté this.form.controls.role.disable() pour les utilisateurs non admin. Personne n'a vu que ça changeait le contenu du payload, parce que côté TypeScript, rien n'a bougé. Le code compile, les tests du composant passent, et le champ a disparu en silence.

Ce bug n'est pas un cas isolé. C'est le premier d'une famille de quatre bugs, tous liés à la même illusion : croire que le type de form.value décrit ce que ton backend va recevoir.

Tout ce qui suit est vérifié sur Angular 22.1 (compilateur et runtime). Les Typed Forms existent depuis Angular 14 et le comportement décrit est identique jusqu'à la v22 incluse. Le comportement runtime de disabled (exclusion de value) est encore plus ancien : il date des débuts de Reactive Forms.


Piège 1 : un champ disabled disparaît de form.value

C'est documenté, c'est voulu, et c'est quand même le piège qui fait le plus de dégâts en prod. Démonstration minimale :

import { FormControl, FormGroup } from '@angular/forms';

const form = new FormGroup({
  email: new FormControl('a@b.c', { nonNullable: true }),
  role: new FormControl('admin', { nonNullable: true }),
});

form.controls.role.disable();

console.log(form.value);
// { email: 'a@b.c' }  <- role a disparu

console.log(form.getRawValue());
// { email: 'a@b.c', role: 'admin' }  <- tout est là

value agrège uniquement les contrôles enabled. getRawValue() agrège tout, désactivé ou pas. Si ton submit envoie form.value, presque chaque disable() posé ailleurs dans le code modifie ton contrat d'API à distance.

Et ça se corse, parce que la règle a une exception qui rend le bug encore plus dur à reproduire :

form.disable(); // TOUT le groupe est désactivé

console.log(form.value);
// { email: 'a@b.c', role: 'admin' }  <- tout revient !

Un groupe partiellement désactivé filtre les clés désactivées. Un groupe entièrement désactivé retombe sur la valeur complète. Donc ton payload change de forme selon que le groupe est partiellement ou entièrement désactivé. Bonne chance pour déboguer ça à partir d'un rapport utilisateur.

Cerise sur le gâteau : un contrôle désactivé sort aussi du calcul de validité, et un groupe entièrement désactivé passe en statut DISABLED, ce qui donne form.valid === false alors qu'aucun validateur n'a échoué. Si ton bouton submit est conditionné à form.valid, il vient de se griser sans message d'erreur.

Le réflexe : tout ce qui part vers une API passe par getRawValue(). form.value sert à réagir aux changements côté UI, pas à construire un payload.


Piège 2 : tes contrôles sont string | null et tu ne l'as pas choisi

Deuxième illusion, côté compilateur cette fois :

const email = new FormControl('');
// Type inféré : FormControl<string | null>

email.value;
// string | null

Tu as initialisé avec une string, tu récupères un type nullable. Ce n'est pas un bug d'inférence : c'est la conséquence directe de reset(). Sans configuration, un reset() ne restaure pas la valeur initiale, il remet le contrôle à null :

const email = new FormControl('init');
email.setValue('changed');
email.reset();

console.log(email.value);
// null  <- pas 'init'

Angular reflète donc honnêtement le runtime : puisque null peut apparaître à l'exécution, il apparaît dans le type. La correction, c'est l'option nonNullable, qui change les deux comportements d'un coup :

const email = new FormControl('init', { nonNullable: true });
email.setValue('changed');
email.reset();

console.log(email.value);
// 'init'  <- retour à la valeur initiale
// et email.value est typé string, sans null

Sur un formulaire complet, écrire { nonNullable: true } sur chaque champ devient vite pénible. NonNullableFormBuilder applique l'option à tout ce qu'il construit :

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-profile-form',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ReactiveFormsModule],
  templateUrl: './profile-form.html',
})
export class ProfileForm {
  private readonly fb = inject(NonNullableFormBuilder);

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

Chaque contrôle déclaré en raccourci (valeur nue ou tuple [valeur, validators]) ressort en FormControl<string>, et reset() redevient prévisible. Si tu injectes déjà FormBuilder ailleurs, fb.nonNullable.group({ ... }) donne exactement le même résultat sans changer le token injecté.

Un piège dans le piège, quand même : le builder configure ce qu'il construit, pas ce que tu lui donnes déjà construit. Un new FormControl('') instancié à la main dans le group() le traverse tel quel, avec son type nullable et son reset() vers null. Le formulaire compile, et le bug réapparaît en silence. Donc dans un group() non nullable, tout se déclare en raccourci.

Le contrôle nullable, lui, se réserve aux cas où null signifie quelque chose dans ton domaine (par exemple "pas encore répondu" sur un champ optionnel). Là, garde-le nullable, mais assume ce null dans le type de ton payload au lieu de le subir.


Piège 3 : form.value est un Partial, et le cast qui "corrige" cache les deux premiers pièges

Même avec nonNullable partout, regarde le type de form.value :

// fb : le NonNullableFormBuilder injecté plus haut
const form = fb.group({
  email: '',
  role: 'member',
});

form.value;
// Partial<{ email: string; role: string }>

const email: string = form.value.email;
// Erreur TS2322 : string | undefined n'est pas assignable à string

Chaque clé est optionnelle. Ce n'est pas une facilité que s'accorde le compilateur : c'est le piège 1 encodé dans le type. Puisqu'un disable() peut retirer n'importe quelle clé au runtime, le compilateur refuse de te promettre sa présence. Le type dit la vérité, et c'est précisément pour ça qu'il est inconfortable.

Le vrai danger, c'est la "correction" qu'on voit dans 90% des codebases :

onSubmit(): void {
  const payload = this.form.value as UserPayload; // <- le mensonge
  this.api.updateUser(payload);
}

Ce as fait taire le compilateur, et avec lui la seule alarme qui te signalait les pièges 1 et 2. Le jour où un champ est désactivé, le payload incomplet part quand même, et TypeScript te couvre les yeux pendant que tu fonces dans le mur.

getRawValue() résout le problème de typage et le problème runtime en même temps :

onSubmit(): void {
  const payload = this.form.getRawValue();
  // Type : { email: string; role: string } - complet, sans undefined, sans cast
  this.api.updateUser(payload);
}

Le type plein n'est pas un cadeau du compilateur, c'est la conséquence logique du runtime : getRawValue() inclut toutes les clés du groupe, disabled compris, donc aucune n'est optionnelle. Seule limite, cohérente elle aussi : un contrôle que tu as toi-même déclaré optionnel dans le type générique du FormGroup (role?: FormControl<string>), retirable via removeControl(), reste optionnel dans le type et absent du résultat s'il n'existe pas.


Piège 4 : form.get('email') te fait perdre ce que les Typed Forms t'ont donné

Dernier piège, hérité des habitudes pré-v14. Depuis les Typed Forms, get() est moins mauvais qu'avant : avec une clé littérale, il infère le type de la valeur, y compris sur un chemin imbriqué comme form.get('address.city'). Mais il garde deux défauts structurels :

const ctrl = form.get('email');
// AbstractControl<string, string, any> | null

ctrl.setValue('a@b.c');
// Erreur : ctrl est possiblement null

Premier défaut : le retour est toujours nullable, parce que la clé pourrait ne pas exister. Tu te retrouves à écrire des ?. ou des if (ctrl) pour un contrôle dont tu sais qu'il existe, puisque tu l'as déclaré dix lignes plus haut. Deuxième défaut : tu récupères un AbstractControl, pas ton FormControl ni ton FormGroup concrets, donc adieu controls et les APIs spécifiques.

Et dès que la clé n'est plus un littéral, le typage s'effondre complètement :

const key: string = 'email';
const ctrl = form.get(key);
// AbstractControl<any, any, any> | null  <- retour à la case any

L'alternative est déjà sous tes doigts :

form.controls.email;
// FormControl<string> - jamais null, type concret, autocomplete

controls est typé à partir de la déclaration du groupe : pas de null fantôme, pas de any, et une faute de frappe dans le nom de la clé devient une erreur de compilation au lieu d'un null au runtime. Pour les clés réellement dynamiques (une liste de préférences, des champs générés), le bon outil n'est ni get() ni un cast, c'est FormRecord<FormControl<boolean>> : un FormGroup à clés ouvertes, dont tu ajoutes et retires les contrôles au runtime avec addControl() / removeControl(), tous du même type. Conçu exactement pour ça.


Before / after : le submit qui ment vs le submit honnête

Le composant qu'on croise partout, avec les quatre pièges empilés :

import { ChangeDetectionStrategy, Component, OnInit, inject, input } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { UserApi, UserPayload } from './user-api';

@Component({
  selector: 'app-profile-form',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ReactiveFormsModule],
  templateUrl: './profile-form.html',
})
export class ProfileForm implements OnInit {
  private readonly fb = inject(FormBuilder);
  private readonly api = inject(UserApi);

  readonly isAdmin = input.required<boolean>();

  protected readonly form = this.fb.group({
    email: [''],           // string | null sans le savoir
    displayName: [''],
    role: ['member'],
  });

  ngOnInit(): void {
    if (!this.isAdmin()) {
      this.form.get('role')?.disable(); // get() + null check inutile
    }
  }

  onSubmit(): void {
    const payload = this.form.value as UserPayload; // cast qui masque tout
    this.api.updateUser(payload);                   // role absent si disabled
  }
}

La version qui dit la vérité au compilateur et au backend :

import { ChangeDetectionStrategy, Component, OnInit, inject, input } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms';
import { UserApi } from './user-api';

@Component({
  selector: 'app-profile-form',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ReactiveFormsModule],
  templateUrl: './profile-form.html',
})
export class ProfileForm implements OnInit {
  private readonly fb = inject(NonNullableFormBuilder);
  private readonly api = inject(UserApi);

  readonly isAdmin = input.required<boolean>();

  protected readonly form = this.fb.group({
    email: [''],           // FormControl<string>, sans rien demander
    displayName: [''],
    role: ['member'],
  });

  ngOnInit(): void {
    if (!this.isAdmin()) {
      this.form.controls.role.disable(); // typé, jamais null
    }
  }

  onSubmit(): void {
    const payload = this.form.getRawValue();
    // { email: string; displayName: string; role: string }
    // complet même avec role disabled, sans un seul cast
    this.api.updateUser(payload);
  }
}

Zéro as, zéro ?., et le payload contient role que le champ soit désactivé ou non. Le diff tient en cinq lignes ; la liste des bugs qu'il ferme, non.


Et les Signal Forms dans tout ça ?

Depuis Angular 22, les Signal Forms sont dans l'API publique et règlent une partie de ces problèmes à la racine : le formulaire est dérivé d'un modèle typé, donc la question "quel est le type de la valeur" ne se pose plus dans les mêmes termes. Et si ton sujet du moment est plutôt le composant de formulaire custom, FormValueControl propose une alternative au ControlValueAccessor verbeux : le guide dédié couvre la migration.

Mais soyons honnêtes : tes formulaires en prod sont en Reactive Forms, et ils vont y rester un moment. Une migration de formulaires est un chantier qu'on ne lance pas pour le plaisir, et les Typed Forms restent une API pleinement supportée. Ces quatre pièges vont donc te concerner encore longtemps, migration prévue ou pas.


Récap actionnable

À vérifier sur chaque formulaire qui touche une API, et à ajouter à ta checklist de review :

  1. Le submit envoie getRawValue(), jamais form.value. value est une vue UI (contrôles actifs seulement), pas un payload.
  2. Aucun as sur une valeur de formulaire. Un cast sur form.value désactive la seule alarme qui signale les clés manquantes.
  3. nonNullable par défaut (NonNullableFormBuilder ou fb.nonNullable). Un contrôle nullable, c'est un choix de domaine explicite, pas un défaut subi.
  4. form.controls.x pour les clés connues, FormRecord pour les clés dynamiques. get() se garde pour les chemins imbriqués littéraux type 'address.city', en assumant le null ; sur une clé variable, il ne type plus rien.
  5. Un disable() quelque part = un test sur le payload du submit. C'est le seul filet qui détecte une clé disparue, puisque le compilateur ne la verra jamais.

En vrai, form.value ne ment pas : son type Partial t'avait prévenu dès le premier jour. C'est le cast que tu as posé dessus qui ment.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.