Since Angular 14, reactive forms are strictly typed — but many codebases still carry UntypedFormGroup from the automatic migration, and teams that do adopt types are surprised that form.value.email is string | undefined, that reset() sets fields to null, and that disabled controls vanish from value.
Each surprise is a deliberate part of the type model, and each has a precise fix. This page walks through typing a real form — nullable versus non-nullable controls, value versus getRawValue(), dynamic keys with FormRecord, and typed validators — as groundwork for the adapter patterns in Angular reactive forms adapters.
Context and prerequisites
The type rules Angular applies:
new FormControl("x")has typeFormControl<string | null>, becausereset()with no argument sets the value tonull.new FormControl("x", { nonNullable: true })has typeFormControl<string>, andreset()restores"x"— the initial value, notnull.FormGroup.valueisPartial<…>: every key is optional, because disabled controls are omitted fromvalue.FormGroup.getRawValue()includes disabled controls and is fully typed withoutPartial.FormBuilder.nonNullable(orNonNullableFormBuilder) builds every control non-nullable, which is what most forms actually want.
Once you know these four rules, the types stop being noise and start catching real bugs — a submit handler that forgets a disabled field, a reset that blanks required values.
The core pattern: a non-nullable builder, explicit raw values, typed validators
import { Component, inject } from "@angular/core";
import {
AbstractControl, FormRecord, NonNullableFormBuilder, ReactiveFormsModule,
ValidationErrors, ValidatorFn, Validators, FormControl,
} from "@angular/forms";
// A typed validator: declares the value type it accepts and the error shape it returns.
export function minAgeValidator(min: number): ValidatorFn {
return (control: AbstractControl<number | null>): ValidationErrors | null => {
const v = control.value;
if (v === null) return null; // "required" is a separate validator
return v < min ? { minAge: { required: min, actual: v } } : null;
};
}
@Component({
selector: "app-account-form",
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: "./account-form.html",
})
export class AccountFormComponent {
private fb = inject(NonNullableFormBuilder); // every control non-nullable
form = this.fb.group({
accountId: this.fb.control({ value: "ACC-812", disabled: true }), // shown, not editable
name: this.fb.control("", [Validators.required, Validators.minLength(2)]),
email: this.fb.control("", [Validators.required, Validators.email]),
// Genuinely optional numeric field: opt back INTO null explicitly.
age: new FormControl<number | null>(null, [minAgeValidator(16)]),
// Dynamic keys (feature flags, custom attributes): FormRecord keeps value typing.
preferences: new FormRecord<FormControl<boolean>>({}),
});
addPreference(key: string) {
this.form.controls.preferences.addControl(key, this.fb.control(false));
}
submit() {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
// getRawValue: includes disabled accountId and is fully typed (no Partial).
const payload = this.form.getRawValue();
// ^? { accountId: string; name: string; email: string; age: number | null; preferences: Record<string, boolean> }
this.save(payload);
}
resetToLoaded() {
this.form.reset(); // non-nullable controls return to their INITIAL values, not null
}
private save(_p: ReturnType<typeof this.form.getRawValue>) { /* … */ }
}
Step-by-step walkthrough
- Inject
NonNullableFormBuilder. Most fields should reset to their initial value; making non-nullability the default removes| nullfrom nearly every type. - Opt specific fields into null. A truly optional number or date uses
new FormControl<T | null>(null), so “empty” is representable and the type says so. - Use
getRawValue()for payloads. It includes disabled controls and has noPartial;valueis for cases where omitting disabled fields is intended, which ties into skipping validation for disabled and hidden fields. - Use
FormRecordfor dynamic keys. UnlikeFormGroup, it allows adding and removing controls by arbitrary string keys while keeping each control’s type. - Type validators by their input.
AbstractControl<number | null>in the signature documents what the validator expects and catches misuse at the call site. - Access controls through
form.controls.form.controls.emailis typed;form.get("email")returnsAbstractControl | nulland loses the type.
Why value is Partial, and why that is useful
It feels like a type-system annoyance that form.value.email may be undefined when every control exists. The reason is runtime behaviour: Angular excludes disabled controls from a group’s value, and whether a control is disabled is a runtime fact the compiler cannot know. The Partial type is therefore telling the truth. The practical consequence is healthy: code that builds a request body from value must handle the absent case, which is exactly the bug — a disabled field silently missing from the payload — that the type exists to prevent. Choose deliberately between value (participating fields only) and getRawValue() (everything).
Typing the template side
Types in the component class do not automatically reach templates in older setups. Enable strictTemplates in the Angular compiler options so bindings such as [formControl]="form.controls.email" and expressions reading form.controls.age.value are type-checked. With it on, a template that treats a nullable age as a number, or references a control that was renamed, fails the build instead of rendering null at runtime. Pair it with a small typed helper for error messages — errorText(form.controls.email) returning a string or null — so templates never index into the untyped errors record directly.
Failure modes and edge cases
1. reset() blanks required fields
With nullable controls, reset() sets values to null, making required fields invalid and, in templates, displaying empty. Non-nullable controls reset to their initial value. If “initial” should be the last saved value, rebuild or reset(savedValues) — the same rebasing logic as resetting the dirty baseline after a successful save.
2. Numbers from text inputs
<input type="number" formControlName="age"> gives a number through Angular’s number value accessor (and null when empty). A plain text input gives a string even if the control is typed number; the type is a promise the template must keep. Use the right input type or a custom value accessor.
3. patchValue versus setValue
setValue requires every key (typed as the full value); patchValue accepts Partial. Loading a record with missing optional keys needs patchValue, or defaults applied first.
4. Errors typed as any
control.errors is ValidationErrors | null, a string-keyed record of any. Wrap access in a helper that maps known keys (required, minlength, minAge) to messages, so templates never read raw error objects.
5. Signals interop
Typed controls expose typed valueChanges, which convert cleanly to signals with toSignal — the basis of bridging Angular signals and reactive forms.
Verification checklist
Frequently Asked Questions
Why did the migration give me UntypedFormGroup everywhere?
The automatic migration preserved existing behaviour by switching to the untyped aliases, so nothing broke at upgrade time. Replacing them with typed constructors, form by form, is the intended follow-up.
Is nonNullable the same as Validators.required?
No. nonNullable affects the type and what reset() restores; it does not make the field required. An empty string is a valid string. Use Validators.required for “must be filled in”.
Can I infer the form type from a Zod schema instead?
You can derive a TypeScript type with z.infer and assert that getRawValue() matches it with a satisfies check. The two stay in sync if you also validate the raw value with the schema before submitting, which catches template–type mismatches at runtime.