Typed reactive forms in Angular, without the any
Strictly typed FormGroups, non-nullable controls, cross-field validation, and mapping form values to an API request.
3 min read
On this page
Forms are where types usually disappear
Back-office applications are full of long forms: customer data, addresses, payment details. Since
Angular 14, reactive forms are strictly typed, but many codebases still read values as any
through form.value.someField or form.get("someField"). This article shows a pattern that keeps
the types from the form definition all the way to the API call.
Define the form once
Use NonNullableFormBuilder so that reset() returns controls to their initial value instead of
null:
import { Component, inject } from "@angular/core";
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
@Component({
selector: "app-customer-form",
imports: [ReactiveFormsModule],
templateUrl: "./customer-form.html",
})
export class CustomerForm {
private readonly fb = inject(NonNullableFormBuilder);
readonly form = this.fb.group({
fullName: ["", [Validators.required, Validators.maxLength(100)]],
email: ["", [Validators.required, Validators.email]],
birthDate: ["", Validators.required],
address: this.fb.group({
street: ["", Validators.required],
city: ["", Validators.required],
postalCode: ["", [Validators.required, Validators.pattern(/^\d{5}$/)]],
}),
});
}TypeScript now infers the whole structure. this.form.controls.address.controls.city is a
FormControl<string>, and a typo in a control name is a compile error, not a runtime null.
Read values through getRawValue
form.value skips disabled controls, so its type marks every field as optional. When submitting,
getRawValue() is usually what you want: every field, fully typed.
submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const value = this.form.getRawValue();
// ^? { fullName: string; email: string; birthDate: string; address: { ... } }
this.api.createCustomer(toRequest(value)).subscribe();
}Map the form to the API, explicitly
The form shape and the API contract are rarely identical. A small mapping function keeps that difference in one place and lets the compiler check both sides:
type CustomerFormValue = ReturnType<CustomerForm["form"]["getRawValue"]>;
export type CreateCustomerRequest = {
name: string;
email: string;
birthDate: string; // ISO yyyy-mm-dd
address: { line1: string; city: string; postalCode: string };
};
function toRequest(value: CustomerFormValue): CreateCustomerRequest {
return {
name: value.fullName.trim(),
email: value.email.trim().toLowerCase(),
birthDate: value.birthDate,
address: {
line1: value.address.street.trim(),
city: value.address.city.trim(),
postalCode: value.address.postalCode,
},
};
}When the backend renames a field, the error shows up in toRequest, not in production.
Cross-field validation
Some rules involve more than one control, for example a date range. Put the validator on the group:
import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";
export const dateRange: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const from = group.get("from")?.value as string | undefined;
const to = group.get("to")?.value as string | undefined;
return from && to && from > to ? { dateRange: true } : null;
};
readonly period = this.fb.group(
{ from: ["", Validators.required], to: ["", Validators.required] },
{ validators: dateRange },
);ISO date strings (yyyy-mm-dd) compare correctly as plain strings, which keeps the validator free of
time-zone surprises.
Show errors consistently
Instead of repeating *ngIf chains for each field, write one small helper and use it everywhere:
showError(control: AbstractControl, error: string): boolean {
return control.hasError(error) && (control.touched || control.dirty);
}<label for="email">Email</label>
<input id="email" type="email" formControlName="email" />
@if (showError(form.controls.email, "email")) {
<p class="error">Enter a valid email address.</p>
}Pair the message with aria-invalid and aria-describedby on the input so screen readers announce
the error too.
Summary
- Build forms with
NonNullableFormBuilderso values are never unexpectedlynull. - Submit with
getRawValue()to get every field with full types. - Keep a typed
toRequestmapper between the form and the API contract. - Put multi-field rules on the group, and show errors with one shared helper.