DEV Community

Ali
Ali

Posted on • Originally published at aelm.dev on

Your FormGroup was returning any this whole time

Quick test on whatever Angular project you have open: hover this.form.value in a submit handler. If your forms predate v14 and nobody did the migration pass, there's a decent chance you're looking at any — which means every field rename for the past few years silently broke nothing at compile time and everything at runtime. Typed forms fixed this, but they came with design decisions that confuse people to this day, and "it compiles with red squiggles gone" isn't the same as "I used the types correctly".

Let inference do it (until you shouldn't)

form = this.fb.group({
  email: ['', [Validators.required, Validators.email]],
  age: [0],
});
// inferred: FormGroup<{ email: FormControl<string | null>;
// age: FormControl<number | null>; }>
Enter fullscreen mode Exit fullscreen mode

For component-local forms, inference from the initial values is all you need. The time to write the type explicitly is when the form's shape is a contract — shared between a parent and child component, or built dynamically. Then an interface of controls documents the shape and survives refactors:

interface ProfileForm {
  email: FormControl<string>;
  age: FormControl<number>;
}
form: FormGroup<ProfileForm>;
Enter fullscreen mode Exit fullscreen mode

The null that surprises everyone

Why is the inferred type string | null when the initial value is clearly a string? Because of a behavior you've relied on without naming it: form.reset(). By default, reset pushes null into every control — so null is an honest member of the type. The fix is nonNullable, and the key is understanding it changes the behavior, not just the type: a non-nullable control resets to its initial value instead of null. The type follows the semantics, not the other way around.

// the ergonomic version: every control in this builder is nonNullable
private fb = inject(NonNullableFormBuilder);

form = this.fb.group({
  email: ['', [Validators.required, Validators.email]], // FormControl<string>
  age: [0], // FormControl<number>
});
Enter fullscreen mode Exit fullscreen mode

NonNullableFormBuilder (or fb.nonNullable.group(...)) makes the safe pattern the short pattern, which in my experience is the only way safe patterns actually get adopted by a team.

Why .value is Partial — and when it's lying to you

Second surprise: form.value is Partial<...>, every field possibly undefined. Not TypeScript pedantry — it reflects a real runtime behavior: disabled controls are excluded from value. Disable the email field and value.email doesn't become empty, it ceases to exist. If your submit handler sends form.value and the API started receiving objects with missing keys after someone added a "disable when..." feature — that's this.

onSubmit() {
  const data = this.form.getRawValue();
  // full type, no Partial — disabled controls included
  this.api.save(data);
}
Enter fullscreen mode Exit fullscreen mode

getRawValue() includes everything, disabled or not, and its return type is the full, non-partial shape. My rule: templates and intermediate logic can read value; the submit boundary reads getRawValue(). The types then stop fighting you, because you're using each accessor for what it actually means.

Worth it?

The migration is genuinely boring — mostly adding nonNullable where reset-to-initial is what you wanted anyway, and chasing a few Partial complaints to their real cause. What you get back: renaming a form field becomes a compiler-guided refactor instead of a grep-and-pray, and a class of "undefined went to the API" bugs moves from production to the editor. For code that sits directly on the user-input boundary — the exact place where garbage enters systems — that's the cheapest insurance the framework sells. And if you're starting fresh: Signal Forms are coming for this whole API, I've written about them too — but typed reactive forms are what's stable in production today, and they'll be with us for years.

Top comments (0)